Files
MBN_STOCK_WEBVIEW/Web/manual-financial-workflow.js

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
};
});