762 lines
33 KiB
JavaScript
762 lines
33 KiB
JavaScript
(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
|
|
};
|
|
});
|