236 lines
9.6 KiB
JavaScript
236 lines
9.6 KiB
JavaScript
(function (root, factory) {
|
|
"use strict";
|
|
|
|
const comparison = typeof module === "object" && module.exports
|
|
? require("./comparison-workflow.js")
|
|
: root?.MbnComparisonWorkflow;
|
|
const api = Object.freeze(factory(comparison));
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
if (root) root.MbnComparisonImportWorkflow = api;
|
|
})(typeof globalThis === "object" ? globalThis : this, function (comparison) {
|
|
"use strict";
|
|
|
|
if (!comparison) throw new Error("Comparison workflow dependency is unavailable.");
|
|
|
|
const IMPORT_SCHEMA_VERSION = 1;
|
|
const STORAGE_ENVELOPE_SCHEMA_VERSION = 1;
|
|
const MAXIMUM_PAIRS = 500;
|
|
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
|
const digestPattern = /^[A-F0-9]{64}$/;
|
|
const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
const controlCharacterPattern = /[\u0000-\u001f\u007f]/u;
|
|
const importedRecordIdPattern = /^legacy-compare-[a-f0-9]{12}-[1-9][0-9]{0,2}$/u;
|
|
|
|
function hasOnlyKeys(value, expected) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const keys = Object.keys(value).sort();
|
|
const wanted = [...expected].sort();
|
|
return keys.length === wanted.length && keys.every((key, index) => key === wanted[index]);
|
|
}
|
|
|
|
function safeText(value, maximumLength) {
|
|
return typeof value === "string" && value.length > 0 && value.length <= maximumLength &&
|
|
value === value.trim() && !controlCharacterPattern.test(value)
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
function normalizeWireTarget(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
typeof value.kind !== "string") return null;
|
|
let candidate;
|
|
switch (value.kind) {
|
|
case "market-target":
|
|
if (!hasOnlyKeys(value, ["kind", "target"]) || !safeText(value.target, 64)) return null;
|
|
candidate = { kind: value.kind, target: value.target };
|
|
break;
|
|
case "krx-stock":
|
|
case "nxt-stock":
|
|
if (!hasOnlyKeys(value, ["kind", "market", "stockName", "stockCode"]) ||
|
|
!safeText(value.market, 8) || !safeText(value.stockName, 200) ||
|
|
!safeText(value.stockCode, 32)) return null;
|
|
candidate = {
|
|
kind: value.kind,
|
|
market: value.market,
|
|
stockName: value.stockName,
|
|
stockCode: value.stockCode
|
|
};
|
|
break;
|
|
case "world-stock":
|
|
if (!hasOnlyKeys(value, ["kind", "inputName", "symbol", "nation"]) ||
|
|
!safeText(value.inputName, 200) || !safeText(value.symbol, 64) ||
|
|
!safeText(value.nation, 2)) return null;
|
|
candidate = {
|
|
kind: value.kind,
|
|
inputName: value.inputName,
|
|
displayName: value.inputName,
|
|
symbol: value.symbol,
|
|
nation: value.nation
|
|
};
|
|
break;
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
return comparison.normalizeTarget(candidate);
|
|
}
|
|
|
|
function normalizeImportResponse(value, expectedRequestId) {
|
|
if (!requestIdPattern.test(String(expectedRequestId || "")) ||
|
|
!hasOnlyKeys(value, ["requestId", "sourceSha256", "sourceRowCount", "retrievedAt", "pairs"]) ||
|
|
value.requestId !== expectedRequestId ||
|
|
typeof value.sourceSha256 !== "string" || !digestPattern.test(value.sourceSha256) ||
|
|
!Number.isInteger(value.sourceRowCount) || value.sourceRowCount < 1 ||
|
|
value.sourceRowCount > MAXIMUM_PAIRS ||
|
|
typeof value.retrievedAt !== "string" || !isoDatePattern.test(value.retrievedAt) ||
|
|
!Number.isFinite(Date.parse(value.retrievedAt)) ||
|
|
!Array.isArray(value.pairs) || value.pairs.length !== value.sourceRowCount) return null;
|
|
|
|
const pairs = [];
|
|
const signatures = new Set();
|
|
for (let index = 0; index < value.pairs.length; index += 1) {
|
|
const raw = value.pairs[index];
|
|
if (!hasOnlyKeys(raw, ["rowNumber", "first", "second"]) ||
|
|
raw.rowNumber !== index + 1) return null;
|
|
const first = normalizeWireTarget(raw.first);
|
|
const second = normalizeWireTarget(raw.second);
|
|
const pair = comparison.normalizePair([first, second]);
|
|
if (!pair) return null;
|
|
const signature = pair.map(comparison.targetKey).join("\u001f");
|
|
if (!signature || signatures.has(signature)) return null;
|
|
signatures.add(signature);
|
|
pairs.push(Object.freeze({ rowNumber: index + 1, pair }));
|
|
}
|
|
|
|
return Object.freeze({
|
|
requestId: value.requestId,
|
|
sourceSha256: value.sourceSha256,
|
|
sourceRowCount: value.sourceRowCount,
|
|
retrievedAt: value.retrievedAt,
|
|
pairs: Object.freeze(pairs)
|
|
});
|
|
}
|
|
|
|
function pairSignature(value) {
|
|
const pair = comparison.normalizePair(value);
|
|
return pair ? pair.map(comparison.targetKey).join("\u001f") : null;
|
|
}
|
|
|
|
function mergeImport(currentValue, importValue) {
|
|
const current = comparison.normalizePairList(currentValue);
|
|
if (!importValue || !Array.isArray(importValue.pairs) ||
|
|
!digestPattern.test(String(importValue.sourceSha256 || "")) ||
|
|
!Number.isInteger(importValue.sourceRowCount) ||
|
|
importValue.sourceRowCount !== importValue.pairs.length ||
|
|
importValue.sourceRowCount < 1 || importValue.sourceRowCount > MAXIMUM_PAIRS ||
|
|
current.pairs.length + importValue.pairs.length > MAXIMUM_PAIRS) {
|
|
throw new RangeError("The legacy comparison import cannot fit the saved-pair schema.");
|
|
}
|
|
|
|
const ids = new Set(current.pairs.map(record => record.id));
|
|
const signatures = new Set(current.pairs.map(record => pairSignature(record.pair)));
|
|
let merged = current;
|
|
for (let index = 0; index < importValue.pairs.length; index += 1) {
|
|
const imported = importValue.pairs[index];
|
|
if (imported?.rowNumber !== index + 1) {
|
|
throw new TypeError("The legacy comparison import row order is invalid.");
|
|
}
|
|
const signature = pairSignature(imported?.pair);
|
|
if (!signature || signatures.has(signature)) {
|
|
throw new RangeError("The legacy comparison import contains an existing pair.");
|
|
}
|
|
|
|
const id = `legacy-compare-${importValue.sourceSha256.slice(0, 12).toLocaleLowerCase("en-US")}-${imported.rowNumber}`;
|
|
if (ids.has(id)) {
|
|
throw new RangeError("The legacy comparison import id already exists.");
|
|
}
|
|
|
|
const record = comparison.createPairRecord(id, imported.pair);
|
|
if (!record) throw new TypeError("The legacy comparison import pair is invalid.");
|
|
merged = comparison.appendPair(merged, record);
|
|
ids.add(id);
|
|
signatures.add(signature);
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function createMarker(importValue, importedAt = new Date().toISOString()) {
|
|
if (!importValue || !digestPattern.test(String(importValue.sourceSha256 || "")) ||
|
|
!Number.isInteger(importValue.sourceRowCount) || importValue.sourceRowCount < 1 ||
|
|
importValue.sourceRowCount > MAXIMUM_PAIRS ||
|
|
typeof importedAt !== "string" || !isoDatePattern.test(importedAt) ||
|
|
!Number.isFinite(Date.parse(importedAt))) return null;
|
|
return Object.freeze({
|
|
schemaVersion: IMPORT_SCHEMA_VERSION,
|
|
sourceSha256: importValue.sourceSha256,
|
|
importedPairCount: importValue.sourceRowCount,
|
|
importedAt
|
|
});
|
|
}
|
|
|
|
function normalizeMarker(value) {
|
|
if (!hasOnlyKeys(value, ["schemaVersion", "sourceSha256", "importedPairCount", "importedAt"]) ||
|
|
value.schemaVersion !== IMPORT_SCHEMA_VERSION ||
|
|
!digestPattern.test(String(value.sourceSha256 || "")) ||
|
|
!Number.isInteger(value.importedPairCount) || value.importedPairCount < 1 ||
|
|
value.importedPairCount > MAXIMUM_PAIRS ||
|
|
typeof value.importedAt !== "string" || !isoDatePattern.test(value.importedAt) ||
|
|
!Number.isFinite(Date.parse(value.importedAt))) return null;
|
|
return Object.freeze({
|
|
schemaVersion: IMPORT_SCHEMA_VERSION,
|
|
sourceSha256: value.sourceSha256,
|
|
importedPairCount: value.importedPairCount,
|
|
importedAt: value.importedAt
|
|
});
|
|
}
|
|
|
|
function normalizeExactPairList(value) {
|
|
if (!hasOnlyKeys(value, ["schemaVersion", "pairs"]) || !Array.isArray(value.pairs) ||
|
|
value.pairs.length > MAXIMUM_PAIRS || value.pairs.some(record =>
|
|
!hasOnlyKeys(record, ["id", "pair"]))) return null;
|
|
const normalized = comparison.normalizePairList(value);
|
|
if (normalized.pairs.length !== value.pairs.length ||
|
|
normalized.pairs.some((record, index) => record.id !== value.pairs[index].id)) return null;
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeStorageEnvelope(value) {
|
|
if (!hasOnlyKeys(value, ["schemaVersion", "pairList", "legacyImportMarker"]) ||
|
|
value.schemaVersion !== STORAGE_ENVELOPE_SCHEMA_VERSION) return null;
|
|
const pairList = normalizeExactPairList(value.pairList);
|
|
const marker = value.legacyImportMarker === null
|
|
? null
|
|
: normalizeMarker(value.legacyImportMarker);
|
|
if (!pairList || (value.legacyImportMarker !== null && !marker) ||
|
|
(!marker && pairList.pairs.some(record => importedRecordIdPattern.test(record.id)))) {
|
|
return null;
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
|
|
pairList,
|
|
legacyImportMarker: marker
|
|
});
|
|
}
|
|
|
|
function createStorageEnvelope(pairList, marker = null) {
|
|
return normalizeStorageEnvelope({
|
|
schemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
|
|
pairList,
|
|
legacyImportMarker: marker
|
|
});
|
|
}
|
|
|
|
return {
|
|
schemaVersion: IMPORT_SCHEMA_VERSION,
|
|
storageEnvelopeSchemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
|
|
maximumPairs: MAXIMUM_PAIRS,
|
|
normalizeImportResponse,
|
|
mergeImport,
|
|
createMarker,
|
|
normalizeMarker,
|
|
createStorageEnvelope,
|
|
normalizeStorageEnvelope
|
|
};
|
|
});
|