692 lines
27 KiB
JavaScript
692 lines
27 KiB
JavaScript
(function (root, factory) {
|
|
"use strict";
|
|
|
|
const api = Object.freeze(factory());
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
if (root) root.MbnNamedPlaylistWorkflow = api;
|
|
})(typeof globalThis === "object" ? globalThis : this, function () {
|
|
"use strict";
|
|
|
|
const SCHEMA_VERSION = 1;
|
|
const MAXIMUM_DEFINITIONS = 1000;
|
|
const MAXIMUM_ITEMS = 1000;
|
|
const MAXIMUM_PAGE_COUNT = 20;
|
|
const MAXIMUM_STORED_PAGE_COUNT = 9999;
|
|
const DEFAULT_MAXIMUM_DEFINITIONS = 200;
|
|
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
|
const programCodePattern = /^[0-9]{8}$/;
|
|
const cutCodePattern = /^[0-9]{1,8}$/;
|
|
const builderKeyPattern = /^s[0-9]+$/;
|
|
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
|
const pagedBuilderByCode = Object.freeze({
|
|
"5074": Object.freeze({ builderKey: "s5074", pageSize: 5 }),
|
|
"5077": Object.freeze({ builderKey: "s5077", pageSize: 6 }),
|
|
"5088": Object.freeze({ builderKey: "s5088", pageSize: 12 })
|
|
});
|
|
|
|
const bridgeContract = Object.freeze({
|
|
requests: Object.freeze({
|
|
list: "request-named-playlist-list",
|
|
load: "request-named-playlist-load",
|
|
create: "create-named-playlist",
|
|
replace: "replace-named-playlist",
|
|
delete: "delete-named-playlist"
|
|
}),
|
|
responses: Object.freeze({
|
|
list: "named-playlist-list-results",
|
|
load: "named-playlist-load-results",
|
|
mutation: "named-playlist-mutation-results",
|
|
error: "named-playlist-error"
|
|
}),
|
|
schemaVersion: SCHEMA_VERSION,
|
|
maximumDefinitions: MAXIMUM_DEFINITIONS,
|
|
maximumItems: MAXIMUM_ITEMS,
|
|
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
|
maximumStoredPageCount: MAXIMUM_STORED_PAGE_COUNT
|
|
});
|
|
|
|
const pagePlanBridgeContract = Object.freeze({
|
|
request: "request-playlist-page-plans",
|
|
results: "playlist-page-plans-results",
|
|
error: "playlist-page-plans-error",
|
|
pagedBuilderByCode
|
|
});
|
|
|
|
const operations = Object.freeze(["list", "load", "create", "replace", "delete"]);
|
|
const operationSet = new Set(operations);
|
|
|
|
function isPlainObject(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === Object.prototype || prototype === null;
|
|
}
|
|
|
|
function hasExactKeys(value, expected) {
|
|
if (!isPlainObject(value)) return false;
|
|
const actual = Object.keys(value).sort();
|
|
const sortedExpected = [...expected].sort();
|
|
return actual.length === sortedExpected.length &&
|
|
actual.every((key, index) => key === sortedExpected[index]);
|
|
}
|
|
|
|
function exactText(value, maximumLength, allowEmpty = false, allowCaret = false) {
|
|
if (typeof value !== "string" || value.length > maximumLength ||
|
|
(!allowEmpty && value.length === 0) || value !== value.trim() ||
|
|
controlCharacterPattern.test(value) || (!allowCaret && value.includes("^"))) return null;
|
|
return value;
|
|
}
|
|
|
|
function requestId(value) {
|
|
const text = exactText(value, 128);
|
|
return text && identifierPattern.test(text) ? text : null;
|
|
}
|
|
|
|
function programCode(value) {
|
|
const text = exactText(value, 8);
|
|
return text && programCodePattern.test(text) ? text : null;
|
|
}
|
|
|
|
function isIsoTimestamp(value) {
|
|
return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
|
|
!Number.isNaN(Date.parse(value));
|
|
}
|
|
|
|
function createListRequest(requestIdValue, maximumResults = DEFAULT_MAXIMUM_DEFINITIONS) {
|
|
const id = requestId(requestIdValue);
|
|
if (!id) throw new TypeError("A safe named-playlist request id is required.");
|
|
if (!Number.isInteger(maximumResults) || maximumResults < 1 ||
|
|
maximumResults > MAXIMUM_DEFINITIONS) {
|
|
throw new RangeError(`The named-playlist definition limit must be 1 through ${MAXIMUM_DEFINITIONS}.`);
|
|
}
|
|
return Object.freeze({ requestId: id, maximumResults });
|
|
}
|
|
|
|
function createLoadRequest(requestIdValue, programCodeValue) {
|
|
const id = requestId(requestIdValue);
|
|
const code = programCode(programCodeValue);
|
|
if (!id || !code) throw new TypeError("A safe request id and eight-digit program code are required.");
|
|
return Object.freeze({ requestId: id, programCode: code });
|
|
}
|
|
|
|
function createDefinitionRequest(requestIdValue, programCodeValue, titleValue) {
|
|
const request = createLoadRequest(requestIdValue, programCodeValue);
|
|
const title = exactText(titleValue, 128, false, true);
|
|
if (!title) throw new TypeError("A canonical named-playlist title is required.");
|
|
return Object.freeze({ ...request, title });
|
|
}
|
|
|
|
function createDeleteRequest(requestIdValue, programCodeValue) {
|
|
return createLoadRequest(requestIdValue, programCodeValue);
|
|
}
|
|
|
|
function parseStoredPageText(value) {
|
|
if (value === "") return null;
|
|
if (typeof value !== "string" || !/^[1-9][0-9]{0,3}\/(?:0|[1-9][0-9]{0,3})$/.test(value)) {
|
|
return null;
|
|
}
|
|
const [currentText, totalText] = value.split("/");
|
|
const currentPage = Number(currentText);
|
|
const totalPages = Number(totalText);
|
|
if (totalPages > MAXIMUM_STORED_PAGE_COUNT ||
|
|
(totalPages === 0 ? currentPage !== 1 : currentPage > totalPages)) return null;
|
|
const isWithinPlayoutBounds = totalPages >= 1 &&
|
|
totalPages <= MAXIMUM_PAGE_COUNT && currentPage <= totalPages;
|
|
return Object.freeze({
|
|
pageText: value,
|
|
currentPage,
|
|
totalPages,
|
|
isWithinPlayoutBounds,
|
|
historicalPageOutOfBounds: !isWithinPlayoutBounds,
|
|
pageRecalculationRequired: true
|
|
});
|
|
}
|
|
|
|
function normalizeSelection(value) {
|
|
if (!hasExactKeys(value, ["groupCode", "subject", "graphicType", "subtype", "dataCode"])) {
|
|
return null;
|
|
}
|
|
const groupCode = exactText(value.groupCode, 256, true);
|
|
const subject = exactText(value.subject, 256, true);
|
|
const graphicType = exactText(value.graphicType, 256, true);
|
|
const subtype = exactText(value.subtype, 256, true);
|
|
const dataCode = exactText(value.dataCode, 256, true);
|
|
if ([groupCode, subject, graphicType, subtype, dataCode].some(field => field === null)) return null;
|
|
return Object.freeze({ groupCode, subject, graphicType, subtype, dataCode });
|
|
}
|
|
|
|
function normalizePageTextForSave(value) {
|
|
if (value === "") return "";
|
|
const page = parseStoredPageText(value);
|
|
if (!page) throw new RangeError("The stored named-playlist page text is invalid.");
|
|
return page.pageText;
|
|
}
|
|
|
|
function toLegacySevenFields(entry, pageTextValue = "1/1") {
|
|
if (!isPlainObject(entry) || typeof entry.enabled !== "boolean") {
|
|
throw new TypeError("A typed playlist entry with an enabled flag is required.");
|
|
}
|
|
const selection = normalizeSelection(entry.selection);
|
|
if (!selection) throw new TypeError("The playlist selection cannot be stored safely.");
|
|
const pageText = normalizePageTextForSave(pageTextValue);
|
|
return Object.freeze([
|
|
entry.enabled ? "1" : "0",
|
|
selection.groupCode,
|
|
selection.subject,
|
|
selection.graphicType,
|
|
selection.subtype,
|
|
pageText,
|
|
selection.dataCode
|
|
]);
|
|
}
|
|
|
|
function legacyListText(fieldsValue) {
|
|
if (!Array.isArray(fieldsValue) || fieldsValue.length !== 7 ||
|
|
fieldsValue.some(field => typeof field !== "string" || field.includes("^") ||
|
|
controlCharacterPattern.test(field)) ||
|
|
!["0", "1"].includes(fieldsValue[0]) ||
|
|
fieldsValue.slice(1, 5).some(field => exactText(field, 256, true) === null) ||
|
|
exactText(fieldsValue[6], 256, true) === null ||
|
|
(fieldsValue[5] !== "" && !parseStoredPageText(fieldsValue[5]))) {
|
|
throw new TypeError("Exactly seven safe legacy playlist fields are required.");
|
|
}
|
|
const value = fieldsValue.join("^");
|
|
if (value.length > 4000) throw new RangeError("The legacy LIST_TEXT value is too long.");
|
|
return value;
|
|
}
|
|
|
|
function createReplaceRequest(
|
|
requestIdValue,
|
|
programCodeValue,
|
|
playlistEntries,
|
|
options = {}) {
|
|
const request = createLoadRequest(requestIdValue, programCodeValue);
|
|
if (!Array.isArray(playlistEntries) || playlistEntries.length > MAXIMUM_ITEMS) {
|
|
throw new RangeError(`A named playlist can contain at most ${MAXIMUM_ITEMS} entries.`);
|
|
}
|
|
if (!isPlainObject(options) || typeof options.isTrustedEntry !== "function") {
|
|
throw new TypeError("A trusted-entry verifier is required before database storage.");
|
|
}
|
|
if (options.pageTextForEntry !== undefined && typeof options.pageTextForEntry !== "function") {
|
|
throw new TypeError("pageTextForEntry must be a function when supplied.");
|
|
}
|
|
|
|
const items = playlistEntries.map((entry, itemIndex) => {
|
|
if (options.isTrustedEntry(entry, itemIndex) !== true) {
|
|
throw new TypeError(`Playlist entry ${itemIndex} did not pass trusted restoration.`);
|
|
}
|
|
const pageText = options.pageTextForEntry
|
|
? options.pageTextForEntry(entry, itemIndex)
|
|
: "1/1";
|
|
const fields = toLegacySevenFields(entry, pageText);
|
|
legacyListText(fields);
|
|
return Object.freeze({
|
|
itemIndex,
|
|
enabled: fields[0] === "1",
|
|
groupCode: fields[1],
|
|
subject: fields[2],
|
|
graphicType: fields[3],
|
|
subtype: fields[4],
|
|
pageText: fields[5],
|
|
dataCode: fields[6]
|
|
});
|
|
});
|
|
return Object.freeze({ ...request, items: Object.freeze(items) });
|
|
}
|
|
|
|
function normalizeDefinition(value) {
|
|
if (!hasExactKeys(value, ["programCode", "title"])) return null;
|
|
const code = programCode(value.programCode);
|
|
const title = exactText(value.title, 128, false, true);
|
|
return code && title ? Object.freeze({ programCode: code, title }) : null;
|
|
}
|
|
|
|
function normalizeListResponse(value) {
|
|
const keys = [
|
|
"requestId", "retrievedAt", "totalRowCount", "truncated",
|
|
"suggestedProgramCode", "canMutate", "writeQuarantined", "definitions"
|
|
];
|
|
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
|
|
!isIsoTimestamp(value.retrievedAt) || !Number.isInteger(value.totalRowCount) ||
|
|
value.totalRowCount < 0 || typeof value.truncated !== "boolean" ||
|
|
!programCode(value.suggestedProgramCode) || typeof value.canMutate !== "boolean" ||
|
|
typeof value.writeQuarantined !== "boolean" || !Array.isArray(value.definitions) ||
|
|
value.definitions.length !== value.totalRowCount ||
|
|
value.definitions.length > MAXIMUM_DEFINITIONS ||
|
|
(value.writeQuarantined && value.canMutate)) return null;
|
|
const definitions = [];
|
|
const codes = new Set();
|
|
for (const raw of value.definitions) {
|
|
const definition = normalizeDefinition(raw);
|
|
if (!definition || codes.has(definition.programCode)) return null;
|
|
codes.add(definition.programCode);
|
|
definitions.push(definition);
|
|
}
|
|
return Object.freeze({
|
|
requestId: value.requestId,
|
|
retrievedAt: value.retrievedAt,
|
|
totalRowCount: value.totalRowCount,
|
|
truncated: value.truncated,
|
|
suggestedProgramCode: value.suggestedProgramCode,
|
|
canMutate: value.canMutate,
|
|
writeQuarantined: value.writeQuarantined,
|
|
definitions: Object.freeze(definitions)
|
|
});
|
|
}
|
|
|
|
function normalizeRawRow(value, expectedIndex) {
|
|
const keys = [
|
|
"itemIndex", "enabled", "groupCode", "subject", "graphicType",
|
|
"subtype", "pageText", "dataCode"
|
|
];
|
|
if (!hasExactKeys(value, keys) || value.itemIndex !== expectedIndex ||
|
|
typeof value.enabled !== "boolean") return null;
|
|
const selection = normalizeSelection({
|
|
groupCode: value.groupCode,
|
|
subject: value.subject,
|
|
graphicType: value.graphicType,
|
|
subtype: value.subtype,
|
|
dataCode: value.dataCode
|
|
});
|
|
if (!selection || typeof value.pageText !== "string" || value.pageText.length > 9) return null;
|
|
const pageState = value.pageText === "" ? null : parseStoredPageText(value.pageText);
|
|
if (value.pageText !== "" && !pageState) return null;
|
|
const legacyFields = Object.freeze([
|
|
value.enabled ? "1" : "0",
|
|
selection.groupCode,
|
|
selection.subject,
|
|
selection.graphicType,
|
|
selection.subtype,
|
|
value.pageText,
|
|
selection.dataCode
|
|
]);
|
|
const listText = legacyListText(legacyFields);
|
|
return Object.freeze({
|
|
itemIndex: value.itemIndex,
|
|
enabled: value.enabled,
|
|
groupCode: selection.groupCode,
|
|
subject: selection.subject,
|
|
graphicType: selection.graphicType,
|
|
subtype: selection.subtype,
|
|
pageText: value.pageText,
|
|
dataCode: selection.dataCode,
|
|
legacyFields,
|
|
listText,
|
|
pageState,
|
|
requiresTrustedRestore: true,
|
|
pageRecalculationRequired: pageState !== null,
|
|
historicalPageOutOfBounds: pageState?.historicalPageOutOfBounds === true
|
|
});
|
|
}
|
|
|
|
function normalizeLoadResponse(value) {
|
|
const keys = [
|
|
"requestId", "definition", "retrievedAt", "totalRowCount",
|
|
"canMutate", "writeQuarantined", "rows"
|
|
];
|
|
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
|
|
!isIsoTimestamp(value.retrievedAt) || !Number.isInteger(value.totalRowCount) ||
|
|
value.totalRowCount < 0 || value.totalRowCount > MAXIMUM_ITEMS ||
|
|
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
|
|
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.rows) ||
|
|
value.rows.length !== value.totalRowCount) return null;
|
|
const definition = normalizeDefinition(value.definition);
|
|
if (!definition) return null;
|
|
const rawRows = [];
|
|
for (let index = 0; index < value.rows.length; index += 1) {
|
|
const row = normalizeRawRow(value.rows[index], index);
|
|
if (!row) return null;
|
|
rawRows.push(row);
|
|
}
|
|
return Object.freeze({
|
|
kind: "named-playlist-load",
|
|
schemaVersion: SCHEMA_VERSION,
|
|
requestId: value.requestId,
|
|
definition,
|
|
retrievedAt: value.retrievedAt,
|
|
totalRowCount: value.totalRowCount,
|
|
canMutate: value.canMutate,
|
|
writeQuarantined: value.writeQuarantined,
|
|
rawRows: Object.freeze(rawRows)
|
|
});
|
|
}
|
|
|
|
function sameSelection(entry, rawRow) {
|
|
const selection = normalizeSelection(entry?.selection);
|
|
return selection !== null && selection.groupCode === rawRow.groupCode &&
|
|
selection.subject === rawRow.subject && selection.graphicType === rawRow.graphicType &&
|
|
selection.subtype === rawRow.subtype && selection.dataCode === rawRow.dataCode;
|
|
}
|
|
|
|
function freezeTrustedEntry(entry) {
|
|
const copy = {
|
|
...entry,
|
|
selection: Object.freeze({ ...entry.selection })
|
|
};
|
|
if (Array.isArray(entry.aliases)) copy.aliases = Object.freeze([...entry.aliases]);
|
|
if (isPlainObject(entry.operator)) copy.operator = Object.freeze({ ...entry.operator });
|
|
return Object.freeze(copy);
|
|
}
|
|
|
|
function trustedEntryMatchesRaw(entry, rawRow) {
|
|
return isPlainObject(entry) && requestId(entry.id) &&
|
|
typeof entry.builderKey === "string" && builderKeyPattern.test(entry.builderKey) &&
|
|
typeof entry.code === "string" && cutCodePattern.test(entry.code) &&
|
|
entry.enabled === rawRow.enabled && sameSelection(entry, rawRow);
|
|
}
|
|
|
|
function restoreRawRows(loadValue, trustedResolver) {
|
|
const load = loadValue?.kind === "named-playlist-load"
|
|
? loadValue
|
|
: normalizeLoadResponse(loadValue);
|
|
if (!load || typeof trustedResolver !== "function") {
|
|
throw new TypeError("A normalized load response and trusted resolver are required.");
|
|
}
|
|
const rows = load.rawRows.map(rawRow => {
|
|
let candidate = null;
|
|
try {
|
|
candidate = trustedResolver(rawRow, rawRow.itemIndex);
|
|
} catch {
|
|
candidate = null;
|
|
}
|
|
const trusted = trustedEntryMatchesRaw(candidate, rawRow);
|
|
return Object.freeze({
|
|
rawRow,
|
|
entry: trusted ? freezeTrustedEntry(candidate) : null,
|
|
trusted,
|
|
pageRecalculationRequired: rawRow.pageRecalculationRequired,
|
|
pageRecalculated: !rawRow.pageRecalculationRequired,
|
|
pagePreflightCompleted: false,
|
|
pageUnavailableReason: null,
|
|
recalculatedPagePlan: null
|
|
});
|
|
});
|
|
return freezeRestoration(load.definition, rows);
|
|
}
|
|
|
|
function preparePagePreflight(restoration, isPagedEntry) {
|
|
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
|
|
typeof isPagedEntry !== "function") {
|
|
throw new TypeError("A valid restoration and paged-entry classifier are required.");
|
|
}
|
|
const rows = restoration.rows.map(row => {
|
|
const paged = row.trusted && isPagedEntry(row.entry, row.rawRow.itemIndex) === true;
|
|
return Object.freeze({
|
|
...row,
|
|
pageRecalculationRequired: paged,
|
|
pageRecalculated: !paged,
|
|
pagePreflightCompleted: false,
|
|
pageUnavailableReason: null,
|
|
recalculatedPagePlan: null
|
|
});
|
|
});
|
|
return freezeRestoration(restoration.definition, rows);
|
|
}
|
|
|
|
function pagePlanEntry(entry) {
|
|
const definition = pagedBuilderByCode[entry?.code];
|
|
const selection = normalizeSelection(entry?.selection);
|
|
const fadeDuration = Number(entry?.fadeDuration);
|
|
if (!definition || definition.builderKey !== entry?.builderKey ||
|
|
!requestId(entry?.id) || typeof entry?.enabled !== "boolean" ||
|
|
!selection || !Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
|
return null;
|
|
}
|
|
return Object.freeze({
|
|
id: entry.id,
|
|
code: entry.code,
|
|
enabled: entry.enabled,
|
|
fadeDuration,
|
|
selection
|
|
});
|
|
}
|
|
|
|
function createPagePlanRequest(requestIdValue, restoration) {
|
|
const id = requestId(requestIdValue);
|
|
if (!id || !restoration || restoration.kind !== "named-playlist-restoration") {
|
|
throw new TypeError("A safe request id and trusted restoration are required.");
|
|
}
|
|
const entries = [];
|
|
const identifiers = new Set();
|
|
for (const row of restoration.rows) {
|
|
if (!row.trusted || !row.pageRecalculationRequired) continue;
|
|
const entry = pagePlanEntry(row.entry);
|
|
if (!entry || identifiers.has(entry.id)) {
|
|
throw new TypeError("A paged restoration entry is not safe for page preflight.");
|
|
}
|
|
identifiers.add(entry.id);
|
|
entries.push(entry);
|
|
}
|
|
if (!entries.length) return null;
|
|
return Object.freeze({ requestId: id, entries: Object.freeze(entries) });
|
|
}
|
|
|
|
function normalizePagePlanResult(value, expectedEntry) {
|
|
const keys = [
|
|
"entryId", "itemCount", "pageSize", "pageCount",
|
|
"accessibleItemCount", "isTruncated", "builderKey"
|
|
];
|
|
const definition = pagedBuilderByCode[expectedEntry?.code];
|
|
if (!hasExactKeys(value, keys) || !definition ||
|
|
value.entryId !== expectedEntry.id || value.builderKey !== definition.builderKey ||
|
|
value.pageSize !== definition.pageSize ||
|
|
!Number.isSafeInteger(value.itemCount) || value.itemCount < 0 ||
|
|
!Number.isInteger(value.pageCount) || value.pageCount < 0 ||
|
|
value.pageCount > MAXIMUM_PAGE_COUNT ||
|
|
!Number.isSafeInteger(value.accessibleItemCount) || value.accessibleItemCount < 0 ||
|
|
typeof value.isTruncated !== "boolean") return null;
|
|
|
|
const expectedPageCount = value.itemCount === 0
|
|
? 0
|
|
: Math.min(MAXIMUM_PAGE_COUNT, Math.ceil(value.itemCount / value.pageSize));
|
|
const expectedAccessible = Math.min(
|
|
value.itemCount,
|
|
expectedPageCount * value.pageSize);
|
|
if (value.pageCount !== expectedPageCount ||
|
|
value.accessibleItemCount !== expectedAccessible ||
|
|
value.isTruncated !== (value.itemCount > expectedAccessible)) return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function normalizePagePlanResponse(value, expectedRequest) {
|
|
if (!expectedRequest || !hasExactKeys(expectedRequest, ["requestId", "entries"]) ||
|
|
!hasExactKeys(value, ["requestId", "calculatedAt", "plans"]) ||
|
|
value.requestId !== expectedRequest.requestId || !isIsoTimestamp(value.calculatedAt) ||
|
|
!Array.isArray(value.plans) || value.plans.length !== expectedRequest.entries.length) {
|
|
return null;
|
|
}
|
|
const plans = [];
|
|
for (let index = 0; index < expectedRequest.entries.length; index += 1) {
|
|
const plan = normalizePagePlanResult(value.plans[index], expectedRequest.entries[index]);
|
|
if (!plan) return null;
|
|
plans.push(plan);
|
|
}
|
|
return Object.freeze({
|
|
requestId: value.requestId,
|
|
calculatedAt: value.calculatedAt,
|
|
plans: Object.freeze(plans)
|
|
});
|
|
}
|
|
|
|
function normalizePagePlanError(value, expectedRequestId) {
|
|
if (!hasExactKeys(value, ["requestId", "code", "message", "retryable"]) ||
|
|
value.requestId !== expectedRequestId || !requestId(value.requestId) ||
|
|
!exactText(value.code, 64) || !exactText(value.message, 1024) ||
|
|
typeof value.retryable !== "boolean") return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function normalizePagePlan(value) {
|
|
if (!hasExactKeys(value, ["itemCount", "pageSize", "pageCount"]) ||
|
|
!Number.isInteger(value.itemCount) || value.itemCount < 0 ||
|
|
!Number.isInteger(value.pageSize) || value.pageSize < 0 || value.pageSize > 12 ||
|
|
!Number.isInteger(value.pageCount) || value.pageCount < 1 ||
|
|
value.pageCount > MAXIMUM_PAGE_COUNT) return null;
|
|
return Object.freeze({
|
|
itemCount: value.itemCount,
|
|
pageSize: value.pageSize,
|
|
pageCount: value.pageCount
|
|
});
|
|
}
|
|
|
|
function markPageRecalculated(restoration, itemIndex, pagePlanValue) {
|
|
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
|
|
!Number.isInteger(itemIndex) || itemIndex < 0 || itemIndex >= restoration.rows.length) {
|
|
throw new TypeError("A valid restoration row is required.");
|
|
}
|
|
const pagePlan = normalizePagePlan(pagePlanValue);
|
|
if (!pagePlan) throw new RangeError("The recalculated page plan is invalid or exceeds 20 pages.");
|
|
const rows = restoration.rows.map((row, index) => index === itemIndex
|
|
? Object.freeze({
|
|
...row,
|
|
pageRecalculated: true,
|
|
pagePreflightCompleted: true,
|
|
pageUnavailableReason: null,
|
|
recalculatedPagePlan: pagePlan
|
|
})
|
|
: row);
|
|
return freezeRestoration(restoration.definition, rows);
|
|
}
|
|
|
|
function applyPagePlanResponse(restoration, response) {
|
|
if (!restoration || restoration.kind !== "named-playlist-restoration" ||
|
|
!response || !Array.isArray(response.plans)) {
|
|
throw new TypeError("A valid restoration and normalized page-plan response are required.");
|
|
}
|
|
let current = restoration;
|
|
for (const plan of response.plans) {
|
|
const itemIndex = current.rows.findIndex(row =>
|
|
row.trusted && row.entry?.id === plan.entryId &&
|
|
row.entry?.builderKey === plan.builderKey && row.pageRecalculationRequired);
|
|
if (itemIndex < 0) throw new TypeError("The page-plan result does not match the restoration.");
|
|
if (plan.itemCount === 0) {
|
|
const rows = current.rows.map((row, index) => index === itemIndex
|
|
? Object.freeze({
|
|
...row,
|
|
pageRecalculated: false,
|
|
pagePreflightCompleted: true,
|
|
pageUnavailableReason: "empty-result",
|
|
recalculatedPagePlan: Object.freeze({ ...plan })
|
|
})
|
|
: row);
|
|
current = freezeRestoration(current.definition, rows);
|
|
continue;
|
|
}
|
|
current = markPageRecalculated(current, itemIndex, {
|
|
itemCount: plan.itemCount,
|
|
pageSize: plan.pageSize,
|
|
pageCount: plan.pageCount
|
|
});
|
|
const rows = current.rows.map((row, index) => index === itemIndex
|
|
? Object.freeze({ ...row, recalculatedPagePlan: Object.freeze({ ...plan }) })
|
|
: row);
|
|
current = freezeRestoration(current.definition, rows);
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function prepareBlockers(restoration) {
|
|
if (!restoration || restoration.kind !== "named-playlist-restoration") {
|
|
return Object.freeze([Object.freeze({ itemIndex: -1, reason: "invalid-restoration" })]);
|
|
}
|
|
const blockers = [];
|
|
for (const row of restoration.rows) {
|
|
if (!row.trusted) blockers.push(Object.freeze({
|
|
itemIndex: row.rawRow.itemIndex,
|
|
reason: "trusted-restore-required"
|
|
}));
|
|
if (row.pageRecalculationRequired && !row.pageRecalculated) blockers.push(Object.freeze({
|
|
itemIndex: row.rawRow.itemIndex,
|
|
reason: row.pagePreflightCompleted && row.pageUnavailableReason === "empty-result"
|
|
? "page-result-empty"
|
|
: "page-recalculation-required"
|
|
}));
|
|
}
|
|
return Object.freeze(blockers);
|
|
}
|
|
|
|
function freezeRestoration(definition, rowsValue) {
|
|
const rows = Object.freeze([...rowsValue]);
|
|
const provisional = { kind: "named-playlist-restoration", schemaVersion: SCHEMA_VERSION, definition, rows };
|
|
const blockers = prepareBlockers(provisional);
|
|
return Object.freeze({ ...provisional, blockers, readyForPrepare: blockers.length === 0 });
|
|
}
|
|
|
|
function canPrepareRestoredRows(restoration) {
|
|
return prepareBlockers(restoration).length === 0;
|
|
}
|
|
|
|
function materializeTrustedPlaylist(restoration) {
|
|
if (!canPrepareRestoredRows(restoration)) {
|
|
throw new Error("Trusted restoration and fresh page calculation are required before PREPARE.");
|
|
}
|
|
return Object.freeze(restoration.rows.map(row => row.entry));
|
|
}
|
|
|
|
function pageStatusLabel(rawRow) {
|
|
if (!rawRow || rawRow.requiresTrustedRestore !== true) return "Invalid stored row";
|
|
if (!rawRow.pageState) return "No stored page; trusted restoration is required";
|
|
if (rawRow.historicalPageOutOfBounds) {
|
|
return `Stored page ${rawRow.pageText}; recalculate before PREPARE (20-page maximum)`;
|
|
}
|
|
return `Stored page ${rawRow.pageText}; recalculate before PREPARE`;
|
|
}
|
|
|
|
function normalizeMutationResponse(value) {
|
|
const keys = ["requestId", "operation", "programCode", "completedAt", "writeQuarantined"];
|
|
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
|
|
!["create", "replace", "delete"].includes(value.operation) ||
|
|
!programCode(value.programCode) || !isIsoTimestamp(value.completedAt) ||
|
|
typeof value.writeQuarantined !== "boolean") return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function normalizeError(value) {
|
|
const keys = [
|
|
"requestId", "operation", "programCode", "code", "message",
|
|
"retryable", "outcomeUnknown", "writeQuarantined"
|
|
];
|
|
if (!hasExactKeys(value, keys) || !requestId(value.requestId) ||
|
|
!operationSet.has(value.operation) ||
|
|
(value.programCode !== "" && !programCode(value.programCode)) ||
|
|
!exactText(value.code, 64) || !exactText(value.message, 1024) ||
|
|
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
|
|
typeof value.writeQuarantined !== "boolean" ||
|
|
(value.outcomeUnknown && (value.retryable || !value.writeQuarantined))) return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function isWriteBlocked(value) {
|
|
return value?.writeQuarantined === true;
|
|
}
|
|
|
|
return {
|
|
bridgeContract,
|
|
pagePlanBridgeContract,
|
|
operations,
|
|
createListRequest,
|
|
createLoadRequest,
|
|
createDefinitionRequest,
|
|
createReplaceRequest,
|
|
createDeleteRequest,
|
|
parseStoredPageText,
|
|
toLegacySevenFields,
|
|
legacyListText,
|
|
normalizeListResponse,
|
|
normalizeLoadResponse,
|
|
restoreRawRows,
|
|
preparePagePreflight,
|
|
createPagePlanRequest,
|
|
normalizePagePlanResponse,
|
|
normalizePagePlanError,
|
|
applyPagePlanResponse,
|
|
markPageRecalculated,
|
|
prepareBlockers,
|
|
canPrepareRestoredRows,
|
|
materializeTrustedPlaylist,
|
|
pageStatusLabel,
|
|
normalizeMutationResponse,
|
|
normalizeError,
|
|
isWriteBlocked
|
|
};
|
|
});
|