Complete legacy operator UI and playout migration
This commit is contained in:
761
Web/operator-catalog-workflow.js
Normal file
761
Web/operator-catalog-workflow.js
Normal file
@@ -0,0 +1,761 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnOperatorCatalogWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const MAXIMUM_THEME_ITEMS = 240;
|
||||
const MAXIMUM_RECOMMENDATIONS = 100;
|
||||
const MAXIMUM_RESULTS = 500;
|
||||
const MAXIMUM_QUERY_LENGTH = 64;
|
||||
const MAXIMUM_WEB_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
|
||||
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const themeCodePattern = /^[0-9]{8}$/;
|
||||
const expertCodePattern = /^[0-9]{4}$/;
|
||||
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
requests: Object.freeze({
|
||||
themeList: "request-theme-catalog-list",
|
||||
expertList: "request-expert-catalog-list",
|
||||
schemaPreflight: "request-operator-catalog-schema",
|
||||
themeNextCode: "request-theme-catalog-next-code",
|
||||
expertNextCode: "request-expert-catalog-next-code",
|
||||
themeCreate: "create-theme-catalog",
|
||||
themeReplace: "replace-theme-catalog",
|
||||
themeDelete: "delete-theme-catalog",
|
||||
expertCreate: "create-expert-catalog",
|
||||
expertReplace: "replace-expert-catalog",
|
||||
expertDelete: "delete-expert-catalog"
|
||||
}),
|
||||
events: Object.freeze({
|
||||
themeList: "theme-catalog-list-results",
|
||||
expertList: "expert-catalog-list-results",
|
||||
schemaPreflight: "operator-catalog-schema-results",
|
||||
themeNextCode: "theme-catalog-next-code-results",
|
||||
expertNextCode: "expert-catalog-next-code-results",
|
||||
mutation: "operator-catalog-mutation-results",
|
||||
error: "operator-catalog-error"
|
||||
})
|
||||
});
|
||||
|
||||
function hasExactKeys(value, keys) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length &&
|
||||
actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value, keys) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) &&
|
||||
Object.keys(value).every(key => keys.includes(key));
|
||||
}
|
||||
|
||||
function isSafeCanonicalText(value, maximumLength, allowEmpty = false) {
|
||||
return typeof value === "string" &&
|
||||
(allowEmpty ? value.length >= 0 : value.length > 0) &&
|
||||
value.length <= maximumLength && value === value.trim() &&
|
||||
!unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function requireRequestId(value) {
|
||||
if (typeof value !== "string" || !requestIdPattern.test(value)) {
|
||||
throw new TypeError("A safe operator-catalog request id is required.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeQuery(value) {
|
||||
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
|
||||
const query = value.trim();
|
||||
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query)
|
||||
? query
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeMarket(value) {
|
||||
return value === "krx" || value === "nxt" ? value : null;
|
||||
}
|
||||
|
||||
function normalizeSession(value, market) {
|
||||
if (market === "krx") return value === "notApplicable" ? value : null;
|
||||
if (market === "nxt") {
|
||||
return value === "preMarket" || value === "afterMarket" ? value : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeThemeIdentity(value) {
|
||||
if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle"])) return null;
|
||||
const market = normalizeMarket(value.market);
|
||||
if (!market || !normalizeSession(value.session, market) ||
|
||||
!themeCodePattern.test(value.themeCode) ||
|
||||
!isSafeCanonicalText(value.themeTitle, 128) ||
|
||||
(market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null;
|
||||
return Object.freeze({
|
||||
market,
|
||||
session: value.session,
|
||||
themeCode: value.themeCode,
|
||||
themeTitle: value.themeTitle
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeThemeItem(value, expectedIndex, market) {
|
||||
if (!hasExactKeys(value, ["inputIndex", "itemCode", "itemName"]) ||
|
||||
value.inputIndex !== expectedIndex || !Number.isInteger(value.inputIndex) ||
|
||||
!isSafeCanonicalText(value.itemCode, 13) ||
|
||||
!isSafeCanonicalText(value.itemName, 200)) return null;
|
||||
const prefix = value.itemCode[0];
|
||||
const suffix = value.itemCode.slice(1);
|
||||
if (!/^[A-Za-z0-9]{1,12}$/.test(suffix) ||
|
||||
(market === "krx" && prefix !== "P" && prefix !== "D") ||
|
||||
(market === "nxt" && prefix !== "X" && prefix !== "T")) return null;
|
||||
return Object.freeze({
|
||||
inputIndex: value.inputIndex,
|
||||
itemCode: value.itemCode,
|
||||
itemName: value.itemName
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeThemeItems(value, market) {
|
||||
if (!Array.isArray(value) || value.length > MAXIMUM_THEME_ITEMS) return null;
|
||||
const items = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = normalizeThemeItem(value[index], index, market);
|
||||
if (!item || codes.has(item.itemCode) || names.has(item.itemName)) return null;
|
||||
codes.add(item.itemCode);
|
||||
names.add(item.itemName);
|
||||
items.push(item);
|
||||
}
|
||||
return Object.freeze(items);
|
||||
}
|
||||
|
||||
function normalizeThemeDraft(value) {
|
||||
if (!hasExactKeys(value, ["market", "themeCode", "themeTitle", "items"])) return null;
|
||||
const market = normalizeMarket(value.market);
|
||||
if (!market || !themeCodePattern.test(value.themeCode) ||
|
||||
!isSafeCanonicalText(value.themeTitle, 128) ||
|
||||
(market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null;
|
||||
const items = normalizeThemeItems(value.items, market);
|
||||
return items && Object.freeze({
|
||||
market,
|
||||
themeCode: value.themeCode,
|
||||
themeTitle: value.themeTitle,
|
||||
items
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExpertIdentity(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName"]) ||
|
||||
!expertCodePattern.test(value.expertCode) ||
|
||||
!isSafeCanonicalText(value.expertName, 128)) return null;
|
||||
return Object.freeze({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRecommendation(value, expectedIndex) {
|
||||
if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
|
||||
value.playIndex !== expectedIndex || !Number.isInteger(value.playIndex) ||
|
||||
!stockCodePattern.test(value.stockCode) ||
|
||||
!isSafeCanonicalText(value.stockName, 200) ||
|
||||
!Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0 ||
|
||||
value.buyAmount > MAXIMUM_WEB_SAFE_INTEGER) return null;
|
||||
return Object.freeze({
|
||||
playIndex: value.playIndex,
|
||||
stockCode: value.stockCode,
|
||||
stockName: value.stockName,
|
||||
buyAmount: value.buyAmount
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRecommendations(value) {
|
||||
if (!Array.isArray(value) || value.length > MAXIMUM_RECOMMENDATIONS) return null;
|
||||
const items = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = normalizeRecommendation(value[index], index);
|
||||
if (!item || codes.has(item.stockCode) || names.has(item.stockName)) return null;
|
||||
codes.add(item.stockCode);
|
||||
names.add(item.stockName);
|
||||
items.push(item);
|
||||
}
|
||||
return Object.freeze(items);
|
||||
}
|
||||
|
||||
function normalizeExpertDraft(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName", "recommendations"])) return null;
|
||||
const identity = normalizeExpertIdentity({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
const recommendations = normalizeRecommendations(value.recommendations);
|
||||
return identity && recommendations && Object.freeze({
|
||||
expertCode: identity.expertCode,
|
||||
expertName: identity.expertName,
|
||||
recommendations
|
||||
});
|
||||
}
|
||||
|
||||
function createThemeCatalogListRequest(requestId, query = "", nxtSession = "preMarket", maximumResults) {
|
||||
requireRequestId(requestId);
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
if (normalizedQuery === null) throw new TypeError("A safe theme catalog query is required.");
|
||||
if (nxtSession !== "preMarket" && nxtSession !== "afterMarket") {
|
||||
throw new TypeError("An explicit NXT catalog session is required.");
|
||||
}
|
||||
if (maximumResults !== undefined &&
|
||||
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
||||
throw new RangeError("Theme catalog results must be limited from 1 through 500.");
|
||||
}
|
||||
const request = { requestId, query: normalizedQuery, nxtSession };
|
||||
if (maximumResults !== undefined) request.maximumResults = maximumResults;
|
||||
return Object.freeze(request);
|
||||
}
|
||||
|
||||
function createExpertCatalogListRequest(requestId, query = "", maximumResults) {
|
||||
requireRequestId(requestId);
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
if (normalizedQuery === null) throw new TypeError("A safe expert catalog query is required.");
|
||||
if (maximumResults !== undefined &&
|
||||
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
||||
throw new RangeError("Expert catalog results must be limited from 1 through 500.");
|
||||
}
|
||||
const request = { requestId, query: normalizedQuery };
|
||||
if (maximumResults !== undefined) request.maximumResults = maximumResults;
|
||||
return Object.freeze(request);
|
||||
}
|
||||
|
||||
function createSchemaPreflightRequest(requestId) {
|
||||
return Object.freeze({ requestId: requireRequestId(requestId) });
|
||||
}
|
||||
|
||||
function createThemeNextCodeRequest(requestId, market) {
|
||||
requireRequestId(requestId);
|
||||
if (!normalizeMarket(market)) throw new TypeError("A KRX or NXT market is required.");
|
||||
return Object.freeze({ requestId, market });
|
||||
}
|
||||
|
||||
function createExpertNextCodeRequest(requestId) {
|
||||
return Object.freeze({ requestId: requireRequestId(requestId) });
|
||||
}
|
||||
|
||||
function normalizeThemeCatalogSelection(value) {
|
||||
if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle", "source"])) return null;
|
||||
const identity = normalizeThemeIdentity({
|
||||
market: value.market,
|
||||
session: value.session,
|
||||
themeCode: value.themeCode,
|
||||
themeTitle: value.themeTitle
|
||||
});
|
||||
if (!identity ||
|
||||
(identity.market === "krx" && value.source !== "oracle") ||
|
||||
(identity.market === "nxt" && value.source !== "mariaDb")) return null;
|
||||
return Object.freeze({ ...identity, source: value.source });
|
||||
}
|
||||
|
||||
function normalizeExpertCatalogSelection(value) {
|
||||
if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) || value.source !== "oracle") {
|
||||
return null;
|
||||
}
|
||||
const identity = normalizeExpertIdentity({
|
||||
expertCode: value.expertCode,
|
||||
expertName: value.expertName
|
||||
});
|
||||
return identity && Object.freeze({ ...identity, source: "oracle" });
|
||||
}
|
||||
|
||||
function normalizeThemePreviewForEdit(value, selection) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
|
||||
]) || !requestIdPattern.test(value.requestId) ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_THEME_ITEMS || value.truncated !== false ||
|
||||
!hasExactKeys(value.selection, ["market", "session", "themeCode", "title"])) return null;
|
||||
const previewIdentity = normalizeThemeIdentity({
|
||||
market: value.selection.market,
|
||||
session: value.selection.session,
|
||||
themeCode: value.selection.themeCode,
|
||||
themeTitle: value.selection.title
|
||||
});
|
||||
if (!previewIdentity || ["market", "session", "themeCode", "themeTitle"]
|
||||
.some(key => previewIdentity[key] !== selection[key])) return null;
|
||||
if (!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
|
||||
const items = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
let previousIndex = -1;
|
||||
for (const raw of value.items) {
|
||||
if (!hasExactKeys(raw, ["inputIndex", "itemCode", "itemName"]) ||
|
||||
!Number.isInteger(raw.inputIndex) || raw.inputIndex <= previousIndex || raw.inputIndex > 9999 ||
|
||||
!isSafeCanonicalText(raw.itemCode, 13) || !isSafeCanonicalText(raw.itemName, 200)) return null;
|
||||
const prefix = raw.itemCode[0];
|
||||
const suffix = raw.itemCode.slice(1);
|
||||
if (!/^[A-Za-z0-9]{1,12}$/.test(suffix) ||
|
||||
(selection.market === "krx" && prefix !== "P" && prefix !== "D") ||
|
||||
(selection.market === "nxt" && prefix !== "X" && prefix !== "T") ||
|
||||
codes.has(raw.itemCode) || names.has(raw.itemName)) return null;
|
||||
previousIndex = raw.inputIndex;
|
||||
codes.add(raw.itemCode);
|
||||
names.add(raw.itemName);
|
||||
items.push(Object.freeze({
|
||||
inputIndex: items.length,
|
||||
itemCode: raw.itemCode,
|
||||
itemName: raw.itemName
|
||||
}));
|
||||
}
|
||||
return Object.freeze(items);
|
||||
}
|
||||
|
||||
function normalizeExpertPreviewForEdit(value, selection) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
|
||||
]) || !requestIdPattern.test(value.requestId) ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_RECOMMENDATIONS || value.truncated !== false) return null;
|
||||
const previewIdentity = normalizeExpertIdentity(value.selection);
|
||||
if (!previewIdentity || previewIdentity.expertCode !== selection.expertCode ||
|
||||
previewIdentity.expertName !== selection.expertName) return null;
|
||||
if (!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
|
||||
const items = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
let previousIndex = -1;
|
||||
for (const raw of value.items) {
|
||||
if (!hasExactKeys(raw, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
|
||||
!Number.isInteger(raw.playIndex) || raw.playIndex <= previousIndex || raw.playIndex > 9999 ||
|
||||
!stockCodePattern.test(raw.stockCode) || !isSafeCanonicalText(raw.stockName, 200) ||
|
||||
!Number.isSafeInteger(raw.buyAmount) || raw.buyAmount <= 0 ||
|
||||
codes.has(raw.stockCode) || names.has(raw.stockName)) return null;
|
||||
previousIndex = raw.playIndex;
|
||||
codes.add(raw.stockCode);
|
||||
names.add(raw.stockName);
|
||||
items.push(Object.freeze({
|
||||
playIndex: items.length,
|
||||
stockCode: raw.stockCode,
|
||||
stockName: raw.stockName,
|
||||
buyAmount: raw.buyAmount
|
||||
}));
|
||||
}
|
||||
return Object.freeze(items);
|
||||
}
|
||||
|
||||
function createThemeEditDraft(selectionValue, previewValue) {
|
||||
const selection = normalizeThemeCatalogSelection(selectionValue);
|
||||
if (!selection) throw new TypeError("A typed theme catalog selection is required.");
|
||||
const items = normalizeThemePreviewForEdit(previewValue, selection);
|
||||
if (!items) throw new TypeError("A complete, correlated theme preview is required for editing.");
|
||||
return {
|
||||
expectedIdentity: {
|
||||
market: selection.market,
|
||||
session: selection.session,
|
||||
themeCode: selection.themeCode,
|
||||
themeTitle: selection.themeTitle
|
||||
},
|
||||
draft: {
|
||||
market: selection.market,
|
||||
themeCode: selection.themeCode,
|
||||
themeTitle: selection.themeTitle,
|
||||
items: items.map(item => ({ ...item }))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createExpertEditDraft(selectionValue, previewValue) {
|
||||
const selection = normalizeExpertCatalogSelection(selectionValue);
|
||||
if (!selection) throw new TypeError("A typed expert catalog selection is required.");
|
||||
const recommendations = normalizeExpertPreviewForEdit(previewValue, selection);
|
||||
if (!recommendations) throw new TypeError("A complete, correlated expert preview is required for editing.");
|
||||
return {
|
||||
expectedIdentity: {
|
||||
expertCode: selection.expertCode,
|
||||
expertName: selection.expertName
|
||||
},
|
||||
draft: {
|
||||
expertCode: selection.expertCode,
|
||||
expertName: selection.expertName,
|
||||
recommendations: recommendations.map(item => ({ ...item }))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createBlankThemeDraft(market, themeCode) {
|
||||
if (!normalizeMarket(market) || !themeCodePattern.test(themeCode)) {
|
||||
throw new TypeError("A typed market and suggested eight-digit theme code are required.");
|
||||
}
|
||||
return { market, themeCode, themeTitle: "", items: [] };
|
||||
}
|
||||
|
||||
function createBlankExpertDraft(expertCode) {
|
||||
if (!expertCodePattern.test(expertCode)) {
|
||||
throw new TypeError("A suggested four-digit expert code is required.");
|
||||
}
|
||||
return { expertCode, expertName: "", recommendations: [] };
|
||||
}
|
||||
|
||||
function cloneThemeDraft(draft) {
|
||||
const normalized = normalizeThemeDraft(draft);
|
||||
return normalized && {
|
||||
market: normalized.market,
|
||||
themeCode: normalized.themeCode,
|
||||
themeTitle: normalized.themeTitle,
|
||||
items: normalized.items.map(item => ({ ...item }))
|
||||
};
|
||||
}
|
||||
|
||||
function cloneExpertDraft(draft) {
|
||||
const normalized = normalizeExpertDraft(draft);
|
||||
return normalized && {
|
||||
expertCode: normalized.expertCode,
|
||||
expertName: normalized.expertName,
|
||||
recommendations: normalized.recommendations.map(item => ({ ...item }))
|
||||
};
|
||||
}
|
||||
|
||||
function createThemeCreateRequest(requestId, draftValue) {
|
||||
requireRequestId(requestId);
|
||||
const draft = cloneThemeDraft(draftValue);
|
||||
if (!draft) throw new TypeError("A complete theme draft is required.");
|
||||
return Object.freeze({ requestId, draft });
|
||||
}
|
||||
|
||||
function createThemeReplaceRequest(requestId, editValue) {
|
||||
requireRequestId(requestId);
|
||||
if (!hasExactKeys(editValue, ["expectedIdentity", "draft"])) {
|
||||
throw new TypeError("An explicit theme edit identity and draft are required.");
|
||||
}
|
||||
const identity = normalizeThemeIdentity(editValue.expectedIdentity);
|
||||
const draft = cloneThemeDraft(editValue.draft);
|
||||
if (!identity || !draft || identity.market !== draft.market ||
|
||||
identity.themeCode !== draft.themeCode) {
|
||||
throw new TypeError("The theme edit identity does not match its draft.");
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId,
|
||||
expectedIdentity: { ...identity },
|
||||
newTitle: draft.themeTitle,
|
||||
items: draft.items
|
||||
});
|
||||
}
|
||||
|
||||
function createThemeDeleteRequest(requestId, identityValue) {
|
||||
requireRequestId(requestId);
|
||||
const identity = normalizeThemeIdentity(identityValue);
|
||||
if (!identity) throw new TypeError("An explicit theme identity is required.");
|
||||
return Object.freeze({ requestId, identity: { ...identity } });
|
||||
}
|
||||
|
||||
function createExpertCreateRequest(requestId, draftValue) {
|
||||
requireRequestId(requestId);
|
||||
const draft = cloneExpertDraft(draftValue);
|
||||
if (!draft) throw new TypeError("A complete expert draft is required.");
|
||||
return Object.freeze({ requestId, draft });
|
||||
}
|
||||
|
||||
function createExpertReplaceRequest(requestId, editValue) {
|
||||
requireRequestId(requestId);
|
||||
if (!hasExactKeys(editValue, ["expectedIdentity", "draft"])) {
|
||||
throw new TypeError("An explicit expert edit identity and draft are required.");
|
||||
}
|
||||
const identity = normalizeExpertIdentity(editValue.expectedIdentity);
|
||||
const draft = cloneExpertDraft(editValue.draft);
|
||||
if (!identity || !draft || identity.expertCode !== draft.expertCode) {
|
||||
throw new TypeError("The expert edit identity does not match its draft.");
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId,
|
||||
expectedIdentity: { ...identity },
|
||||
newName: draft.expertName,
|
||||
recommendations: draft.recommendations
|
||||
});
|
||||
}
|
||||
|
||||
function createExpertDeleteRequest(requestId, identityValue) {
|
||||
requireRequestId(requestId);
|
||||
const identity = normalizeExpertIdentity(identityValue);
|
||||
if (!identity) throw new TypeError("An explicit expert identity is required.");
|
||||
return Object.freeze({ requestId, identity: { ...identity } });
|
||||
}
|
||||
|
||||
function normalizeThemeListResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "query", "nxtSession", "retrievedAt", "totalRowCount", "truncated",
|
||||
"canMutate", "writeQuarantined", "results"
|
||||
]) || !requestIdPattern.test(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
||||
(value.nxtSession !== "preMarket" && value.nxtSession !== "afterMarket") ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" ||
|
||||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.results) ||
|
||||
value.results.length !== value.totalRowCount) return null;
|
||||
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
|
||||
value.query !== expectedRequest.query || value.nxtSession !== expectedRequest.nxtSession ||
|
||||
value.results.length > (expectedRequest.maximumResults ?? 100))) return null;
|
||||
const results = [];
|
||||
const identities = new Set();
|
||||
const titles = new Set();
|
||||
for (const raw of value.results) {
|
||||
const item = normalizeThemeCatalogSelection(raw);
|
||||
const key = item && `${item.market}:${item.themeCode}`;
|
||||
const titleKey = item && `${item.market}:${item.themeTitle}`;
|
||||
if (!item || identities.has(key) || titles.has(titleKey)) return null;
|
||||
identities.add(key);
|
||||
titles.add(titleKey);
|
||||
results.push(item);
|
||||
}
|
||||
return Object.freeze({ ...value, results: Object.freeze(results) });
|
||||
}
|
||||
|
||||
function normalizeExpertListResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "canMutate",
|
||||
"writeQuarantined", "results"
|
||||
]) || !requestIdPattern.test(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
||||
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
||||
value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" ||
|
||||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate) || !Array.isArray(value.results) ||
|
||||
value.results.length !== value.totalRowCount) return null;
|
||||
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
|
||||
value.query !== expectedRequest.query ||
|
||||
value.results.length > (expectedRequest.maximumResults ?? 100))) return null;
|
||||
const results = [];
|
||||
const codes = new Set();
|
||||
const names = new Set();
|
||||
for (const raw of value.results) {
|
||||
const item = normalizeExpertCatalogSelection(raw);
|
||||
if (!item || codes.has(item.expertCode) || names.has(item.expertName)) return null;
|
||||
codes.add(item.expertCode);
|
||||
names.add(item.expertName);
|
||||
results.push(item);
|
||||
}
|
||||
return Object.freeze({ ...value, results: Object.freeze(results) });
|
||||
}
|
||||
|
||||
function normalizeThemeNextCodeResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "market", "themeCode", "canMutate", "writeQuarantined"
|
||||
]) || !requestIdPattern.test(value.requestId) || !normalizeMarket(value.market) ||
|
||||
!themeCodePattern.test(value.themeCode) || typeof value.canMutate !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate)) return null;
|
||||
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
|
||||
value.market !== expectedRequest.market)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeExpertNextCodeResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "expertCode", "canMutate", "writeQuarantined"
|
||||
]) || !requestIdPattern.test(value.requestId) || !expertCodePattern.test(value.expertCode) ||
|
||||
typeof value.canMutate !== "boolean" || typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && value.canMutate)) return null;
|
||||
if (expectedRequest && value.requestId !== expectedRequest.requestId) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeSchemaResult(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "validatedAt", "validatedSources", "themeCanMutate", "expertCanMutate",
|
||||
"writeQuarantined"
|
||||
]) || !requestIdPattern.test(value.requestId) ||
|
||||
!isSafeCanonicalText(value.validatedAt, 64) || !Number.isFinite(Date.parse(value.validatedAt)) ||
|
||||
!Array.isArray(value.validatedSources) || value.validatedSources.length !== 2 ||
|
||||
value.validatedSources[0] !== "oracle" || value.validatedSources[1] !== "mariaDb" ||
|
||||
typeof value.themeCanMutate !== "boolean" || typeof value.expertCanMutate !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.writeQuarantined && (value.themeCanMutate || value.expertCanMutate))) return null;
|
||||
if (expectedRequest && value.requestId !== expectedRequest.requestId) return null;
|
||||
return Object.freeze({ ...value, validatedSources: Object.freeze([...value.validatedSources]) });
|
||||
}
|
||||
|
||||
const mutationOperations = new Set(["create", "replace", "delete"]);
|
||||
function normalizeMutationResult(value, expected) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "entity", "operation", "identityCode", "operationId", "committedAt",
|
||||
"writeQuarantined"
|
||||
]) || !requestIdPattern.test(value.requestId) ||
|
||||
(value.entity !== "theme" && value.entity !== "expert") ||
|
||||
!mutationOperations.has(value.operation) ||
|
||||
!(value.entity === "theme" ? themeCodePattern : expertCodePattern).test(value.identityCode) ||
|
||||
!uuidPattern.test(value.operationId) || !isSafeCanonicalText(value.committedAt, 64) ||
|
||||
!Number.isFinite(Date.parse(value.committedAt)) || value.writeQuarantined !== false) return null;
|
||||
if (expected && ["requestId", "entity", "operation", "identityCode"]
|
||||
.some(key => value[key] !== expected[key])) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
const errorCodes = new Set([
|
||||
"INVALID_REQUEST", "DATABASE_UNAVAILABLE", "INVALID_DATA", "INVALID_SCHEMA", "DATABASE_ERROR",
|
||||
"PLAYOUT_PRIORITY", "PLAYOUT_ACTIVE", "DATABASE_BUSY", "WRITE_BUSY", "CONFLICT", "WRITE_FAILED",
|
||||
"CANCELLED", "OUTCOME_UNKNOWN"
|
||||
]);
|
||||
function normalizeCatalogError(value, expected) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "entity", "operation", "identityCode", "code", "message", "retryable",
|
||||
"outcomeUnknown", "writeQuarantined"
|
||||
]) || (value.requestId !== "" && !requestIdPattern.test(value.requestId)) ||
|
||||
!["theme", "expert", "catalog"].includes(value.entity) ||
|
||||
!["list", "nextCode", "create", "replace", "delete", "schemaPreflight"].includes(value.operation) ||
|
||||
typeof value.identityCode !== "string" || value.identityCode.length > 8 ||
|
||||
!errorCodes.has(value.code) || !isSafeCanonicalText(value.message, 512) ||
|
||||
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
(value.outcomeUnknown && (value.retryable || !value.writeQuarantined)) ||
|
||||
(mutationOperations.has(value.operation) && value.retryable)) return null;
|
||||
if (expected && ["requestId", "entity", "operation", "identityCode"]
|
||||
.some(key => value[key] !== expected[key])) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function describeMutationRequest(type, payload) {
|
||||
try {
|
||||
switch (type) {
|
||||
case bridgeContract.requests.themeCreate: {
|
||||
const request = createThemeCreateRequest(payload?.requestId, payload?.draft);
|
||||
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "create",
|
||||
identityCode: request.draft.themeCode });
|
||||
}
|
||||
case bridgeContract.requests.themeReplace: {
|
||||
if (!hasExactKeys(payload, ["requestId", "expectedIdentity", "newTitle", "items"])) return null;
|
||||
const request = createThemeReplaceRequest(payload.requestId, {
|
||||
expectedIdentity: payload.expectedIdentity,
|
||||
draft: {
|
||||
market: payload.expectedIdentity?.market,
|
||||
themeCode: payload.expectedIdentity?.themeCode,
|
||||
themeTitle: payload.newTitle,
|
||||
items: payload.items
|
||||
}
|
||||
});
|
||||
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "replace",
|
||||
identityCode: request.expectedIdentity.themeCode });
|
||||
}
|
||||
case bridgeContract.requests.themeDelete: {
|
||||
if (!hasExactKeys(payload, ["requestId", "identity"])) return null;
|
||||
const request = createThemeDeleteRequest(payload.requestId, payload.identity);
|
||||
return Object.freeze({ requestId: request.requestId, entity: "theme", operation: "delete",
|
||||
identityCode: request.identity.themeCode });
|
||||
}
|
||||
case bridgeContract.requests.expertCreate: {
|
||||
const request = createExpertCreateRequest(payload?.requestId, payload?.draft);
|
||||
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "create",
|
||||
identityCode: request.draft.expertCode });
|
||||
}
|
||||
case bridgeContract.requests.expertReplace: {
|
||||
if (!hasExactKeys(payload, ["requestId", "expectedIdentity", "newName", "recommendations"])) return null;
|
||||
const request = createExpertReplaceRequest(payload.requestId, {
|
||||
expectedIdentity: payload.expectedIdentity,
|
||||
draft: {
|
||||
expertCode: payload.expectedIdentity?.expertCode,
|
||||
expertName: payload.newName,
|
||||
recommendations: payload.recommendations
|
||||
}
|
||||
});
|
||||
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "replace",
|
||||
identityCode: request.expectedIdentity.expertCode });
|
||||
}
|
||||
case bridgeContract.requests.expertDelete: {
|
||||
if (!hasExactKeys(payload, ["requestId", "identity"])) return null;
|
||||
const request = createExpertDeleteRequest(payload.requestId, payload.identity);
|
||||
return Object.freeze({ requestId: request.requestId, entity: "expert", operation: "delete",
|
||||
identityCode: request.identity.expertCode });
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createMutationCoordinator() {
|
||||
let active = null;
|
||||
let quarantined = false;
|
||||
return Object.freeze({
|
||||
begin(type, payload) {
|
||||
if (quarantined) throw new Error("Operator catalog writes are quarantined for this process.");
|
||||
if (active) throw new Error("Another operator catalog write is already active.");
|
||||
const description = describeMutationRequest(type, payload);
|
||||
if (!description) throw new TypeError("A valid operator catalog mutation request is required.");
|
||||
active = description;
|
||||
return description;
|
||||
},
|
||||
acceptResult(payload) {
|
||||
if (!active) return null;
|
||||
const result = normalizeMutationResult(payload, active);
|
||||
if (!result) return null;
|
||||
active = null;
|
||||
return result;
|
||||
},
|
||||
acceptError(payload) {
|
||||
if (!active) return null;
|
||||
const error = normalizeCatalogError(payload, active);
|
||||
if (!error) return null;
|
||||
if (error.outcomeUnknown || error.writeQuarantined) quarantined = true;
|
||||
active = null;
|
||||
return error;
|
||||
},
|
||||
loseCorrelation() {
|
||||
if (active) quarantined = true;
|
||||
active = null;
|
||||
},
|
||||
getState() {
|
||||
return Object.freeze({ active, quarantined });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContract,
|
||||
limits: Object.freeze({
|
||||
maximumThemeItems: MAXIMUM_THEME_ITEMS,
|
||||
maximumRecommendations: MAXIMUM_RECOMMENDATIONS,
|
||||
maximumResults: MAXIMUM_RESULTS,
|
||||
maximumQueryLength: MAXIMUM_QUERY_LENGTH
|
||||
}),
|
||||
normalizeThemeIdentity,
|
||||
normalizeThemeDraft,
|
||||
normalizeExpertIdentity,
|
||||
normalizeExpertDraft,
|
||||
createThemeCatalogListRequest,
|
||||
createExpertCatalogListRequest,
|
||||
createSchemaPreflightRequest,
|
||||
createThemeNextCodeRequest,
|
||||
createExpertNextCodeRequest,
|
||||
normalizeThemeCatalogSelection,
|
||||
normalizeExpertCatalogSelection,
|
||||
createThemeEditDraft,
|
||||
createExpertEditDraft,
|
||||
createBlankThemeDraft,
|
||||
createBlankExpertDraft,
|
||||
createThemeCreateRequest,
|
||||
createThemeReplaceRequest,
|
||||
createThemeDeleteRequest,
|
||||
createExpertCreateRequest,
|
||||
createExpertReplaceRequest,
|
||||
createExpertDeleteRequest,
|
||||
normalizeThemeListResult,
|
||||
normalizeExpertListResult,
|
||||
normalizeThemeNextCodeResult,
|
||||
normalizeExpertNextCodeResult,
|
||||
normalizeSchemaResult,
|
||||
normalizeMutationResult,
|
||||
normalizeCatalogError,
|
||||
describeMutationRequest,
|
||||
createMutationCoordinator
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user