1043 lines
41 KiB
JavaScript
1043 lines
41 KiB
JavaScript
(function (root, factory) {
|
|
"use strict";
|
|
|
|
const api = Object.freeze(factory());
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
if (root) root.MbnManualFinancialWorkflow = api;
|
|
})(typeof globalThis === "object" ? globalThis : this, function () {
|
|
"use strict";
|
|
|
|
const DEFAULT_MAXIMUM_RESULTS = 200;
|
|
const MAXIMUM_RESULTS = 1000;
|
|
const MAXIMUM_QUERY_LENGTH = 64;
|
|
const DEFAULT_FADE_DURATION = 6;
|
|
const OPERATOR_SCHEMA_VERSION = 1;
|
|
const MAXIMUM_FLOAT = 1_000_000_000_000;
|
|
const int32Minimum = -2147483648;
|
|
const int32Maximum = 2147483647;
|
|
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
|
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
|
|
const rowVersionPattern = /^[0-9A-F]{64}$/;
|
|
const numberTextPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
|
|
|
const bridgeContract = Object.freeze({
|
|
requestErrorType: "manual-financial-request-error",
|
|
listRequestType: "request-manual-financial-list",
|
|
listResultType: "manual-financial-list-results",
|
|
listErrorType: "manual-financial-list-error",
|
|
loadRequestType: "request-manual-financial-load",
|
|
loadResultType: "manual-financial-load-results",
|
|
loadErrorType: "manual-financial-load-error",
|
|
selectionRequestType: "request-manual-financial-selection",
|
|
selectionResultType: "manual-financial-selection-results",
|
|
selectionErrorType: "manual-financial-selection-error",
|
|
createRequestType: "create-manual-financial-record",
|
|
updateRequestType: "update-manual-financial-record",
|
|
deleteRequestType: "delete-manual-financial-record",
|
|
deleteAllRequestType: "delete-all-manual-financial-records",
|
|
mutationResultType: "manual-financial-mutation-result",
|
|
mutationErrorType: "manual-financial-mutation-error",
|
|
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
|
|
maximumResults: MAXIMUM_RESULTS,
|
|
maximumQueryLength: MAXIMUM_QUERY_LENGTH
|
|
});
|
|
|
|
const screenDefinitions = Object.freeze({
|
|
"revenue-composition": Object.freeze({
|
|
screen: "revenue-composition",
|
|
table: "INPUT_PIE",
|
|
label: "주요매출 구성",
|
|
builderKey: "s5076",
|
|
code: "5076",
|
|
aliases: Object.freeze(["5076"]),
|
|
graphicType: "REVENUE_COMPOSITION",
|
|
storageColumns: Object.freeze([
|
|
"STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2",
|
|
"GUSUNG_3", "GUSUNG_4", "GUSUNG_5"
|
|
]),
|
|
storageShape: "BASE_DATE + five label_percentage columns",
|
|
deleteAllConfirmation: "DELETE_ALL_INPUT_PIE"
|
|
}),
|
|
"growth-metrics": Object.freeze({
|
|
screen: "growth-metrics",
|
|
table: "INPUT_GROW",
|
|
label: "성장성 지표",
|
|
builderKey: "s5079",
|
|
code: "5079",
|
|
aliases: Object.freeze(["5079"]),
|
|
graphicType: "GROWTH_METRICS",
|
|
storageColumns: Object.freeze([
|
|
"STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3",
|
|
"GUSUNG_4", "BASE_DATE"
|
|
]),
|
|
storageShape: "four value_value_value_value series + BASE_DATE periods",
|
|
deleteAllConfirmation: "DELETE_ALL_INPUT_GROW"
|
|
}),
|
|
sales: Object.freeze({
|
|
screen: "sales",
|
|
table: "INPUT_SELL",
|
|
label: "매출액",
|
|
builderKey: "s5080",
|
|
code: "5080",
|
|
aliases: Object.freeze(["5080"]),
|
|
graphicType: "SALES",
|
|
storageColumns: Object.freeze([
|
|
"STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3",
|
|
"GUSUNG_4", "GUSUNG_5", "GUSUNG_6"
|
|
]),
|
|
storageShape: "six quarter_integer columns",
|
|
deleteAllConfirmation: "DELETE_ALL_INPUT_SELL"
|
|
}),
|
|
"operating-profit": Object.freeze({
|
|
screen: "operating-profit",
|
|
table: "INPUT_PROFIT",
|
|
label: "영업이익",
|
|
builderKey: "s5081",
|
|
code: "5081",
|
|
aliases: Object.freeze(["5081"]),
|
|
graphicType: "OPERATING_PROFIT",
|
|
storageColumns: Object.freeze([
|
|
"STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3",
|
|
"GUSUNG_4", "GUSUNG_5", "GUSUNG_6"
|
|
]),
|
|
storageShape: "six quarter_integer columns",
|
|
deleteAllConfirmation: "DELETE_ALL_INPUT_PROFIT"
|
|
})
|
|
});
|
|
|
|
const sourceContract = Object.freeze({
|
|
originalForm: "Form/GraphE.cs",
|
|
provider: "oracle",
|
|
operatorSchemaVersion: OPERATOR_SCHEMA_VERSION,
|
|
restoreRequiresNativeLoad: true,
|
|
rawNamedPlaylistRowPlayable: false,
|
|
screens: Object.freeze(Object.values(screenDefinitions)),
|
|
writeSafety: Object.freeze({
|
|
valuesBound: true,
|
|
oneTransactionPerWrite: true,
|
|
exclusiveTableIdentityCheck: true,
|
|
optimisticRowVersion: "SHA-256",
|
|
outcomeUnknownAutomaticRetry: false
|
|
})
|
|
});
|
|
|
|
const actions = Object.freeze(Object.values(screenDefinitions).map(definition => Object.freeze({
|
|
id: definition.screen,
|
|
label: definition.label,
|
|
screen: definition.screen,
|
|
builderKey: definition.builderKey,
|
|
code: definition.code,
|
|
aliases: definition.aliases,
|
|
pageSize: 1
|
|
})));
|
|
|
|
const trustedSnapshots = new WeakSet();
|
|
const trustedStockSearchResponses = new WeakSet();
|
|
const verifiedStocks = new WeakSet();
|
|
const trustedSelectionResponses = new WeakSet();
|
|
const trustedPlaylistEntries = new WeakSet();
|
|
const pendingRestoreEntries = new WeakSet();
|
|
|
|
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 isSafeText(value, maximumLength, options = {}) {
|
|
const { allowEmpty = false, allowUnderscore = true } = options;
|
|
return typeof value === "string" && value.length <= maximumLength &&
|
|
(allowEmpty || value.length > 0) && value === value.trim() &&
|
|
!unsafeTextPattern.test(value) && !value.includes("^") &&
|
|
(allowUnderscore || !value.includes("_"));
|
|
}
|
|
|
|
function isRequestId(value) {
|
|
return typeof value === "string" && requestIdPattern.test(value);
|
|
}
|
|
|
|
function definition(screen) {
|
|
return typeof screen === "string" &&
|
|
Object.prototype.hasOwnProperty.call(screenDefinitions, screen)
|
|
? screenDefinitions[screen]
|
|
: null;
|
|
}
|
|
|
|
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 normalizeFinite(value) {
|
|
return typeof value === "number" && Number.isFinite(value) &&
|
|
Math.abs(value) <= MAXIMUM_FLOAT ? value : null;
|
|
}
|
|
|
|
function normalizeRevenueSlice(value) {
|
|
if (value === null) return null;
|
|
if (!hasExactKeys(value, ["label", "percentageText", "percentage"]) ||
|
|
!isSafeText(value.label, 200, { allowUnderscore: false }) ||
|
|
!isSafeText(value.percentageText, 64, { allowUnderscore: false }) ||
|
|
normalizeFinite(value.percentage) === null) return undefined;
|
|
const parsed = value.percentageText === "-"
|
|
? 0
|
|
: numberTextPattern.test(value.percentageText)
|
|
? Number(value.percentageText)
|
|
: Number.NaN;
|
|
if (!Number.isFinite(parsed) || Math.abs(parsed) > MAXIMUM_FLOAT ||
|
|
!Object.is(parsed, value.percentage) && parsed !== value.percentage) return undefined;
|
|
return Object.freeze({
|
|
label: value.label,
|
|
percentageText: value.percentageText,
|
|
percentage: parsed
|
|
});
|
|
}
|
|
|
|
function normalizeGrowthSeries(value) {
|
|
if (!Array.isArray(value) || value.length !== 4) return null;
|
|
const result = [];
|
|
for (const item of value) {
|
|
if (item === null) {
|
|
result.push(null);
|
|
} else {
|
|
const normalized = normalizeFinite(item);
|
|
if (normalized === null) return null;
|
|
result.push(normalized);
|
|
}
|
|
}
|
|
return Object.freeze(result);
|
|
}
|
|
|
|
function normalizePeriods(value) {
|
|
if (!Array.isArray(value) || value.length !== 4) return null;
|
|
const periods = [];
|
|
for (const period of value) {
|
|
if (!isSafeText(period, 200, { allowEmpty: true, allowUnderscore: false })) return null;
|
|
periods.push(period);
|
|
}
|
|
return Object.freeze(periods);
|
|
}
|
|
|
|
function normalizeQuarters(value) {
|
|
if (!Array.isArray(value) || value.length !== 6) return null;
|
|
const quarters = [];
|
|
for (const item of value) {
|
|
if (!hasExactKeys(item, ["quarter", "value"]) ||
|
|
!isSafeText(item.quarter, 200, { allowUnderscore: false }) ||
|
|
!Number.isInteger(item.value) || item.value < int32Minimum || item.value > int32Maximum) {
|
|
return null;
|
|
}
|
|
quarters.push(Object.freeze({ quarter: item.quarter, value: item.value }));
|
|
}
|
|
return Object.freeze(quarters);
|
|
}
|
|
|
|
function normalizeRecord(screen, value) {
|
|
const profile = definition(screen);
|
|
if (!profile || !value || typeof value !== "object" || Array.isArray(value) ||
|
|
value.kind !== screen || !isSafeText(value.stockName, 200)) return null;
|
|
|
|
if (screen === "revenue-composition") {
|
|
if (!hasExactKeys(value, ["kind", "stockName", "baseDate", "slices"]) ||
|
|
!isSafeText(value.baseDate, 200, { allowEmpty: true }) ||
|
|
!Array.isArray(value.slices) || value.slices.length !== 5) return null;
|
|
const slices = [];
|
|
for (const raw of value.slices) {
|
|
const slice = normalizeRevenueSlice(raw);
|
|
if (slice === undefined) return null;
|
|
slices.push(slice);
|
|
}
|
|
return Object.freeze({
|
|
kind: screen,
|
|
stockName: value.stockName,
|
|
baseDate: value.baseDate,
|
|
slices: Object.freeze(slices)
|
|
});
|
|
}
|
|
|
|
if (screen === "growth-metrics") {
|
|
if (!hasExactKeys(value, [
|
|
"kind", "stockName", "salesGrowth", "operatingProfitGrowth",
|
|
"netAssetGrowth", "netIncomeGrowth", "periods"
|
|
])) return null;
|
|
const salesGrowth = normalizeGrowthSeries(value.salesGrowth);
|
|
const operatingProfitGrowth = normalizeGrowthSeries(value.operatingProfitGrowth);
|
|
const netAssetGrowth = normalizeGrowthSeries(value.netAssetGrowth);
|
|
const netIncomeGrowth = normalizeGrowthSeries(value.netIncomeGrowth);
|
|
const periods = normalizePeriods(value.periods);
|
|
if (!salesGrowth || !operatingProfitGrowth || !netAssetGrowth ||
|
|
!netIncomeGrowth || !periods) return null;
|
|
return Object.freeze({
|
|
kind: screen,
|
|
stockName: value.stockName,
|
|
salesGrowth,
|
|
operatingProfitGrowth,
|
|
netAssetGrowth,
|
|
netIncomeGrowth,
|
|
periods
|
|
});
|
|
}
|
|
|
|
if (screen === "sales" || screen === "operating-profit") {
|
|
if (!hasExactKeys(value, ["kind", "stockName", "quarters"])) return null;
|
|
const quarters = normalizeQuarters(value.quarters);
|
|
return quarters && Object.freeze({
|
|
kind: screen,
|
|
stockName: value.stockName,
|
|
quarters
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function serializeRecordForStorage(screen, value) {
|
|
const record = normalizeRecord(screen, value);
|
|
const profile = definition(screen);
|
|
if (!record || !profile) return null;
|
|
let values;
|
|
if (screen === "revenue-composition") {
|
|
values = [record.stockName, record.baseDate,
|
|
...record.slices.map(slice => slice ? `${slice.label}_${slice.percentageText}` : "")];
|
|
} else if (screen === "growth-metrics") {
|
|
const series = [record.salesGrowth, record.operatingProfitGrowth,
|
|
record.netAssetGrowth, record.netIncomeGrowth];
|
|
values = [record.stockName,
|
|
...series.map(items => items.map(item => item === null ? "" : String(item)).join("_")),
|
|
record.periods.join("_")];
|
|
} else {
|
|
values = [record.stockName,
|
|
...record.quarters.map(item => `${item.quarter}_${item.value}`)];
|
|
}
|
|
return Object.freeze({
|
|
table: profile.table,
|
|
columns: profile.storageColumns,
|
|
values: Object.freeze(values)
|
|
});
|
|
}
|
|
|
|
function createListRequest(requestId, screen, query = "", maximumResults) {
|
|
if (!isRequestId(requestId)) throw new TypeError("A safe list request id is required.");
|
|
if (!definition(screen)) throw new TypeError("A closed manual-financial screen is required.");
|
|
const normalizedQuery = normalizeQuery(query);
|
|
if (normalizedQuery === null) throw new TypeError("The manual-financial query is invalid.");
|
|
if (maximumResults !== undefined &&
|
|
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
|
throw new RangeError(`Manual-financial results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
|
|
}
|
|
const request = { requestId, screen, query: normalizedQuery };
|
|
if (maximumResults !== undefined) request.maximumResults = maximumResults;
|
|
return Object.freeze(request);
|
|
}
|
|
|
|
function normalizeRequestError(value, expectedOperation) {
|
|
if (!hasExactKeys(value, ["requestId", "operation", "code", "message"]) ||
|
|
value.requestId !== "" && !isRequestId(value.requestId) ||
|
|
![
|
|
"list", "load", "selection", "create", "update", "delete-one", "delete-all"
|
|
].includes(value.operation) || value.code !== "INVALID_REQUEST" ||
|
|
!isSafeText(value.message, 512) ||
|
|
expectedOperation !== undefined && value.operation !== expectedOperation) return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function compareNames(left, right) {
|
|
const foldedLeft = left.toUpperCase();
|
|
const foldedRight = right.toUpperCase();
|
|
if (foldedLeft < foldedRight) return -1;
|
|
if (foldedLeft > foldedRight) return 1;
|
|
if (left < right) return -1;
|
|
if (left > right) return 1;
|
|
return 0;
|
|
}
|
|
|
|
function normalizeSnapshot(screen, value) {
|
|
if (!hasExactKeys(value, ["stockName", "rowVersion", "record"]) ||
|
|
!rowVersionPattern.test(value.rowVersion)) return null;
|
|
const record = normalizeRecord(screen, value.record);
|
|
if (!record || value.stockName !== record.stockName) return null;
|
|
const snapshot = Object.freeze({
|
|
screen,
|
|
stockName: record.stockName,
|
|
rowVersion: value.rowVersion,
|
|
record
|
|
});
|
|
trustedSnapshots.add(snapshot);
|
|
return snapshot;
|
|
}
|
|
|
|
function normalizeListResponse(value, expectedRequest) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "screen", "query", "retrievedAt", "totalRowCount", "truncated", "items"
|
|
]) || !isRequestId(value.requestId) || !definition(value.screen) ||
|
|
normalizeQuery(value.query) !== value.query ||
|
|
!isSafeText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
|
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
|
value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" ||
|
|
!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
|
|
|
|
if (expectedRequest !== undefined) {
|
|
let expected;
|
|
try {
|
|
expected = createListRequest(
|
|
expectedRequest?.requestId,
|
|
expectedRequest?.screen,
|
|
expectedRequest?.query,
|
|
expectedRequest?.maximumResults);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const bound = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
|
|
if (value.requestId !== expected.requestId || value.screen !== expected.screen ||
|
|
value.query !== expected.query || value.items.length > bound ||
|
|
value.truncated && value.items.length !== bound) return null;
|
|
}
|
|
|
|
const items = [];
|
|
const names = new Set();
|
|
for (const raw of value.items) {
|
|
const snapshot = normalizeSnapshot(value.screen, raw);
|
|
const foldedName = snapshot?.stockName.toUpperCase();
|
|
if (!snapshot || names.has(foldedName) ||
|
|
items.length && compareNames(items[items.length - 1].stockName, snapshot.stockName) > 0) {
|
|
return null;
|
|
}
|
|
names.add(foldedName);
|
|
items.push(snapshot);
|
|
}
|
|
return Object.freeze({
|
|
requestId: value.requestId,
|
|
screen: value.screen,
|
|
query: value.query,
|
|
retrievedAt: value.retrievedAt,
|
|
totalRowCount: value.totalRowCount,
|
|
truncated: value.truncated,
|
|
items: Object.freeze(items)
|
|
});
|
|
}
|
|
|
|
function normalizeListError(value, expectedRequest) {
|
|
if (!hasExactKeys(value, ["requestId", "screen", "query", "message"]) ||
|
|
!isRequestId(value.requestId) || !definition(value.screen) ||
|
|
normalizeQuery(value.query) !== value.query ||
|
|
!isSafeText(value.message, 512)) return null;
|
|
if (expectedRequest) {
|
|
let expected;
|
|
try {
|
|
expected = createListRequest(
|
|
expectedRequest.requestId,
|
|
expectedRequest.screen,
|
|
expectedRequest.query,
|
|
expectedRequest.maximumResults);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (value.requestId !== expected.requestId || value.screen !== expected.screen ||
|
|
value.query !== expected.query) return null;
|
|
}
|
|
return Object.freeze({
|
|
requestId: value.requestId,
|
|
screen: value.screen,
|
|
query: value.query,
|
|
message: value.message
|
|
});
|
|
}
|
|
|
|
function createLoadRequest(requestId, screen, stockName) {
|
|
if (!isRequestId(requestId)) throw new TypeError("A safe load request id is required.");
|
|
if (!definition(screen) || !isSafeText(stockName, 200)) {
|
|
throw new TypeError("A closed screen and canonical stock name are required.");
|
|
}
|
|
return Object.freeze({ requestId, screen, stockName });
|
|
}
|
|
|
|
function normalizeLoadResponse(value, expectedRequest) {
|
|
if (!hasExactKeys(value, ["requestId", "screen", "retrievedAt", "snapshot"]) ||
|
|
!isRequestId(value.requestId) || !definition(value.screen) ||
|
|
!isSafeText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt))) return null;
|
|
let expected;
|
|
if (expectedRequest !== undefined) {
|
|
try {
|
|
expected = createLoadRequest(
|
|
expectedRequest?.requestId,
|
|
expectedRequest?.screen,
|
|
expectedRequest?.stockName);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (value.requestId !== expected.requestId || value.screen !== expected.screen) return null;
|
|
}
|
|
const snapshot = normalizeSnapshot(value.screen, value.snapshot);
|
|
if (!snapshot || expected && snapshot.stockName !== expected.stockName) return null;
|
|
return Object.freeze({
|
|
requestId: value.requestId,
|
|
screen: value.screen,
|
|
retrievedAt: value.retrievedAt,
|
|
snapshot
|
|
});
|
|
}
|
|
|
|
function normalizeLoadError(value, expectedRequest) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "screen", "stockName", "code", "message", "retryable"
|
|
]) || !isRequestId(value.requestId) || !definition(value.screen) ||
|
|
!isSafeText(value.stockName, 200) || !isSafeText(value.code, 64) ||
|
|
!isSafeText(value.message, 512) || typeof value.retryable !== "boolean") return null;
|
|
if (expectedRequest) {
|
|
let expected;
|
|
try {
|
|
expected = createLoadRequest(
|
|
expectedRequest.requestId,
|
|
expectedRequest.screen,
|
|
expectedRequest.stockName);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (value.requestId !== expected.requestId || value.screen !== expected.screen ||
|
|
value.stockName !== expected.stockName) return null;
|
|
}
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function normalizeStockSearchResponse(value) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
|
|
]) || !isRequestId(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
|
!isSafeText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
|
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
|
typeof value.truncated !== "boolean" || !Array.isArray(value.results) ||
|
|
value.results.length !== value.totalRowCount) return null;
|
|
const results = [];
|
|
const identities = new Set();
|
|
for (const item of value.results) {
|
|
if (!hasExactKeys(item, ["market", "source", "name", "code"]) ||
|
|
!["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"].includes(item.market) ||
|
|
!["oracle", "mariaDb"].includes(item.source) ||
|
|
(item.market.startsWith("nxt-") ? item.source !== "mariaDb" : item.source !== "oracle") ||
|
|
!isSafeText(item.name, 200) || !stockCodePattern.test(item.code)) return null;
|
|
const key = `${item.market}\u001f${item.source}\u001f${item.code}`;
|
|
if (identities.has(key)) return null;
|
|
identities.add(key);
|
|
results.push(Object.freeze({
|
|
market: item.market,
|
|
source: item.source,
|
|
name: item.name,
|
|
code: item.code
|
|
}));
|
|
}
|
|
const response = Object.freeze({
|
|
requestId: value.requestId,
|
|
query: value.query.trim(),
|
|
retrievedAt: value.retrievedAt,
|
|
totalRowCount: value.totalRowCount,
|
|
truncated: value.truncated,
|
|
results: Object.freeze(results)
|
|
});
|
|
trustedStockSearchResponses.add(response);
|
|
return response;
|
|
}
|
|
|
|
function verifyStockForRecord(recordValue, stockResponse, selectedMarket, selectedCode) {
|
|
const screen = recordValue?.kind;
|
|
const record = normalizeRecord(screen, recordValue);
|
|
if (!record || !stockResponse || !trustedStockSearchResponses.has(stockResponse) ||
|
|
typeof selectedMarket !== "string" || !stockCodePattern.test(selectedCode)) return null;
|
|
const matches = stockResponse.results.filter(item => item.name === record.stockName);
|
|
if (matches.length !== 1 || matches[0].market !== selectedMarket ||
|
|
matches[0].code !== selectedCode) return null;
|
|
const verified = Object.freeze({
|
|
market: matches[0].market,
|
|
source: matches[0].source,
|
|
name: matches[0].name,
|
|
code: matches[0].code
|
|
});
|
|
verifiedStocks.add(verified);
|
|
return verified;
|
|
}
|
|
|
|
function normalizeVerifiedStockValue(value) {
|
|
if (!hasExactKeys(value, ["market", "source", "name", "code"]) ||
|
|
!["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"].includes(value.market) ||
|
|
!["oracle", "mariaDb"].includes(value.source) ||
|
|
(value.market.startsWith("nxt-") ? value.source !== "mariaDb" : value.source !== "oracle") ||
|
|
!isSafeText(value.name, 200) || !stockCodePattern.test(value.code)) return null;
|
|
return Object.freeze({
|
|
market: value.market,
|
|
source: value.source,
|
|
name: value.name,
|
|
code: value.code
|
|
});
|
|
}
|
|
|
|
function createSelectionRequest(requestId, snapshot, verifiedStock) {
|
|
if (!isRequestId(requestId)) throw new TypeError("A safe selection request id is required.");
|
|
if (!snapshot || !trustedSnapshots.has(snapshot) ||
|
|
!verifiedStock || !verifiedStocks.has(verifiedStock) ||
|
|
snapshot.stockName !== verifiedStock.name) {
|
|
throw new TypeError("Selection requires a native snapshot and freshly verified stock.");
|
|
}
|
|
return Object.freeze({
|
|
requestId,
|
|
screen: snapshot.screen,
|
|
stockName: snapshot.stockName,
|
|
rowVersion: snapshot.rowVersion,
|
|
stock: Object.freeze({
|
|
market: verifiedStock.market,
|
|
source: verifiedStock.source,
|
|
name: verifiedStock.name,
|
|
code: verifiedStock.code
|
|
})
|
|
});
|
|
}
|
|
|
|
function normalizeSelectionResponse(value, expectedRequest) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "screen", "stockName", "rowVersion", "builderKey", "code",
|
|
"aliases", "selection", "currentPage", "totalPages"
|
|
]) || !isRequestId(value.requestId) || !definition(value.screen) ||
|
|
!isSafeText(value.stockName, 200) || !rowVersionPattern.test(value.rowVersion) ||
|
|
!isSafeText(value.builderKey, 32) || !/^[0-9A-Z]{4,6}$/.test(value.code) ||
|
|
!Array.isArray(value.aliases) || value.aliases.length !== 1 ||
|
|
value.aliases[0] !== value.code ||
|
|
!hasExactKeys(value.selection, [
|
|
"groupCode", "subject", "graphicType", "subtype", "dataCode"
|
|
]) || value.currentPage !== 1 || value.totalPages !== 1) return null;
|
|
const expected = expectedRequest;
|
|
if (!expected || !hasExactKeys(expected, [
|
|
"requestId", "screen", "stockName", "rowVersion", "stock"
|
|
]) || value.requestId !== expected.requestId || value.screen !== expected.screen ||
|
|
value.stockName !== expected.stockName || value.rowVersion !== expected.rowVersion ||
|
|
!normalizeVerifiedStockValue(expected.stock) || expected.stock.name !== expected.stockName) return null;
|
|
const profile = definition(value.screen);
|
|
const groupCode = {
|
|
kospi: "KOSPI",
|
|
kosdaq: "KOSDAQ",
|
|
"nxt-kospi": "NXT_KOSPI",
|
|
"nxt-kosdaq": "NXT_KOSDAQ"
|
|
}[expected.stock?.market];
|
|
if (!profile || !groupCode || value.builderKey !== profile.builderKey ||
|
|
value.code !== profile.code ||
|
|
value.selection.groupCode !== groupCode ||
|
|
value.selection.subject !== value.stockName ||
|
|
value.selection.graphicType !== profile.graphicType ||
|
|
value.selection.subtype !== "" || value.selection.dataCode !== "") return null;
|
|
const normalized = Object.freeze({
|
|
requestId: value.requestId,
|
|
screen: value.screen,
|
|
stockName: value.stockName,
|
|
rowVersion: value.rowVersion,
|
|
builderKey: value.builderKey,
|
|
code: value.code,
|
|
aliases: Object.freeze([...value.aliases]),
|
|
selection: Object.freeze({ ...value.selection }),
|
|
currentPage: 1,
|
|
totalPages: 1
|
|
});
|
|
trustedSelectionResponses.add(normalized);
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeSelectionError(value, expectedRequest) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "screen", "stockName", "code", "message", "retryable"
|
|
]) || !isRequestId(value.requestId) || !definition(value.screen) ||
|
|
!isSafeText(value.stockName, 200) || !isSafeText(value.code, 64) ||
|
|
!isSafeText(value.message, 512) || typeof value.retryable !== "boolean") return null;
|
|
if (expectedRequest &&
|
|
(value.requestId !== expectedRequest.requestId || value.screen !== expectedRequest.screen ||
|
|
value.stockName !== expectedRequest.stockName)) return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function createCreateRequest(requestId, recordValue, verifiedStock) {
|
|
const screen = recordValue?.kind;
|
|
const record = normalizeRecord(screen, recordValue);
|
|
if (!isRequestId(requestId)) throw new TypeError("A safe create request id is required.");
|
|
if (!record || !verifiedStock || !verifiedStocks.has(verifiedStock) ||
|
|
verifiedStock.name !== record.stockName) {
|
|
throw new TypeError("Create requires one verified stock and a valid closed record.");
|
|
}
|
|
return Object.freeze({
|
|
requestId,
|
|
screen,
|
|
stock: Object.freeze({
|
|
market: verifiedStock.market,
|
|
source: verifiedStock.source,
|
|
name: verifiedStock.name,
|
|
code: verifiedStock.code
|
|
}),
|
|
record
|
|
});
|
|
}
|
|
|
|
function createUpdateRequest(requestId, snapshot, replacementValue) {
|
|
if (!isRequestId(requestId)) throw new TypeError("A safe update request id is required.");
|
|
if (!snapshot || !trustedSnapshots.has(snapshot)) {
|
|
throw new TypeError("Update requires a native-normalized snapshot.");
|
|
}
|
|
const replacement = normalizeRecord(snapshot.screen, replacementValue);
|
|
if (!replacement || replacement.stockName !== snapshot.stockName) {
|
|
throw new TypeError("A GraphE update cannot rename a stock or change its screen.");
|
|
}
|
|
return Object.freeze({
|
|
requestId,
|
|
screen: snapshot.screen,
|
|
stockName: snapshot.stockName,
|
|
expectedRowVersion: snapshot.rowVersion,
|
|
record: replacement
|
|
});
|
|
}
|
|
|
|
function createDeleteRequest(requestId, snapshot) {
|
|
if (!isRequestId(requestId)) throw new TypeError("A safe delete request id is required.");
|
|
if (!snapshot || !trustedSnapshots.has(snapshot)) {
|
|
throw new TypeError("Delete requires a native-normalized snapshot.");
|
|
}
|
|
return Object.freeze({
|
|
requestId,
|
|
screen: snapshot.screen,
|
|
stockName: snapshot.stockName,
|
|
expectedRowVersion: snapshot.rowVersion
|
|
});
|
|
}
|
|
|
|
function createDeleteAllRequest(requestId, screen, confirmationToken) {
|
|
const profile = definition(screen);
|
|
if (!isRequestId(requestId)) throw new TypeError("A safe delete-all request id is required.");
|
|
if (!profile || confirmationToken !== profile.deleteAllConfirmation) {
|
|
throw new TypeError("Delete-all requires the exact screen confirmation token.");
|
|
}
|
|
return Object.freeze({ requestId, screen, confirmationToken });
|
|
}
|
|
|
|
function normalizeMutationResult(value, expectedRequest) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "operationId", "operation", "screen", "stockName", "committedAt", "affectedRows"
|
|
]) || !isRequestId(value.requestId) || !isRequestId(value.operationId) ||
|
|
!["create", "update", "delete-one", "delete-all"].includes(value.operation) ||
|
|
!definition(value.screen) ||
|
|
typeof value.stockName !== "string" ||
|
|
(value.operation === "delete-all"
|
|
? value.stockName !== ""
|
|
: !isSafeText(value.stockName, 200)) ||
|
|
!isSafeText(value.committedAt, 64) || !Number.isFinite(Date.parse(value.committedAt)) ||
|
|
!Number.isInteger(value.affectedRows) || value.affectedRows < 0 ||
|
|
value.operation !== "delete-all" && value.affectedRows !== 1) return null;
|
|
if (expectedRequest &&
|
|
(value.requestId !== expectedRequest.requestId || value.screen !== expectedRequest.screen ||
|
|
value.operation !== operationForRequest(expectedRequest) ||
|
|
value.stockName !== (expectedRequest.stockName ?? expectedRequest.record?.stockName ?? ""))) return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function normalizeMutationError(value, expectedRequest) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "operation", "screen", "stockName", "code", "message",
|
|
"retryable", "outcomeUnknown"
|
|
]) || !isRequestId(value.requestId) ||
|
|
!["create", "update", "delete-one", "delete-all"].includes(value.operation) ||
|
|
!definition(value.screen) || typeof value.stockName !== "string" ||
|
|
(value.operation === "delete-all"
|
|
? value.stockName !== ""
|
|
: !isSafeText(value.stockName, 200)) ||
|
|
!isSafeText(value.code, 64) || !isSafeText(value.message, 512) ||
|
|
value.retryable !== false || typeof value.outcomeUnknown !== "boolean") return null;
|
|
if (expectedRequest &&
|
|
(value.requestId !== expectedRequest.requestId || value.screen !== expectedRequest.screen ||
|
|
value.operation !== operationForRequest(expectedRequest) ||
|
|
value.stockName !== (expectedRequest.stockName ?? expectedRequest.record?.stockName ?? ""))) return null;
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function operationForRequest(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
if (Object.prototype.hasOwnProperty.call(value, "confirmationToken")) return "delete-all";
|
|
if (Object.prototype.hasOwnProperty.call(value, "expectedRowVersion")) {
|
|
return Object.prototype.hasOwnProperty.call(value, "record") ? "update" : "delete-one";
|
|
}
|
|
return Object.prototype.hasOwnProperty.call(value, "record") ? "create" : null;
|
|
}
|
|
|
|
function selectionForValues(screen, stockName, stock) {
|
|
const profile = definition(screen);
|
|
const groupCode = {
|
|
kospi: "KOSPI",
|
|
kosdaq: "KOSDAQ",
|
|
"nxt-kospi": "NXT_KOSPI",
|
|
"nxt-kosdaq": "NXT_KOSDAQ"
|
|
}[stock?.market];
|
|
if (!profile || !groupCode) return null;
|
|
return Object.freeze({
|
|
builderKey: profile.builderKey,
|
|
code: profile.code,
|
|
aliases: profile.aliases,
|
|
selection: Object.freeze({
|
|
groupCode,
|
|
subject: stockName,
|
|
graphicType: profile.graphicType,
|
|
subtype: "",
|
|
dataCode: ""
|
|
}),
|
|
currentPage: 1,
|
|
pageCount: 1
|
|
});
|
|
}
|
|
|
|
function buildSelection(snapshot, verifiedStock) {
|
|
if (!snapshot || !trustedSnapshots.has(snapshot) ||
|
|
!verifiedStock || !verifiedStocks.has(verifiedStock) ||
|
|
snapshot.stockName !== verifiedStock.name) return null;
|
|
return selectionForValues(snapshot.screen, snapshot.stockName, verifiedStock);
|
|
}
|
|
|
|
function normalizePlaylistOptions(options, profile, stock) {
|
|
if (!options || typeof options !== "object" || Array.isArray(options) ||
|
|
!Object.keys(options).every(key => ["id", "fadeDuration", "enabled"].includes(key))) {
|
|
throw new TypeError("Manual-financial playlist options are invalid.");
|
|
}
|
|
const id = options.id ?? `manual-financial-${profile.code}-${stock.code}`;
|
|
const fadeDuration = options.fadeDuration ?? DEFAULT_FADE_DURATION;
|
|
const enabled = options.enabled ?? true;
|
|
if (!isRequestId(id) || !Number.isInteger(fadeDuration) ||
|
|
fadeDuration < 0 || fadeDuration > 60 || typeof enabled !== "boolean") {
|
|
throw new TypeError("Manual-financial playlist options are invalid.");
|
|
}
|
|
return Object.freeze({ id, fadeDuration, enabled });
|
|
}
|
|
|
|
function createEntryObject(snapshot, stock, selection, options) {
|
|
const profile = definition(snapshot.screen);
|
|
const normalizedOptions = normalizePlaylistOptions(options, profile, stock);
|
|
const snapshotMetadata = Object.freeze({
|
|
screen: snapshot.screen,
|
|
stockName: snapshot.stockName,
|
|
rowVersion: snapshot.rowVersion,
|
|
record: snapshot.record
|
|
});
|
|
const stockMetadata = Object.freeze({
|
|
market: stock.market,
|
|
source: stock.source,
|
|
name: stock.name,
|
|
code: stock.code
|
|
});
|
|
return Object.freeze({
|
|
id: normalizedOptions.id,
|
|
builderKey: selection.builderKey,
|
|
code: selection.code,
|
|
aliases: Object.freeze([...selection.aliases]),
|
|
title: snapshot.stockName,
|
|
detail: profile.label,
|
|
category: "manual-financial",
|
|
market: stock.market,
|
|
stockName: snapshot.stockName,
|
|
stockCode: stock.code,
|
|
isNxt: stock.market.startsWith("nxt-"),
|
|
selection: Object.freeze({ ...selection.selection }),
|
|
enabled: normalizedOptions.enabled,
|
|
fadeDuration: normalizedOptions.fadeDuration,
|
|
graphicType: selection.selection.graphicType,
|
|
subtype: selection.selection.subtype,
|
|
currentPage: 1,
|
|
pageCount: 1,
|
|
operator: Object.freeze({
|
|
source: "manual-financial-workflow",
|
|
schemaVersion: OPERATOR_SCHEMA_VERSION,
|
|
screen: snapshot.screen,
|
|
snapshot: snapshotMetadata,
|
|
verifiedStock: stockMetadata
|
|
})
|
|
});
|
|
}
|
|
|
|
function createPlaylistEntry(snapshot, verifiedStock, selectionResponse, options = {}) {
|
|
const selection = buildSelection(snapshot, verifiedStock);
|
|
if (!selection || !selectionResponse ||
|
|
!trustedSelectionResponses.has(selectionResponse) ||
|
|
selectionResponse.screen !== snapshot.screen ||
|
|
selectionResponse.stockName !== snapshot.stockName ||
|
|
selectionResponse.rowVersion !== snapshot.rowVersion ||
|
|
selectionResponse.builderKey !== selection.builderKey ||
|
|
selectionResponse.code !== selection.code ||
|
|
!deepEqualValue(selectionResponse.aliases, selection.aliases) ||
|
|
!deepEqualValue(selectionResponse.selection, selection.selection) ||
|
|
selectionResponse.currentPage !== 1 || selectionResponse.totalPages !== 1) {
|
|
throw new TypeError(
|
|
"A native-confirmed selection, trusted snapshot and verified stock are required.");
|
|
}
|
|
const entry = createEntryObject(snapshot, verifiedStock, selection, options);
|
|
trustedPlaylistEntries.add(entry);
|
|
return entry;
|
|
}
|
|
|
|
function deepEqualValue(left, right) {
|
|
if (Object.is(left, right)) return true;
|
|
if (Array.isArray(left) || Array.isArray(right)) {
|
|
return Array.isArray(left) && Array.isArray(right) &&
|
|
left.length === right.length &&
|
|
left.every((value, index) => deepEqualValue(value, right[index]));
|
|
}
|
|
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
|
|
const leftKeys = Object.keys(left).sort();
|
|
const rightKeys = Object.keys(right).sort();
|
|
return leftKeys.length === rightKeys.length &&
|
|
leftKeys.every((key, index) => key === rightKeys[index] &&
|
|
deepEqualValue(left[key], right[key]));
|
|
}
|
|
|
|
function restorePlaylistEntry(value) {
|
|
if (!hasExactKeys(value, [
|
|
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
|
|
"stockName", "stockCode", "isNxt", "selection", "enabled", "fadeDuration",
|
|
"graphicType", "subtype", "currentPage", "pageCount", "operator"
|
|
]) || !value.operator || !hasExactKeys(value.operator, [
|
|
"source", "schemaVersion", "screen", "snapshot", "verifiedStock"
|
|
]) || value.operator.source !== "manual-financial-workflow" ||
|
|
value.operator.schemaVersion !== OPERATOR_SCHEMA_VERSION ||
|
|
!definition(value.operator.screen) ||
|
|
!hasExactKeys(value.operator.snapshot, ["screen", "stockName", "rowVersion", "record"]) ||
|
|
value.operator.snapshot.screen !== value.operator.screen ||
|
|
!rowVersionPattern.test(value.operator.snapshot.rowVersion)) return null;
|
|
const record = normalizeRecord(value.operator.screen, value.operator.snapshot.record);
|
|
const stock = normalizeVerifiedStockValue(value.operator.verifiedStock);
|
|
if (!record || !stock || record.stockName !== value.operator.snapshot.stockName ||
|
|
stock.name !== record.stockName) return null;
|
|
const snapshot = Object.freeze({
|
|
screen: value.operator.screen,
|
|
stockName: record.stockName,
|
|
rowVersion: value.operator.snapshot.rowVersion,
|
|
record
|
|
});
|
|
const selection = selectionForValues(snapshot.screen, snapshot.stockName, stock);
|
|
if (!selection) return null;
|
|
let canonical;
|
|
try {
|
|
canonical = createEntryObject(snapshot, stock, selection, {
|
|
id: value.id,
|
|
fadeDuration: value.fadeDuration,
|
|
enabled: value.enabled
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!deepEqualValue(value, canonical)) return null;
|
|
const pending = Object.freeze({
|
|
status: "refresh-required",
|
|
playable: false,
|
|
entry: canonical,
|
|
identity: Object.freeze({ screen: snapshot.screen, stockName: snapshot.stockName }),
|
|
rowVersion: snapshot.rowVersion,
|
|
verifiedStock: stock
|
|
});
|
|
pendingRestoreEntries.add(pending);
|
|
return pending;
|
|
}
|
|
|
|
function createRestoreLoadRequest(requestId, pending) {
|
|
if (!pending || !pendingRestoreEntries.has(pending)) {
|
|
throw new TypeError("A validated pending restore is required.");
|
|
}
|
|
return createLoadRequest(requestId, pending.identity.screen, pending.identity.stockName);
|
|
}
|
|
|
|
function sameStoredSnapshot(pending, snapshot) {
|
|
const stored = pending?.entry?.operator?.snapshot;
|
|
return !!stored && snapshot.screen === stored.screen &&
|
|
snapshot.stockName === stored.stockName && snapshot.rowVersion === stored.rowVersion &&
|
|
deepEqualValue(snapshot.record, stored.record);
|
|
}
|
|
|
|
function createRestoreSelectionRequest(requestId, pending, loadResponse, verifiedStock) {
|
|
if (!pending || !pendingRestoreEntries.has(pending) ||
|
|
!loadResponse || !loadResponse.snapshot ||
|
|
!trustedSnapshots.has(loadResponse.snapshot) ||
|
|
!verifiedStock || !verifiedStocks.has(verifiedStock) ||
|
|
!sameStoredSnapshot(pending, loadResponse.snapshot) ||
|
|
!deepEqualValue(pending.verifiedStock, verifiedStock)) {
|
|
throw new TypeError("Restore refresh did not reproduce the stored identities.");
|
|
}
|
|
return createSelectionRequest(requestId, loadResponse.snapshot, verifiedStock);
|
|
}
|
|
|
|
function materializeRestoredPlaylistEntry(
|
|
pending,
|
|
loadResponse,
|
|
verifiedStock,
|
|
selectionResponse) {
|
|
if (!pending || !pendingRestoreEntries.has(pending) ||
|
|
!loadResponse || !loadResponse.snapshot ||
|
|
!trustedSnapshots.has(loadResponse.snapshot) ||
|
|
!verifiedStock || !verifiedStocks.has(verifiedStock) ||
|
|
!selectionResponse || !trustedSelectionResponses.has(selectionResponse) ||
|
|
!sameStoredSnapshot(pending, loadResponse.snapshot) ||
|
|
!deepEqualValue(pending.verifiedStock, verifiedStock)) return null;
|
|
let recreated;
|
|
try {
|
|
recreated = createPlaylistEntry(
|
|
loadResponse.snapshot,
|
|
verifiedStock,
|
|
selectionResponse,
|
|
{
|
|
id: pending.entry.id,
|
|
fadeDuration: pending.entry.fadeDuration,
|
|
enabled: pending.entry.enabled
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
return deepEqualValue(recreated, pending.entry) ? recreated : null;
|
|
}
|
|
|
|
function isPlaylistEntryPlayable(value) {
|
|
return !!value && trustedPlaylistEntries.has(value);
|
|
}
|
|
|
|
function withEnabled(entry, enabled) {
|
|
if (!trustedPlaylistEntries.has(entry) || typeof enabled !== "boolean") return null;
|
|
if (entry.enabled === enabled) return entry;
|
|
const replacement = Object.freeze({ ...entry, enabled });
|
|
trustedPlaylistEntries.add(replacement);
|
|
return replacement;
|
|
}
|
|
|
|
return {
|
|
bridgeContract,
|
|
sourceContract,
|
|
actions,
|
|
getScreenDefinition: definition,
|
|
normalizeRecord,
|
|
serializeRecordForStorage,
|
|
normalizeRequestError,
|
|
createListRequest,
|
|
normalizeListResponse,
|
|
normalizeListError,
|
|
createLoadRequest,
|
|
normalizeLoadResponse,
|
|
normalizeLoadError,
|
|
normalizeStockSearchResponse,
|
|
verifyStockForRecord,
|
|
createSelectionRequest,
|
|
normalizeSelectionResponse,
|
|
normalizeSelectionError,
|
|
createCreateRequest,
|
|
createUpdateRequest,
|
|
createDeleteRequest,
|
|
createDeleteAllRequest,
|
|
normalizeMutationResult,
|
|
normalizeMutationError,
|
|
buildSelection,
|
|
createPlaylistEntry,
|
|
restorePlaylistEntry,
|
|
createRestoreLoadRequest,
|
|
createRestoreSelectionRequest,
|
|
materializeRestoredPlaylistEntry,
|
|
withEnabled,
|
|
isPlaylistEntryPlayable
|
|
};
|
|
});
|