feat: restore legacy named playlist workflows
This commit is contained in:
402
Web/named-foreign-stock-restore-workflow.js
Normal file
402
Web/named-foreign-stock-restore-workflow.js
Normal file
@@ -0,0 +1,402 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const overseas = typeof module === "object" && module.exports
|
||||
? require("./overseas-workflow.js")
|
||||
: root.MbnOverseasWorkflow;
|
||||
const api = Object.freeze(factory(overseas));
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnNamedForeignStockRestoreWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function (overseas) {
|
||||
"use strict";
|
||||
|
||||
if (!overseas) throw new Error("Overseas workflow is unavailable.");
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const SOURCE = "legacy-named-foreign-stock-workflow";
|
||||
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
const pendingValues = new WeakSet();
|
||||
const resolvedValues = new WeakSet();
|
||||
const materializedEntries = new WeakSet();
|
||||
const failureCodes = new Set([
|
||||
"INVALID_ROW",
|
||||
"IDENTITY_NOT_FOUND",
|
||||
"IDENTITY_AMBIGUOUS",
|
||||
"DATABASE_DATA_INVALID",
|
||||
"RESTORE_FAILED"
|
||||
]);
|
||||
|
||||
const actionProfiles = Object.freeze([
|
||||
Object.freeze({
|
||||
actionId: "stock-current",
|
||||
graphicType: "1열판기본",
|
||||
subtype: "",
|
||||
builderKey: "s5001",
|
||||
cutCode: "5001",
|
||||
valueType: "CURRENT",
|
||||
periodDays: null
|
||||
}),
|
||||
Object.freeze({
|
||||
actionId: "stock-candle-5d",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "5일",
|
||||
builderKey: "s8010",
|
||||
cutCode: "8061",
|
||||
valueType: "PRICE",
|
||||
periodDays: 5
|
||||
}),
|
||||
Object.freeze({
|
||||
actionId: "stock-candle-20d",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "20일",
|
||||
builderKey: "s8010",
|
||||
cutCode: "8040",
|
||||
valueType: "PRICE",
|
||||
periodDays: 20
|
||||
}),
|
||||
Object.freeze({
|
||||
actionId: "stock-candle-60d",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "60일",
|
||||
builderKey: "s8010",
|
||||
cutCode: "8046",
|
||||
valueType: "PRICE",
|
||||
periodDays: 60
|
||||
}),
|
||||
Object.freeze({
|
||||
actionId: "stock-candle-120d",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "120일",
|
||||
builderKey: "s8010",
|
||||
cutCode: "8051",
|
||||
valueType: "PRICE",
|
||||
periodDays: 120
|
||||
}),
|
||||
Object.freeze({
|
||||
actionId: "stock-candle-240d",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "240일",
|
||||
builderKey: "s8010",
|
||||
cutCode: "8056",
|
||||
valueType: "PRICE",
|
||||
periodDays: 240
|
||||
})
|
||||
]);
|
||||
const profilesByAction = new Map(actionProfiles.map(value => [value.actionId, value]));
|
||||
const profilesByRaw = new Map(actionProfiles.map(value => [
|
||||
`${value.graphicType}\u0000${value.subtype}`,
|
||||
value
|
||||
]));
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
resultType: "named-foreign-stock-restore-results",
|
||||
errorType: "named-foreign-stock-restore-error"
|
||||
});
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(value, keys) {
|
||||
if (!isPlainObject(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 safeText(value, maximumLength, allowEmpty = false) {
|
||||
return typeof value === "string" && value.length <= maximumLength &&
|
||||
(allowEmpty || value.length > 0) && value === value.trim() &&
|
||||
!value.includes("^") && !unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!hasExactKeys(options, ["id", "fadeDuration", "ma5", "ma20"]) ||
|
||||
!requestIdPattern.test(options.id) ||
|
||||
!Number.isInteger(options.fadeDuration) ||
|
||||
options.fadeDuration < 0 || options.fadeDuration > 60 ||
|
||||
typeof options.ma5 !== "boolean" || typeof options.ma20 !== "boolean") return null;
|
||||
return Object.freeze({ ...options });
|
||||
}
|
||||
|
||||
function rawSelection(raw) {
|
||||
return Object.freeze({
|
||||
groupCode: raw.groupCode,
|
||||
subject: raw.subject,
|
||||
graphicType: raw.graphicType,
|
||||
subtype: raw.subtype,
|
||||
dataCode: raw.dataCode
|
||||
});
|
||||
}
|
||||
|
||||
function profileForRaw(raw) {
|
||||
return profilesByRaw.get(`${raw?.graphicType}\u0000${raw?.subtype}`) || null;
|
||||
}
|
||||
|
||||
function safeRaw(raw) {
|
||||
return isPlainObject(raw) && Number.isInteger(raw.itemIndex) &&
|
||||
raw.itemIndex >= 0 && raw.itemIndex < 1000 &&
|
||||
typeof raw.enabled === "boolean" && raw.groupCode === "해외종목" &&
|
||||
safeText(raw.subject, 200) && raw.pageText === "1/1" && raw.dataCode === "" &&
|
||||
safeText(raw.graphicType, 256) && safeText(raw.subtype, 256, true) &&
|
||||
safeText(raw.pageText, 9) && safeText(raw.dataCode, 1024, true) &&
|
||||
Boolean(profileForRaw(raw));
|
||||
}
|
||||
|
||||
function classify(raw, options) {
|
||||
const normalizedOptions = requireOptions(options);
|
||||
const profile = profileForRaw(raw);
|
||||
if (!normalizedOptions || !safeRaw(raw) || !profile) return null;
|
||||
const movingAverages = Object.freeze(profile.builderKey === "s8010"
|
||||
? { ma5: normalizedOptions.ma5, ma20: normalizedOptions.ma20 }
|
||||
: { ma5: false, ma20: false });
|
||||
const pending = Object.freeze({
|
||||
itemIndex: raw.itemIndex,
|
||||
enabled: raw.enabled,
|
||||
actionId: profile.actionId,
|
||||
builderKey: profile.builderKey,
|
||||
cutCode: profile.cutCode,
|
||||
valueType: profile.valueType,
|
||||
periodDays: profile.periodDays,
|
||||
inputName: raw.subject,
|
||||
entry: Object.freeze({ id: normalizedOptions.id }),
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
movingAverages,
|
||||
rawSelection: rawSelection(raw)
|
||||
});
|
||||
pendingValues.add(pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function normalizeOutcome(value, pending, verifiedAt) {
|
||||
if (!pendingValues.has(pending) || !isPlainObject(value) ||
|
||||
value.itemIndex !== pending.itemIndex) return null;
|
||||
if (value.status === "failed") {
|
||||
if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) ||
|
||||
!failureCodes.has(value.failure)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
if (value.status !== "resolved" || !hasExactKeys(value, [
|
||||
"itemIndex", "status", "actionId", "inputName", "symbol", "nationCode",
|
||||
"foreignDomesticCode", "builderKey", "cutCode", "valueType", "periodDays"
|
||||
]) || value.actionId !== pending.actionId || value.inputName !== pending.inputName ||
|
||||
value.foreignDomesticCode !== "1" || value.builderKey !== pending.builderKey ||
|
||||
value.cutCode !== pending.cutCode || value.valueType !== pending.valueType ||
|
||||
value.periodDays !== pending.periodDays) return null;
|
||||
const identity = overseas.normalizeStock({
|
||||
inputName: value.inputName,
|
||||
symbol: value.symbol,
|
||||
nationCode: value.nationCode,
|
||||
fdtc: value.foreignDomesticCode
|
||||
});
|
||||
if (!identity) return null;
|
||||
const resolved = Object.freeze({
|
||||
itemIndex: value.itemIndex,
|
||||
status: "resolved",
|
||||
actionId: value.actionId,
|
||||
inputName: identity.inputName,
|
||||
symbol: identity.symbol,
|
||||
nationCode: identity.nationCode,
|
||||
foreignDomesticCode: identity.fdtc,
|
||||
builderKey: value.builderKey,
|
||||
cutCode: value.cutCode,
|
||||
valueType: value.valueType,
|
||||
periodDays: value.periodDays,
|
||||
verifiedAt
|
||||
});
|
||||
resolvedValues.add(resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function normalizeResponse(value, expectedRequestId, pendingRows) {
|
||||
if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) ||
|
||||
pendingRows.length < 1 || pendingRows.length > 1000 ||
|
||||
pendingRows.some(pending => !pendingValues.has(pending)) ||
|
||||
!hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) ||
|
||||
value.requestId !== expectedRequestId || typeof value.retrievedAt !== "string" ||
|
||||
!isoTimestampPattern.test(value.retrievedAt) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
||||
value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) ||
|
||||
value.rows.length !== pendingRows.length) return null;
|
||||
const rows = [];
|
||||
for (let index = 0; index < pendingRows.length; index += 1) {
|
||||
const outcome = normalizeOutcome(value.rows[index], pendingRows[index], value.retrievedAt);
|
||||
if (!outcome) return null;
|
||||
rows.push(outcome);
|
||||
}
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
retrievedAt: value.retrievedAt,
|
||||
rows: Object.freeze(rows)
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeError(value, expectedRequestId) {
|
||||
return requestIdPattern.test(expectedRequestId) &&
|
||||
hasExactKeys(value, ["requestId", "code", "message", "retryable"]) &&
|
||||
value.requestId === expectedRequestId &&
|
||||
["PLAYOUT_PRIORITY", "DATABASE_UNAVAILABLE", "RESTORE_FAILED"].includes(value.code) &&
|
||||
safeText(value.message, 1024) && value.retryable === false
|
||||
? Object.freeze({ ...value })
|
||||
: null;
|
||||
}
|
||||
|
||||
function customOperator(canonical, raw, verifiedAt) {
|
||||
return Object.freeze({
|
||||
source: SOURCE,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: "stock",
|
||||
actionId: canonical.operator.actionId,
|
||||
identity: canonical.operator.identity,
|
||||
movingAverages: canonical.operator.movingAverages,
|
||||
rawSelection: raw,
|
||||
verifiedAt
|
||||
});
|
||||
}
|
||||
|
||||
function buildEntry(pending, outcome) {
|
||||
const canonical = overseas.createOverseasStockPlaylistEntry({
|
||||
inputName: outcome.inputName,
|
||||
symbol: outcome.symbol,
|
||||
nationCode: outcome.nationCode,
|
||||
fdtc: outcome.foreignDomesticCode
|
||||
}, outcome.actionId, {
|
||||
id: pending.entry.id,
|
||||
fadeDuration: pending.fadeDuration,
|
||||
ma5: pending.movingAverages.ma5,
|
||||
ma20: pending.movingAverages.ma20
|
||||
});
|
||||
if (canonical.builderKey !== outcome.builderKey || canonical.code !== outcome.cutCode) return null;
|
||||
return Object.freeze({
|
||||
...canonical,
|
||||
enabled: pending.enabled,
|
||||
operator: customOperator(canonical, pending.rawSelection, outcome.verifiedAt)
|
||||
});
|
||||
}
|
||||
|
||||
function materialize(pending, outcome) {
|
||||
if (!pendingValues.has(pending) || !resolvedValues.has(outcome) ||
|
||||
outcome.itemIndex !== pending.itemIndex || outcome.actionId !== pending.actionId) return null;
|
||||
let entry;
|
||||
try {
|
||||
entry = buildEntry(pending, outcome);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!entry) return null;
|
||||
materializedEntries.add(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function sameSelection(left, right) {
|
||||
return hasExactKeys(left, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) &&
|
||||
hasExactKeys(right, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) &&
|
||||
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
||||
.every(key => left[key] === right[key]);
|
||||
}
|
||||
|
||||
function restorePlaylistEntry(value) {
|
||||
if (!hasExactKeys(value, [
|
||||
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
|
||||
"symbol", "nationCode", "fdtc", "selection", "enabled", "fadeDuration",
|
||||
"graphicType", "subtype", "operator"
|
||||
]) || typeof value.enabled !== "boolean" ||
|
||||
!isPlainObject(value.operator) || !hasExactKeys(value.operator, [
|
||||
"source", "schemaVersion", "kind", "actionId", "identity", "movingAverages",
|
||||
"rawSelection", "verifiedAt"
|
||||
]) || value.operator.source !== SOURCE ||
|
||||
value.operator.schemaVersion !== SCHEMA_VERSION || value.operator.kind !== "stock" ||
|
||||
!profilesByAction.has(value.operator.actionId) ||
|
||||
!hasExactKeys(value.operator.identity, ["inputName", "symbol", "nationCode", "fdtc"]) ||
|
||||
!hasExactKeys(value.operator.movingAverages, ["ma5", "ma20"]) ||
|
||||
typeof value.operator.movingAverages.ma5 !== "boolean" ||
|
||||
typeof value.operator.movingAverages.ma20 !== "boolean" ||
|
||||
typeof value.operator.verifiedAt !== "string" ||
|
||||
!isoTimestampPattern.test(value.operator.verifiedAt) ||
|
||||
!Number.isFinite(Date.parse(value.operator.verifiedAt))) return null;
|
||||
const identity = overseas.normalizeStock(value.operator.identity);
|
||||
const raw = value.operator.rawSelection;
|
||||
const profile = profileForRaw(raw);
|
||||
if (!identity || !profile || profile.actionId !== value.operator.actionId ||
|
||||
!hasExactKeys(raw, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) ||
|
||||
raw.groupCode !== "해외종목" || raw.subject !== identity.inputName || raw.dataCode !== "" ||
|
||||
!safeText(raw.subject, 200) ||
|
||||
(profile.builderKey !== "s8010" &&
|
||||
(value.operator.movingAverages.ma5 || value.operator.movingAverages.ma20))) return null;
|
||||
|
||||
let canonical;
|
||||
try {
|
||||
canonical = overseas.createOverseasStockPlaylistEntry(
|
||||
identity,
|
||||
profile.actionId,
|
||||
{
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration,
|
||||
ma5: value.operator.movingAverages.ma5,
|
||||
ma20: value.operator.movingAverages.ma20
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const rebuilt = Object.freeze({
|
||||
...canonical,
|
||||
enabled: value.enabled,
|
||||
operator: customOperator(canonical, raw, value.operator.verifiedAt)
|
||||
});
|
||||
const scalarKeys = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "market", "symbol",
|
||||
"nationCode", "fdtc", "enabled", "fadeDuration", "graphicType", "subtype"
|
||||
];
|
||||
if (!scalarKeys.every(key => value[key] === rebuilt[key]) ||
|
||||
!Array.isArray(value.aliases) || value.aliases.length !== 1 ||
|
||||
value.aliases[0] !== rebuilt.code || !sameSelection(value.selection, rebuilt.selection) ||
|
||||
JSON.stringify(value.operator) !== JSON.stringify(rebuilt.operator)) return null;
|
||||
materializedEntries.add(rebuilt);
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
function legacySelectionForEntry(entry) {
|
||||
const restored = restorePlaylistEntry(entry);
|
||||
return restored ? restored.operator.rawSelection : null;
|
||||
}
|
||||
|
||||
function withEnabled(entry, enabled) {
|
||||
if (!materializedEntries.has(entry) || typeof enabled !== "boolean") return null;
|
||||
if (entry.enabled === enabled) return entry;
|
||||
const replacement = Object.freeze({ ...entry, enabled });
|
||||
materializedEntries.add(replacement);
|
||||
return replacement;
|
||||
}
|
||||
|
||||
function matchesRaw(entry, raw) {
|
||||
const signature = legacySelectionForEntry(entry);
|
||||
return Boolean(signature && safeRaw(raw) && entry.enabled === raw.enabled &&
|
||||
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
||||
.every(key => signature[key] === raw[key]));
|
||||
}
|
||||
|
||||
function isPending(value) {
|
||||
return pendingValues.has(value);
|
||||
}
|
||||
|
||||
function isMaterializedEntry(value) {
|
||||
return materializedEntries.has(value);
|
||||
}
|
||||
|
||||
return {
|
||||
source: SOURCE,
|
||||
bridgeContract,
|
||||
actionProfiles,
|
||||
classify,
|
||||
normalizeResponse,
|
||||
normalizeError,
|
||||
materialize,
|
||||
restorePlaylistEntry,
|
||||
withEnabled,
|
||||
legacySelectionForEntry,
|
||||
matchesRaw,
|
||||
isPending,
|
||||
isMaterializedEntry
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user