Complete legacy operator UI and playout migration
This commit is contained in:
691
Web/named-playlist-workflow.js
Normal file
691
Web/named-playlist-workflow.js
Normal file
@@ -0,0 +1,691 @@
|
||||
(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
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user