1100 lines
45 KiB
JavaScript
1100 lines
45 KiB
JavaScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
const STATE_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer/v1";
|
|
const PLAN_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer-plan/v1";
|
|
const ACCEPTANCE_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer-acceptance/v1";
|
|
const PRESENTATION_PATCH_SCHEMA_VERSION = "nodedc.foundry.presentation-patch/v1";
|
|
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
|
|
const cursorPattern = /^(?:0|[1-9]\d*)$/;
|
|
|
|
function consumerError(code, statusCode = 400) {
|
|
return Object.assign(new Error(code), { statusCode });
|
|
}
|
|
|
|
function targetIdentity(target) {
|
|
const applicationId = String(target?.application?.id || target?.applicationId || "");
|
|
const pageId = String(target?.page?.id || target?.pageId || "");
|
|
const binding = target?.binding || {};
|
|
const bindingId = String(binding.id || target?.bindingId || "");
|
|
if (!/^[0-9a-f-]{36}$/i.test(applicationId) || !/^[a-z0-9-]+$/i.test(pageId) || !identifier.test(bindingId)) {
|
|
throw consumerError("data_product_consumer_target_invalid");
|
|
}
|
|
return { applicationId, pageId, bindingId };
|
|
}
|
|
|
|
function targetKey(target) {
|
|
const identity = targetIdentity(target);
|
|
return `${identity.applicationId}/${identity.pageId}/${identity.bindingId}`;
|
|
}
|
|
|
|
function stateFileName(target) {
|
|
return `${createHash("sha256").update(targetKey(target), "utf8").digest("hex")}.json`;
|
|
}
|
|
|
|
function factKey(fact) {
|
|
return `${fact.semanticType}\u0000${fact.sourceId}`;
|
|
}
|
|
|
|
function safeErrorCode(error) {
|
|
const value = String(error?.message || "data_product_consumer_failed");
|
|
return /^[a-z][a-z0-9_]{2,120}$/.test(value) ? value : "data_product_consumer_failed";
|
|
}
|
|
|
|
function safeStatusFromFact(fact, policy, nowMs) {
|
|
const statusContract = policy.statusContract || null;
|
|
if (statusContract) {
|
|
const sourceStatus = fact?.attributes?.[statusContract.attribute];
|
|
if (typeof sourceStatus !== "string" || !statusContract.allowedValues.includes(sourceStatus)) {
|
|
throw consumerError("data_product_consumer_fact_status_invalid", 502);
|
|
}
|
|
if (policy.freshness === "none" || policy.terminalStatuses.includes(sourceStatus)) {
|
|
return sourceStatus;
|
|
}
|
|
const observedAt = Date.parse(String(fact?.observedAt || ""));
|
|
if (Number.isFinite(observedAt) && nowMs - observedAt > policy.staleAfterMs) return "stale";
|
|
return sourceStatus;
|
|
}
|
|
const sourceStatus = [fact?.attributes?.operational_status, fact?.attributes?.status]
|
|
.find((value) => typeof value === "string" && value.trim());
|
|
const normalized = String(sourceStatus || "active").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, 64) || "active";
|
|
if (policy.terminalStatuses.includes(normalized)) return normalized;
|
|
if (policy.freshness === "none") return normalized;
|
|
const observedAt = Date.parse(String(fact?.observedAt || ""));
|
|
if (Number.isFinite(observedAt) && nowMs - observedAt > policy.staleAfterMs) return "stale";
|
|
return normalized;
|
|
}
|
|
|
|
function decorateFact(subject) {
|
|
return { ...subject.fact, presentationStatus: subject.status };
|
|
}
|
|
|
|
function safeTarget(target) {
|
|
const identity = targetIdentity(target);
|
|
const binding = target.binding || {};
|
|
return {
|
|
...identity,
|
|
dataProductId: String(binding.dataProductId || ""),
|
|
slotId: String(binding.slotId || ""),
|
|
delivery: "snapshot+patch",
|
|
semanticTypes: [...(binding.semanticTypes || [])],
|
|
fieldProjection: [...(binding.fieldProjection || [])],
|
|
};
|
|
}
|
|
|
|
function planHash(value) {
|
|
return `fcp1_${createHash("sha256").update(JSON.stringify(value), "utf8").digest("base64url")}`;
|
|
}
|
|
|
|
function emptyMetrics() {
|
|
return {
|
|
snapshotCommits: 0,
|
|
patchCommits: 0,
|
|
patchOperations: 0,
|
|
replayedPatches: 0,
|
|
snapshotRebaseRemovals: 0,
|
|
canonicalRemovals: 0,
|
|
staleTransitions: 0,
|
|
reconnects: 0,
|
|
};
|
|
}
|
|
|
|
function safeStateSummary(record) {
|
|
const state = record.state;
|
|
const subjects = Object.values(state.subjects || {});
|
|
return {
|
|
schemaVersion: STATE_SCHEMA_VERSION,
|
|
consumerId: state.consumerId,
|
|
target: state.target,
|
|
enabled: state.enabled === true,
|
|
runtimeState: state.runtimeState,
|
|
product: state.product,
|
|
policy: state.policy,
|
|
cursor: state.cursor,
|
|
snapshotGeneration: state.snapshotGeneration,
|
|
subjectCount: subjects.length,
|
|
subjects: subjects.slice(0, 20).map((subject) => ({
|
|
subjectId: subject.fact.sourceId,
|
|
semanticType: subject.fact.semanticType,
|
|
observedAt: subject.fact.observedAt,
|
|
status: subject.status,
|
|
})),
|
|
viewerCount: record.listeners.size,
|
|
upstreamStreamCount: record.stream ? 1 : 0,
|
|
metrics: state.metrics,
|
|
timestamps: state.timestamps,
|
|
lastError: state.lastError || null,
|
|
readerGrant: "target-scoped-server-only",
|
|
readerGrantGeneration: Number.isSafeInteger(state.readerGrantGeneration) ? state.readerGrantGeneration : 1,
|
|
};
|
|
}
|
|
|
|
function activeReaderGrantGeneration(state) {
|
|
const generation = Number(state?.readerGrantGeneration ?? 1);
|
|
if (!Number.isSafeInteger(generation) || generation < 1 || generation > 2_147_483_647) {
|
|
throw consumerError("data_product_reader_grant_generation_invalid", 500);
|
|
}
|
|
return generation;
|
|
}
|
|
|
|
function validateConsumerContract(value) {
|
|
if (value === undefined) return null;
|
|
const keys = value && typeof value === "object" && !Array.isArray(value)
|
|
? Object.keys(value).sort()
|
|
: [];
|
|
if (JSON.stringify(keys) !== JSON.stringify([
|
|
"deliveryMode",
|
|
"fieldProjection",
|
|
"fieldTypes",
|
|
"geometryTypes",
|
|
"ontologyRevision",
|
|
"patchMode",
|
|
"semanticTypes",
|
|
"snapshotMode",
|
|
"subjectIdentity",
|
|
])) throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
const semanticTypes = Array.isArray(value.semanticTypes) ? value.semanticTypes : [];
|
|
const fieldProjection = Array.isArray(value.fieldProjection) ? value.fieldProjection : [];
|
|
const geometryTypes = Array.isArray(value.geometryTypes) ? value.geometryTypes : [];
|
|
const supportedGeometryTypes = new Set(["Point", "LineString", "Polygon", "MultiPolygon"]);
|
|
const fieldTypes = value.fieldTypes && typeof value.fieldTypes === "object" && !Array.isArray(value.fieldTypes)
|
|
? value.fieldTypes
|
|
: null;
|
|
if (
|
|
!identifier.test(String(value.ontologyRevision || ""))
|
|
|| value.deliveryMode !== "snapshot+patch"
|
|
|| semanticTypes.length === 0
|
|
|| semanticTypes.length > 8
|
|
|| new Set(semanticTypes).size !== semanticTypes.length
|
|
|| semanticTypes.some((item) => !identifier.test(item))
|
|
|| fieldProjection.length === 0
|
|
|| fieldProjection.length > 32
|
|
|| new Set(fieldProjection).size !== fieldProjection.length
|
|
|| fieldProjection.some((item) => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(item))
|
|
|| !fieldTypes
|
|
|| JSON.stringify(Object.keys(fieldTypes)) !== JSON.stringify(fieldProjection)
|
|
|| Object.values(fieldTypes).some((type) => !["string", "number", "boolean"].includes(type))
|
|
|| geometryTypes.length > supportedGeometryTypes.size
|
|
|| new Set(geometryTypes).size !== geometryTypes.length
|
|
|| geometryTypes.some((type) => !supportedGeometryTypes.has(type))
|
|
|| value.subjectIdentity !== "semantic-type+source-id"
|
|
|| value.snapshotMode !== "atomic-replace"
|
|
|| value.patchMode !== "atomic"
|
|
) throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
return {
|
|
ontologyRevision: value.ontologyRevision,
|
|
deliveryMode: "snapshot+patch",
|
|
semanticTypes: [...semanticTypes],
|
|
fieldProjection: [...fieldProjection],
|
|
fieldTypes: { ...fieldTypes },
|
|
geometryTypes: [...geometryTypes],
|
|
subjectIdentity: "semantic-type+source-id",
|
|
snapshotMode: "atomic-replace",
|
|
patchMode: "atomic",
|
|
};
|
|
}
|
|
|
|
function assertConsumerContract(contract, product, binding) {
|
|
if (!contract) return;
|
|
const productFieldTypes = Object.fromEntries(contract.fieldProjection.map((field) => [
|
|
field,
|
|
product?.fieldContracts?.[field]?.type,
|
|
]));
|
|
const productHasGeometry = Array.isArray(product.fields) && product.fields.includes("geometry");
|
|
const contractHasGeometry = contract.geometryTypes.length > 0;
|
|
if (
|
|
product.ontologyRevision !== contract.ontologyRevision
|
|
|| product.deliveryMode !== contract.deliveryMode
|
|
|| JSON.stringify(product.semanticTypes) !== JSON.stringify(contract.semanticTypes)
|
|
|| JSON.stringify(binding.semanticTypes) !== JSON.stringify(contract.semanticTypes)
|
|
|| JSON.stringify(binding.fieldProjection) !== JSON.stringify(contract.fieldProjection)
|
|
|| !Array.isArray(product.fields)
|
|
|| contract.fieldProjection.some((field) => !product.fields.includes(field))
|
|
|| JSON.stringify(productFieldTypes) !== JSON.stringify(contract.fieldTypes)
|
|
|| productHasGeometry !== contractHasGeometry
|
|
|| (contractHasGeometry && product?.fieldContracts?.geometry?.type !== "geometry")
|
|
) throw consumerError("data_product_consumer_contract_mismatch", 409);
|
|
}
|
|
|
|
function assertEnvelopeProduct(envelope, product, errorCode) {
|
|
if (envelope?.dataProduct?.id !== product.id || envelope?.dataProduct?.version !== product.version) {
|
|
throw consumerError(errorCode, 502);
|
|
}
|
|
}
|
|
|
|
function assertFactContract(fact, policy) {
|
|
const contract = policy.consumerContract;
|
|
if (!contract) return;
|
|
if (!contract.semanticTypes.includes(fact.semanticType)) {
|
|
throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
|
}
|
|
const hasGeometry = fact.geometry !== undefined && fact.geometry !== null;
|
|
if (contract.geometryTypes.length === 0) {
|
|
if (hasGeometry) throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
|
return;
|
|
}
|
|
if (!hasGeometry || !contract.geometryTypes.includes(fact.geometry.type)) {
|
|
throw consumerError("data_product_consumer_fact_contract_invalid", 502);
|
|
}
|
|
}
|
|
|
|
function validatePolicy(policy, product) {
|
|
if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) {
|
|
throw consumerError("data_product_consumer_policy_not_found", 409);
|
|
}
|
|
const statusContract = policy.statusContract === undefined
|
|
? null
|
|
: validateStatusContract(policy.statusContract);
|
|
const freshness = policy.freshness ?? statusContract?.freshness ?? "observed-at";
|
|
if (!["none", "observed-at"].includes(freshness)) {
|
|
throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
}
|
|
if (statusContract && statusContract.freshness !== freshness) {
|
|
throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
}
|
|
const staleDisabled = freshness === "none";
|
|
if (
|
|
(staleDisabled && policy.staleAfterMs !== null)
|
|
|| (!staleDisabled && (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000))
|
|
) {
|
|
throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
}
|
|
const terminalStatuses = Array.isArray(policy.terminalStatuses) ? policy.terminalStatuses : [];
|
|
if (terminalStatuses.some((value) => typeof value !== "string" || !/^[a-z0-9_-]{1,64}$/.test(value))) {
|
|
throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
}
|
|
if (statusContract && terminalStatuses.some((value) => !statusContract.allowedValues.includes(value))) {
|
|
throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
}
|
|
if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") {
|
|
throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
}
|
|
const consumerContract = validateConsumerContract(policy.consumerContract);
|
|
return {
|
|
id: String(policy.id || ""),
|
|
version: String(policy.version || ""),
|
|
dataProductId: product.id,
|
|
productVersion: product.version,
|
|
freshness,
|
|
staleAfterMs: policy.staleAfterMs,
|
|
terminalStatuses: [...terminalStatuses],
|
|
...(statusContract ? { statusContract } : {}),
|
|
...(consumerContract ? { consumerContract } : {}),
|
|
removeMode: policy.removeMode,
|
|
};
|
|
}
|
|
|
|
function validateStatusContract(value) {
|
|
const keys = value && typeof value === "object" && !Array.isArray(value)
|
|
? Object.keys(value).sort()
|
|
: [];
|
|
if (JSON.stringify(keys) !== JSON.stringify(["allowedValues", "attribute", "freshness", "missing"])) {
|
|
throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
}
|
|
const attribute = String(value.attribute || "");
|
|
const allowedValues = Array.isArray(value.allowedValues) ? value.allowedValues : [];
|
|
if (
|
|
!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(attribute)
|
|
|| allowedValues.length === 0
|
|
|| allowedValues.length > 32
|
|
|| new Set(allowedValues).size !== allowedValues.length
|
|
|| allowedValues.some((item) => typeof item !== "string" || !/^[a-z0-9_-]{1,64}$/.test(item))
|
|
|| value.missing !== "reject"
|
|
|| !["none", "observed-at"].includes(value.freshness)
|
|
) {
|
|
throw consumerError("data_product_consumer_policy_invalid", 500);
|
|
}
|
|
return {
|
|
attribute,
|
|
allowedValues: [...allowedValues],
|
|
missing: "reject",
|
|
freshness: value.freshness,
|
|
};
|
|
}
|
|
|
|
function parseSseBlock(block) {
|
|
const fields = { event: "message", id: "", data: [] };
|
|
for (const line of block.split("\n")) {
|
|
if (!line || line.startsWith(":")) continue;
|
|
const separator = line.indexOf(":");
|
|
const name = separator === -1 ? line : line.slice(0, separator);
|
|
const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
|
|
if (name === "event") fields.event = value;
|
|
if (name === "id") fields.id = value;
|
|
if (name === "data") fields.data.push(value);
|
|
}
|
|
return { event: fields.event, id: fields.id, data: fields.data.join("\n") };
|
|
}
|
|
|
|
function waitForAbortableDelay(ms, signal) {
|
|
if (signal.aborted) return Promise.resolve();
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(done, ms);
|
|
function done() {
|
|
clearTimeout(timer);
|
|
signal.removeEventListener("abort", done);
|
|
resolve();
|
|
}
|
|
signal.addEventListener("abort", done, { once: true });
|
|
});
|
|
}
|
|
|
|
export function createFoundryDataProductConsumerManager({
|
|
stateDir,
|
|
dataPlaneUrl,
|
|
resolveTarget,
|
|
readReaderToken,
|
|
inspectReaderGrant,
|
|
ensureReaderGrant,
|
|
revokeReaderGrant,
|
|
resolvePolicy,
|
|
sanitizeSnapshot,
|
|
sanitizePatch,
|
|
fetchImpl = fetch,
|
|
openStream,
|
|
now = () => Date.now(),
|
|
idleStopMs = 2_000,
|
|
staleSweepMs = 5_000,
|
|
reconnectMinMs = 500,
|
|
reconnectMaxMs = 15_000,
|
|
}) {
|
|
const records = new Map();
|
|
const ready = mkdir(stateDir, { recursive: true });
|
|
let closed = false;
|
|
|
|
async function writeState(record) {
|
|
await ready;
|
|
const targetPath = join(stateDir, stateFileName(record.state.target));
|
|
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
await writeFile(tempPath, `${JSON.stringify(record.state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
await rename(tempPath, targetPath);
|
|
}
|
|
|
|
async function readState(target) {
|
|
await ready;
|
|
try {
|
|
const parsed = JSON.parse(await readFile(join(stateDir, stateFileName(target)), "utf8"));
|
|
if (parsed?.schemaVersion !== STATE_SCHEMA_VERSION || targetKey(parsed.target) !== targetKey(target)) {
|
|
throw consumerError("data_product_consumer_state_invalid", 500);
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function recordFor(target) {
|
|
const key = targetKey(target);
|
|
if (records.has(key)) return records.get(key);
|
|
const persisted = await readState(target);
|
|
const record = {
|
|
key,
|
|
target,
|
|
state: persisted,
|
|
listeners: new Map(),
|
|
stream: null,
|
|
idleTimer: null,
|
|
writing: Promise.resolve(),
|
|
};
|
|
records.set(key, record);
|
|
return record;
|
|
}
|
|
|
|
function serialize(record, action) {
|
|
const pending = record.writing.then(action, action);
|
|
record.writing = pending.catch(() => undefined);
|
|
return pending;
|
|
}
|
|
|
|
function emit(record, event) {
|
|
for (const listener of record.listeners.values()) {
|
|
try { listener(event); } catch { /* one browser cannot stop the shared consumer */ }
|
|
}
|
|
}
|
|
|
|
async function catalog(target, { generation = 1, ensureGeneration = generation } = {}) {
|
|
if (!dataPlaneUrl) throw consumerError("data_product_runtime_not_configured", 503);
|
|
let product = null;
|
|
let readerGrantAction = "reuse";
|
|
let readerGrantGeneration = generation;
|
|
if (inspectReaderGrant) {
|
|
const inspection = await inspectReaderGrant(target, { generation });
|
|
product = inspection?.product || null;
|
|
readerGrantAction = inspection?.readerGrantAction === "ensure" ? "ensure" : "reuse";
|
|
readerGrantGeneration = readerGrantAction === "ensure" ? ensureGeneration : generation;
|
|
} else {
|
|
const token = await readReaderToken(target.application.id, target.page.id, target.binding.id, generation);
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(new URL("/internal/data-plane/v1/reader/data-products", `${dataPlaneUrl}/`), {
|
|
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
} catch {
|
|
throw consumerError("data_product_consumer_catalog_unavailable", 503);
|
|
}
|
|
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
|
|
if (!response.ok) throw consumerError("data_product_consumer_catalog_unavailable", 503);
|
|
const payload = await response.json().catch(() => null);
|
|
product = Array.isArray(payload?.dataProducts)
|
|
? payload.dataProducts.find((candidate) => candidate?.id === target.binding.dataProductId)
|
|
: null;
|
|
}
|
|
if (!product || product.active === false) throw consumerError("data_product_not_found", 404);
|
|
if (product.deliveryMode !== "snapshot+patch" || typeof product.version !== "string") {
|
|
throw consumerError("data_product_consumer_delivery_not_supported", 409);
|
|
}
|
|
if (target.binding.semanticTypes.some((semanticType) => !product.semanticTypes?.includes(semanticType))) {
|
|
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
|
|
}
|
|
const policy = validatePolicy(resolvePolicy(product), product);
|
|
assertConsumerContract(policy.consumerContract, product, target.binding);
|
|
if (policy.statusContract && !target.binding.fieldProjection.includes(policy.statusContract.attribute)) {
|
|
throw consumerError("data_product_consumer_status_field_not_projected", 409);
|
|
}
|
|
return {
|
|
product,
|
|
policy,
|
|
readerGrantAction,
|
|
readerGrantGeneration,
|
|
};
|
|
}
|
|
|
|
async function plan(input) {
|
|
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
|
const record = await recordFor(target);
|
|
const currentReaderGrantGeneration = activeReaderGrantGeneration(record.state);
|
|
const ensureReaderGrantGeneration = record.state
|
|
? Math.min(2_147_483_647, currentReaderGrantGeneration + 1)
|
|
: currentReaderGrantGeneration;
|
|
const { product, policy, readerGrantAction, readerGrantGeneration } = await catalog(target, {
|
|
generation: currentReaderGrantGeneration,
|
|
ensureGeneration: ensureReaderGrantGeneration,
|
|
});
|
|
if (readerGrantAction === "ensure" && record.state && currentReaderGrantGeneration === 2_147_483_647) {
|
|
throw consumerError("data_product_reader_grant_generation_exhausted", 409);
|
|
}
|
|
const safe = safeTarget(target);
|
|
const configuration = {
|
|
target: safe,
|
|
product: { id: product.id, version: product.version },
|
|
policy,
|
|
readerGrant: "target-scoped-server-only",
|
|
readerGrantAction,
|
|
readerGrantGeneration,
|
|
};
|
|
const existingMatches = record.state
|
|
&& JSON.stringify(record.state.target) === JSON.stringify(safe)
|
|
&& record.state.product?.id === product.id
|
|
&& record.state.product?.version === product.version
|
|
&& record.state.policy?.id === policy.id
|
|
&& record.state.policy?.version === policy.version;
|
|
const predecessorReaderGrantGeneration = Number.isSafeInteger(record.state?.predecessorReaderGrantGeneration)
|
|
? record.state.predecessorReaderGrantGeneration
|
|
: null;
|
|
const action = !record.state
|
|
? "create"
|
|
: existingMatches && predecessorReaderGrantGeneration
|
|
? "finalize-reader-grant-rotation"
|
|
: existingMatches
|
|
? (record.state.enabled ? "refresh" : "resume")
|
|
: "replace";
|
|
return {
|
|
schemaVersion: PLAN_SCHEMA_VERSION,
|
|
planId: planHash({ configuration, action }),
|
|
action,
|
|
configuration,
|
|
current: record.state ? { enabled: record.state.enabled === true, cursor: record.state.cursor, product: record.state.product } : null,
|
|
effects: [
|
|
...(readerGrantAction === "ensure"
|
|
? [record.state ? "ensure-successor-target-scoped-reader-grant" : "ensure-target-scoped-reader-grant"]
|
|
: []),
|
|
"persist-consumer-state",
|
|
...(action === "finalize-reader-grant-rotation" ? [] : ["bootstrap-scoped-snapshot"]),
|
|
...(predecessorReaderGrantGeneration ? ["revoke-predecessor-reader-grant"] : []),
|
|
"share-one-upstream-stream-per-active-binding",
|
|
],
|
|
destructive: false,
|
|
};
|
|
}
|
|
|
|
function initialState(target, product, policy, readerGrantGeneration) {
|
|
const timestamp = new Date(now()).toISOString();
|
|
return {
|
|
schemaVersion: STATE_SCHEMA_VERSION,
|
|
consumerId: createHash("sha256").update(targetKey(target), "utf8").digest("hex").slice(0, 32),
|
|
target: safeTarget(target),
|
|
enabled: true,
|
|
runtimeState: "bootstrapping",
|
|
product: { id: product.id, version: product.version },
|
|
policy,
|
|
readerGrantGeneration,
|
|
predecessorReaderGrantGeneration: null,
|
|
cursor: "0",
|
|
snapshotGeneration: 0,
|
|
subjects: {},
|
|
metrics: emptyMetrics(),
|
|
timestamps: { createdAt: timestamp, updatedAt: timestamp, lastSnapshotAt: null, lastPatchAt: null, lastConnectedAt: null },
|
|
lastError: null,
|
|
};
|
|
}
|
|
|
|
function upstreamUrl(record, resource, after = "") {
|
|
const url = new URL(`/internal/data-plane/v1/data-products/${encodeURIComponent(record.state.target.dataProductId)}/${resource}`, `${dataPlaneUrl}/`);
|
|
if (after) url.searchParams.set("after", after);
|
|
return url;
|
|
}
|
|
|
|
async function fetchSnapshot(record) {
|
|
const token = await readReaderToken(
|
|
record.state.target.applicationId,
|
|
record.state.target.pageId,
|
|
record.state.target.bindingId,
|
|
activeReaderGrantGeneration(record.state),
|
|
);
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(upstreamUrl(record, "snapshot"), {
|
|
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
} catch {
|
|
throw consumerError("data_product_runtime_unavailable", 503);
|
|
}
|
|
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
|
|
if (response.status === 404) throw consumerError("data_product_not_found", 404);
|
|
if (!response.ok) throw consumerError("data_product_runtime_unavailable", 503);
|
|
const payload = await response.json().catch(() => null);
|
|
return sanitizeSnapshot(payload, record.target.binding);
|
|
}
|
|
|
|
async function bootstrap(record, { notify = true } = {}) {
|
|
return serialize(record, async () => {
|
|
record.state.runtimeState = "bootstrapping";
|
|
record.state.timestamps.updatedAt = new Date(now()).toISOString();
|
|
await writeState(record);
|
|
try {
|
|
const snapshot = await fetchSnapshot(record);
|
|
assertEnvelopeProduct(snapshot, record.state.product, "data_product_snapshot_product_mismatch");
|
|
const previousKeys = new Set(Object.keys(record.state.subjects || {}));
|
|
const subjects = {};
|
|
for (const fact of snapshot.facts) {
|
|
assertFactContract(fact, record.state.policy);
|
|
const key = factKey(fact);
|
|
subjects[key] = {
|
|
fact,
|
|
status: safeStatusFromFact(fact, record.state.policy, now()),
|
|
lastAppliedCursor: snapshot.cursor,
|
|
};
|
|
previousKeys.delete(key);
|
|
}
|
|
const timestamp = new Date(now()).toISOString();
|
|
record.state = {
|
|
...record.state,
|
|
enabled: true,
|
|
runtimeState: record.listeners.size ? "connecting" : "idle",
|
|
product: snapshot.dataProduct,
|
|
cursor: snapshot.cursor,
|
|
snapshotGeneration: record.state.snapshotGeneration + 1,
|
|
subjects,
|
|
metrics: {
|
|
...record.state.metrics,
|
|
snapshotCommits: record.state.metrics.snapshotCommits + 1,
|
|
snapshotRebaseRemovals: record.state.metrics.snapshotRebaseRemovals + previousKeys.size,
|
|
},
|
|
timestamps: { ...record.state.timestamps, updatedAt: timestamp, lastSnapshotAt: timestamp },
|
|
lastError: null,
|
|
};
|
|
await writeState(record);
|
|
if (notify) emit(record, {
|
|
event: "nodedc.data-product.resync-required.v1",
|
|
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: record.state.product.id },
|
|
});
|
|
return snapshot;
|
|
} catch (error) {
|
|
const timestamp = new Date(now()).toISOString();
|
|
record.state.runtimeState = "error";
|
|
record.state.lastError = { code: safeErrorCode(error), at: timestamp };
|
|
record.state.timestamps.updatedAt = timestamp;
|
|
await writeState(record);
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function apply(input) {
|
|
const planned = await plan(input);
|
|
if (input.planId !== planned.planId) throw consumerError("data_product_consumer_plan_mismatch", 409);
|
|
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
|
const record = await recordFor(target);
|
|
const plannedGeneration = planned.configuration.readerGrantGeneration;
|
|
|
|
if (planned.action === "finalize-reader-grant-rotation") {
|
|
const predecessorGeneration = record.state?.predecessorReaderGrantGeneration;
|
|
if (!revokeReaderGrant || !Number.isSafeInteger(predecessorGeneration)) {
|
|
throw consumerError("data_product_reader_grant_rotation_invalid", 500);
|
|
}
|
|
await revokeReaderGrant(record.target, { generation: predecessorGeneration });
|
|
record.state.predecessorReaderGrantGeneration = null;
|
|
record.state.timestamps.updatedAt = new Date(now()).toISOString();
|
|
record.state.lastError = null;
|
|
await writeState(record);
|
|
return { plan: planned, consumer: safeStateSummary(record) };
|
|
}
|
|
|
|
if (planned.configuration.readerGrantAction === "ensure") {
|
|
if (!ensureReaderGrant) throw consumerError("data_product_reader_grant_not_found", 403);
|
|
await ensureReaderGrant(target, { generation: plannedGeneration });
|
|
}
|
|
const { product, policy, readerGrantAction, readerGrantGeneration } = await catalog(target, {
|
|
generation: plannedGeneration,
|
|
ensureGeneration: plannedGeneration,
|
|
});
|
|
if (readerGrantAction !== "reuse") throw consumerError("data_product_reader_grant_not_ready", 409);
|
|
if (readerGrantGeneration !== plannedGeneration) throw consumerError("data_product_reader_grant_generation_mismatch", 409);
|
|
|
|
const previousTarget = record.target;
|
|
const previousState = record.state ? structuredClone(record.state) : null;
|
|
const previousGeneration = previousState ? activeReaderGrantGeneration(previousState) : null;
|
|
if (record.stream) await stopStreamAndWait(record);
|
|
if (!record.state || planned.action === "replace") {
|
|
record.target = target;
|
|
record.state = initialState(target, product, policy, plannedGeneration);
|
|
if (previousGeneration && previousGeneration !== plannedGeneration) {
|
|
record.state.predecessorReaderGrantGeneration = previousGeneration;
|
|
}
|
|
}
|
|
else {
|
|
record.target = target;
|
|
record.state.target = safeTarget(target);
|
|
record.state.product = { id: product.id, version: product.version };
|
|
record.state.policy = policy;
|
|
record.state.readerGrantGeneration = plannedGeneration;
|
|
record.state.enabled = true;
|
|
record.state.runtimeState = "bootstrapping";
|
|
record.state.lastError = null;
|
|
}
|
|
try {
|
|
await writeState(record);
|
|
await bootstrap(record, { notify: false });
|
|
} catch (error) {
|
|
if (previousState) {
|
|
record.target = previousTarget;
|
|
record.state = previousState;
|
|
record.state.runtimeState = record.listeners.size ? "connecting" : "idle";
|
|
record.state.timestamps.updatedAt = new Date(now()).toISOString();
|
|
await writeState(record);
|
|
if (record.listeners.size) startStream(record);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const predecessorGeneration = record.state.predecessorReaderGrantGeneration;
|
|
if (Number.isSafeInteger(predecessorGeneration)) {
|
|
if (!revokeReaderGrant) throw consumerError("data_product_reader_grant_rotation_not_configured", 503);
|
|
await revokeReaderGrant(previousTarget, { generation: predecessorGeneration });
|
|
record.state.predecessorReaderGrantGeneration = null;
|
|
record.state.timestamps.updatedAt = new Date(now()).toISOString();
|
|
await writeState(record);
|
|
}
|
|
if (record.listeners.size) startStream(record);
|
|
return { plan: planned, consumer: safeStateSummary(record) };
|
|
}
|
|
|
|
async function ensureProvisioned(target) {
|
|
const record = await recordFor(target);
|
|
if (record.state) {
|
|
if (!record.state.enabled) throw consumerError("data_product_consumer_stopped", 409);
|
|
record.target = target;
|
|
return record;
|
|
}
|
|
const planned = await plan(targetIdentity(target));
|
|
await apply({ ...targetIdentity(target), planId: planned.planId });
|
|
return recordFor(target);
|
|
}
|
|
|
|
async function refreshPresentation(record) {
|
|
if (!record.state?.enabled) return [];
|
|
return serialize(record, async () => {
|
|
const operations = [];
|
|
for (const subject of Object.values(record.state.subjects || {})) {
|
|
const status = safeStatusFromFact(subject.fact, record.state.policy, now());
|
|
if (status === subject.status) continue;
|
|
subject.status = status;
|
|
operations.push({ op: "upsert", sourceId: subject.fact.sourceId, semanticType: subject.fact.semanticType, status });
|
|
}
|
|
if (!operations.length) return operations;
|
|
const timestamp = new Date(now()).toISOString();
|
|
record.state.metrics.staleTransitions += operations.filter((operation) => operation.status === "stale").length;
|
|
record.state.timestamps.updatedAt = timestamp;
|
|
await writeState(record);
|
|
emit(record, {
|
|
event: "nodedc.foundry.presentation-patch.v1",
|
|
data: { schemaVersion: PRESENTATION_PATCH_SCHEMA_VERSION, generatedAt: timestamp, operations },
|
|
});
|
|
return operations;
|
|
});
|
|
}
|
|
|
|
async function applyPatch(record, patch) {
|
|
return serialize(record, async () => {
|
|
const currentCursor = BigInt(record.state.cursor);
|
|
const patchCursor = BigInt(patch.cursor);
|
|
if (patchCursor <= currentCursor) {
|
|
record.state.metrics.replayedPatches += 1;
|
|
return { replayed: true };
|
|
}
|
|
if (patch.previousCursor !== record.state.cursor) throw consumerError("resync_required", 409);
|
|
const subjects = { ...record.state.subjects };
|
|
const emittedOperations = [];
|
|
for (const operation of patch.operations) {
|
|
if (operation.op === "upsert") {
|
|
assertFactContract(operation.fact, record.state.policy);
|
|
const key = factKey(operation.fact);
|
|
const subject = {
|
|
fact: operation.fact,
|
|
status: safeStatusFromFact(operation.fact, record.state.policy, now()),
|
|
lastAppliedCursor: patch.cursor,
|
|
};
|
|
subjects[key] = subject;
|
|
emittedOperations.push({ op: "upsert", fact: decorateFact(subject) });
|
|
} else if (operation.op === "remove") {
|
|
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
|
|
if (subjects[key]) {
|
|
delete subjects[key];
|
|
emittedOperations.push(operation);
|
|
}
|
|
}
|
|
}
|
|
const timestamp = new Date(now()).toISOString();
|
|
record.state = {
|
|
...record.state,
|
|
runtimeState: "connected",
|
|
cursor: patch.cursor,
|
|
subjects,
|
|
metrics: {
|
|
...record.state.metrics,
|
|
patchCommits: record.state.metrics.patchCommits + 1,
|
|
patchOperations: record.state.metrics.patchOperations + emittedOperations.length,
|
|
canonicalRemovals: record.state.metrics.canonicalRemovals + emittedOperations.filter((operation) => operation.op === "remove").length,
|
|
},
|
|
timestamps: { ...record.state.timestamps, updatedAt: timestamp, lastPatchAt: timestamp },
|
|
lastError: null,
|
|
};
|
|
// Persist the new cursor and semantic state before acknowledging it to
|
|
// any browser listener. A crash can replay a patch but cannot skip one.
|
|
await writeState(record);
|
|
if (emittedOperations.length) emit(record, {
|
|
event: "nodedc.data-product.patch.v1",
|
|
id: patch.cursor,
|
|
data: { ...patch, operations: emittedOperations },
|
|
});
|
|
return { replayed: false };
|
|
});
|
|
}
|
|
|
|
async function consumeOnce(record, signal) {
|
|
const token = await readReaderToken(
|
|
record.state.target.applicationId,
|
|
record.state.target.pageId,
|
|
record.state.target.bindingId,
|
|
activeReaderGrantGeneration(record.state),
|
|
);
|
|
const url = upstreamUrl(record, "stream", record.state.cursor);
|
|
const response = openStream
|
|
? await openStream({ url, token, signal })
|
|
: await fetchImpl(url, {
|
|
headers: { authorization: `Bearer ${token}`, accept: "text/event-stream", connection: "close" },
|
|
signal,
|
|
});
|
|
if (response.status === 409) throw consumerError("resync_required", 409);
|
|
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
|
|
if (!response.ok || !response.body || !String(response.headers.get("content-type") || "").startsWith("text/event-stream")) {
|
|
throw consumerError("data_product_runtime_unavailable", 503);
|
|
}
|
|
const timestamp = new Date(now()).toISOString();
|
|
record.state.runtimeState = "connected";
|
|
record.state.timestamps.lastConnectedAt = timestamp;
|
|
record.state.timestamps.updatedAt = timestamp;
|
|
record.state.lastError = null;
|
|
await writeState(record);
|
|
const reader = response.body.getReader();
|
|
if (record.stream) record.stream.reader = reader;
|
|
const decoder = new TextDecoder();
|
|
let pending = "";
|
|
let upstreamDone = false;
|
|
try {
|
|
while (!signal.aborted && record.listeners.size) {
|
|
const next = await reader.read();
|
|
if (next.done) {
|
|
upstreamDone = true;
|
|
break;
|
|
}
|
|
pending += decoder.decode(next.value, { stream: true }).replace(/\r\n/g, "\n");
|
|
let separator;
|
|
while ((separator = pending.indexOf("\n\n")) !== -1) {
|
|
const event = parseSseBlock(pending.slice(0, separator));
|
|
pending = pending.slice(separator + 2);
|
|
if (event.event !== "nodedc.data-product.patch.v1") continue;
|
|
let payload = null;
|
|
try { payload = JSON.parse(event.data); } catch { payload = null; }
|
|
const patch = sanitizePatch(payload, record.target.binding);
|
|
if (!patch) throw consumerError("data_product_patch_contract_invalid", 502);
|
|
assertEnvelopeProduct(patch, record.state.product, "data_product_patch_product_mismatch");
|
|
await applyPatch(record, patch);
|
|
}
|
|
}
|
|
} finally {
|
|
if (!upstreamDone) await reader.cancel().catch(() => undefined);
|
|
reader.releaseLock();
|
|
if (record.stream?.reader === reader) record.stream.reader = null;
|
|
}
|
|
}
|
|
|
|
function startStream(record) {
|
|
if (closed || record.stream || !record.state?.enabled || !record.listeners.size) return;
|
|
if (record.idleTimer) clearTimeout(record.idleTimer);
|
|
record.idleTimer = null;
|
|
const controller = new AbortController();
|
|
record.stream = { controller, promise: null, reader: null };
|
|
record.stream.promise = (async () => {
|
|
let delay = reconnectMinMs;
|
|
while (!closed && !controller.signal.aborted && record.listeners.size && record.state.enabled) {
|
|
try {
|
|
record.state.runtimeState = "connecting";
|
|
await writeState(record);
|
|
await consumeOnce(record, controller.signal);
|
|
delay = reconnectMinMs;
|
|
if (!controller.signal.aborted && record.listeners.size) throw consumerError("data_product_stream_closed", 503);
|
|
} catch (error) {
|
|
if (controller.signal.aborted || closed || !record.listeners.size) break;
|
|
if (error?.message === "resync_required") {
|
|
await bootstrap(record).catch(() => undefined);
|
|
} else {
|
|
const timestamp = new Date(now()).toISOString();
|
|
record.state.runtimeState = "reconnecting";
|
|
record.state.metrics.reconnects += 1;
|
|
record.state.lastError = { code: safeErrorCode(error), at: timestamp };
|
|
record.state.timestamps.updatedAt = timestamp;
|
|
await writeState(record);
|
|
}
|
|
await waitForAbortableDelay(delay, controller.signal);
|
|
delay = Math.min(reconnectMaxMs, delay * 2);
|
|
}
|
|
}
|
|
})().finally(async () => {
|
|
if (record.stream?.controller === controller) record.stream = null;
|
|
if (record.state?.enabled && !record.listeners.size) {
|
|
record.state.runtimeState = "idle";
|
|
record.state.timestamps.updatedAt = new Date(now()).toISOString();
|
|
await writeState(record).catch(() => undefined);
|
|
}
|
|
});
|
|
}
|
|
|
|
function stopStream(record) {
|
|
if (record.idleTimer) clearTimeout(record.idleTimer);
|
|
record.idleTimer = null;
|
|
void record.stream?.reader?.cancel().catch(() => undefined);
|
|
record.stream?.controller.abort();
|
|
}
|
|
|
|
async function stopStreamAndWait(record) {
|
|
const pending = record.stream?.promise;
|
|
stopStream(record);
|
|
if (!pending) return;
|
|
await Promise.race([
|
|
pending.catch(() => undefined),
|
|
new Promise((resolve) => setTimeout(resolve, 750)),
|
|
]);
|
|
}
|
|
|
|
async function snapshot(input) {
|
|
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
|
const record = await ensureProvisioned(target);
|
|
// A removed target grant revokes browser reads immediately even though the
|
|
// last safe snapshot remains persisted for rollback/diagnostics.
|
|
await readReaderToken(
|
|
record.state.target.applicationId,
|
|
record.state.target.pageId,
|
|
record.state.target.bindingId,
|
|
activeReaderGrantGeneration(record.state),
|
|
);
|
|
await refreshPresentation(record);
|
|
return {
|
|
schemaVersion: "nodedc.data-product.snapshot/v1",
|
|
dataProduct: record.state.product,
|
|
generatedAt: record.state.timestamps.lastSnapshotAt || record.state.timestamps.updatedAt,
|
|
cursor: record.state.cursor,
|
|
facts: Object.values(record.state.subjects).map(decorateFact),
|
|
};
|
|
}
|
|
|
|
async function subscribe(input, listener) {
|
|
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
|
const record = await ensureProvisioned(target);
|
|
const after = String(input.after || "");
|
|
if (after && !cursorPattern.test(after)) throw consumerError("stream_cursor_invalid");
|
|
if (after && after !== record.state.cursor) return { resyncRequired: true, cursor: record.state.cursor, release() {} };
|
|
const leaseId = randomUUID();
|
|
record.listeners.set(leaseId, listener);
|
|
listener({
|
|
event: "nodedc.data-product.ready.v1",
|
|
data: {
|
|
schemaVersion: "nodedc.data-product.ready/v1",
|
|
dataProductId: record.state.product.id,
|
|
cursor: record.state.cursor,
|
|
emittedAt: new Date(now()).toISOString(),
|
|
},
|
|
});
|
|
startStream(record);
|
|
let released = false;
|
|
return {
|
|
resyncRequired: false,
|
|
cursor: record.state.cursor,
|
|
release() {
|
|
if (released) return;
|
|
released = true;
|
|
record.listeners.delete(leaseId);
|
|
if (!record.listeners.size && record.stream && !record.idleTimer) {
|
|
record.idleTimer = setTimeout(() => {
|
|
record.idleTimer = null;
|
|
if (!record.listeners.size) stopStream(record);
|
|
}, idleStopMs);
|
|
record.idleTimer.unref?.();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async function status(input) {
|
|
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
|
const record = await recordFor(target);
|
|
if (!record.state) return { found: false, target: safeTarget(target), readerGrant: "target-scoped-server-only" };
|
|
await refreshPresentation(record);
|
|
return { found: true, consumer: safeStateSummary(record) };
|
|
}
|
|
|
|
async function accept(input) {
|
|
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
|
const record = await ensureProvisioned(target);
|
|
const timeoutMs = Math.min(30_000, Math.max(250, Number(input.timeoutMs ?? 5_000)));
|
|
const minSubjectCount = Math.min(5_000, Math.max(0, Number(input.minSubjectCount ?? 1)));
|
|
const minPatchCount = Math.min(1_000, Math.max(0, Number(input.minPatchCount ?? 0)));
|
|
if (![timeoutMs, minSubjectCount, minPatchCount].every(Number.isInteger)) throw consumerError("data_product_consumer_acceptance_input_invalid");
|
|
const baselinePatchCount = record.state.metrics.patchCommits;
|
|
const lease = await subscribe(target, () => undefined);
|
|
const deadline = now() + timeoutMs;
|
|
try {
|
|
while (now() < deadline) {
|
|
const patchDelta = record.state.metrics.patchCommits - baselinePatchCount;
|
|
if (Object.keys(record.state.subjects).length >= minSubjectCount && patchDelta >= minPatchCount) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
await refreshPresentation(record);
|
|
const subjectKeys = Object.keys(record.state.subjects);
|
|
const duplicateCount = subjectKeys.length - new Set(subjectKeys).size;
|
|
const patchDelta = record.state.metrics.patchCommits - baselinePatchCount;
|
|
const upstreamStreamCount = record.stream ? 1 : 0;
|
|
const checks = {
|
|
bindingResolved: true,
|
|
targetScopedReader: true,
|
|
snapshotCommitted: record.state.metrics.snapshotCommits > 0,
|
|
cursorPersisted: cursorPattern.test(record.state.cursor),
|
|
minimumSubjects: subjectKeys.length >= minSubjectCount,
|
|
minimumNewPatches: patchDelta >= minPatchCount,
|
|
duplicateSubjects: duplicateCount === 0,
|
|
singleUpstreamStream: upstreamStreamCount <= 1,
|
|
secretAndEndpointExcluded: true,
|
|
};
|
|
return {
|
|
schemaVersion: ACCEPTANCE_SCHEMA_VERSION,
|
|
accepted: Object.values(checks).every(Boolean),
|
|
checks,
|
|
observed: { subjectCount: subjectKeys.length, newPatchCount: patchDelta, cursor: record.state.cursor, runtimeState: record.state.runtimeState },
|
|
consumer: safeStateSummary(record),
|
|
};
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
}
|
|
|
|
async function rollback(input) {
|
|
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
|
|
const record = await recordFor(target);
|
|
if (!record.state) throw consumerError("data_product_consumer_not_found", 404);
|
|
stopStream(record);
|
|
record.listeners.clear();
|
|
record.state.enabled = false;
|
|
record.state.runtimeState = "stopped";
|
|
record.state.timestamps.updatedAt = new Date(now()).toISOString();
|
|
record.state.lastError = null;
|
|
await writeState(record);
|
|
return { rolledBack: true, snapshotPreserved: true, consumer: safeStateSummary(record) };
|
|
}
|
|
|
|
async function resumePersisted() {
|
|
await ready;
|
|
for (const entry of await readdir(stateDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || !/^[0-9a-f]{64}\.json$/.test(entry.name)) continue;
|
|
try {
|
|
const state = JSON.parse(await readFile(join(stateDir, entry.name), "utf8"));
|
|
if (state?.schemaVersion !== STATE_SCHEMA_VERSION || !state.target) continue;
|
|
const target = await resolveTarget(state.target.applicationId, state.target.pageId, state.target.bindingId);
|
|
const record = await recordFor(target);
|
|
record.target = target;
|
|
if (record.state?.enabled) {
|
|
record.state.runtimeState = "idle";
|
|
record.state.timestamps.updatedAt = new Date(now()).toISOString();
|
|
await writeState(record);
|
|
}
|
|
} catch {
|
|
// One damaged or obsolete target cannot stop Foundry from serving the
|
|
// remaining applications. Its file remains available for diagnostics.
|
|
}
|
|
}
|
|
}
|
|
|
|
const staleTimer = setInterval(() => {
|
|
for (const record of records.values()) void refreshPresentation(record).catch(() => undefined);
|
|
}, staleSweepMs);
|
|
staleTimer.unref?.();
|
|
|
|
async function shutdown() {
|
|
if (closed) return;
|
|
closed = true;
|
|
clearInterval(staleTimer);
|
|
const streams = [];
|
|
for (const record of records.values()) {
|
|
stopStream(record);
|
|
record.listeners.clear();
|
|
if (record.stream?.promise) streams.push(record.stream.promise);
|
|
}
|
|
await Promise.race([
|
|
Promise.allSettled(streams),
|
|
new Promise((resolve) => setTimeout(resolve, 750)),
|
|
]);
|
|
}
|
|
|
|
return {
|
|
plan,
|
|
apply,
|
|
snapshot,
|
|
subscribe,
|
|
status,
|
|
accept,
|
|
rollback,
|
|
resumePersisted,
|
|
shutdown,
|
|
};
|
|
}
|
|
|
|
export const foundryDataProductConsumerSchemas = Object.freeze({
|
|
state: STATE_SCHEMA_VERSION,
|
|
plan: PLAN_SCHEMA_VERSION,
|
|
acceptance: ACCEPTANCE_SCHEMA_VERSION,
|
|
presentationPatch: PRESENTATION_PATCH_SCHEMA_VERSION,
|
|
});
|