@lssm/lib.contracts-runtime-core 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -0
- package/dist/approval.d.ts +13 -0
- package/dist/approval.js +2 -0
- package/dist/approval.test.d.ts +1 -0
- package/dist/browser/approval.js +1 -0
- package/dist/browser/collaboration.js +0 -0
- package/dist/browser/contracts-runtime-core.feature.js +1 -0
- package/dist/browser/engine/backoff.js +1 -0
- package/dist/browser/engine/cache-store.js +1 -0
- package/dist/browser/engine/conflict-registry.js +1 -0
- package/dist/browser/engine/conflict-resolution.js +1 -0
- package/dist/browser/engine/data-engine-host.js +1 -0
- package/dist/browser/engine/data-engine-test-helpers.js +1 -0
- package/dist/browser/engine/data-engine.js +1 -0
- package/dist/browser/engine/dedup.js +1 -0
- package/dist/browser/engine/events.js +1 -0
- package/dist/browser/engine/index.js +1 -0
- package/dist/browser/engine/maintenance.js +1 -0
- package/dist/browser/engine/mutation-engine.js +1 -0
- package/dist/browser/engine/mutation-queue.js +1 -0
- package/dist/browser/engine/mutation-types.js +0 -0
- package/dist/browser/engine/no-cache-store.js +1 -0
- package/dist/browser/engine/online-only-data-engine.js +1 -0
- package/dist/browser/engine/online-only-mutation-engine.js +1 -0
- package/dist/browser/engine/query-engine.js +1 -0
- package/dist/browser/engine/state.js +1 -0
- package/dist/browser/engine/types.js +0 -0
- package/dist/browser/forms-platform-csv-columns.js +1 -0
- package/dist/browser/forms-platform-csv.js +2 -0
- package/dist/browser/forms-platform-management.js +1 -0
- package/dist/browser/forms-platform-navigation.js +1 -0
- package/dist/browser/forms-platform-submission.js +1 -0
- package/dist/browser/forms-platform-types.js +1 -0
- package/dist/browser/forms-platform-utils.js +1 -0
- package/dist/browser/forms-platform.js +2 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/local-offline.js +1 -0
- package/dist/browser/observability.js +1 -0
- package/dist/browser/reachability.js +1 -0
- package/dist/browser/redaction.js +1 -0
- package/dist/browser/storage/indexeddb.web.js +1 -0
- package/dist/browser/storage/keyed-store.js +1 -0
- package/dist/browser/storage/memory.js +1 -0
- package/dist/browser/storage/port.js +1 -0
- package/dist/browser/transport.js +2 -0
- package/dist/collaboration.d.ts +57 -0
- package/dist/collaboration.js +1 -0
- package/dist/contracts-runtime-core.feature.d.ts +1 -0
- package/dist/contracts-runtime-core.feature.js +2 -0
- package/dist/engine/backoff.d.ts +16 -0
- package/dist/engine/backoff.js +2 -0
- package/dist/engine/backoff.test.d.ts +1 -0
- package/dist/engine/cache-store.d.ts +19 -0
- package/dist/engine/cache-store.js +2 -0
- package/dist/engine/conflict-registry.d.ts +10 -0
- package/dist/engine/conflict-registry.js +2 -0
- package/dist/engine/conflict-registry.test.d.ts +1 -0
- package/dist/engine/conflict-resolution.d.ts +24 -0
- package/dist/engine/conflict-resolution.js +2 -0
- package/dist/engine/conflict-resolution.test.d.ts +1 -0
- package/dist/engine/data-engine-host.d.ts +30 -0
- package/dist/engine/data-engine-host.js +2 -0
- package/dist/engine/data-engine-local-offline.test.d.ts +1 -0
- package/dist/engine/data-engine-test-helpers.d.ts +30 -0
- package/dist/engine/data-engine-test-helpers.js +2 -0
- package/dist/engine/data-engine.d.ts +46 -0
- package/dist/engine/data-engine.js +2 -0
- package/dist/engine/data-engine.test.d.ts +1 -0
- package/dist/engine/dedup.d.ts +9 -0
- package/dist/engine/dedup.js +2 -0
- package/dist/engine/events.d.ts +76 -0
- package/dist/engine/events.economic-evidence.test.d.ts +1 -0
- package/dist/engine/events.js +2 -0
- package/dist/engine/index.d.ts +17 -0
- package/dist/engine/index.js +2 -0
- package/dist/engine/maintenance.d.ts +18 -0
- package/dist/engine/maintenance.js +2 -0
- package/dist/engine/maintenance.test.d.ts +1 -0
- package/dist/engine/mutation-engine.d.ts +2 -0
- package/dist/engine/mutation-engine.js +2 -0
- package/dist/engine/mutation-queue.d.ts +16 -0
- package/dist/engine/mutation-queue.js +2 -0
- package/dist/engine/mutation-queue.test.d.ts +1 -0
- package/dist/engine/mutation-types.d.ts +118 -0
- package/dist/engine/mutation-types.js +1 -0
- package/dist/engine/no-cache-store.d.ts +3 -0
- package/dist/engine/no-cache-store.js +2 -0
- package/dist/engine/online-only-data-engine.d.ts +9 -0
- package/dist/engine/online-only-data-engine.js +2 -0
- package/dist/engine/online-only-mutation-engine.d.ts +3 -0
- package/dist/engine/online-only-mutation-engine.js +2 -0
- package/dist/engine/query-engine.d.ts +7 -0
- package/dist/engine/query-engine.js +2 -0
- package/dist/engine/query-engine.test.d.ts +1 -0
- package/dist/engine/state.d.ts +37 -0
- package/dist/engine/state.js +2 -0
- package/dist/engine/types.d.ts +70 -0
- package/dist/engine/types.js +1 -0
- package/dist/forms-platform-choice.test.d.ts +1 -0
- package/dist/forms-platform-csv-columns.d.ts +7 -0
- package/dist/forms-platform-csv-columns.js +2 -0
- package/dist/forms-platform-csv.d.ts +12 -0
- package/dist/forms-platform-csv.js +3 -0
- package/dist/forms-platform-management.d.ts +12 -0
- package/dist/forms-platform-management.js +2 -0
- package/dist/forms-platform-navigation.d.ts +2 -0
- package/dist/forms-platform-navigation.js +2 -0
- package/dist/forms-platform-submission.d.ts +7 -0
- package/dist/forms-platform-submission.js +2 -0
- package/dist/forms-platform-types.d.ts +51 -0
- package/dist/forms-platform-types.js +2 -0
- package/dist/forms-platform-utils.d.ts +6 -0
- package/dist/forms-platform-utils.js +2 -0
- package/dist/forms-platform-utils.test.d.ts +1 -0
- package/dist/forms-platform.d.ts +19 -0
- package/dist/forms-platform.js +3 -0
- package/dist/forms-platform.test.d.ts +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +4 -0
- package/dist/local-offline.d.ts +17 -0
- package/dist/local-offline.js +2 -0
- package/dist/local-offline.test.d.ts +1 -0
- package/dist/native/approval.js +130 -0
- package/dist/native/collaboration.js +0 -0
- package/dist/native/contracts-runtime-core.feature.js +17 -0
- package/dist/native/engine/backoff.js +16 -0
- package/dist/native/engine/cache-store.js +49 -0
- package/dist/native/engine/conflict-registry.js +51 -0
- package/dist/native/engine/conflict-resolution.js +81 -0
- package/dist/native/engine/data-engine-host.js +1119 -0
- package/dist/native/engine/data-engine-test-helpers.js +117 -0
- package/dist/native/engine/data-engine.js +1106 -0
- package/dist/native/engine/dedup.js +22 -0
- package/dist/native/engine/events.js +19 -0
- package/dist/native/engine/index.js +1189 -0
- package/dist/native/engine/maintenance.js +36 -0
- package/dist/native/engine/mutation-engine.js +360 -0
- package/dist/native/engine/mutation-queue.js +26 -0
- package/dist/native/engine/mutation-types.js +0 -0
- package/dist/native/engine/no-cache-store.js +15 -0
- package/dist/native/engine/online-only-data-engine.js +422 -0
- package/dist/native/engine/online-only-mutation-engine.js +70 -0
- package/dist/native/engine/query-engine.js +360 -0
- package/dist/native/engine/state.js +116 -0
- package/dist/native/engine/types.js +0 -0
- package/dist/native/forms-platform-csv-columns.js +31 -0
- package/dist/native/forms-platform-csv.js +150 -0
- package/dist/native/forms-platform-management.js +179 -0
- package/dist/native/forms-platform-navigation.js +27 -0
- package/dist/native/forms-platform-submission.js +207 -0
- package/dist/native/forms-platform-types.js +12 -0
- package/dist/native/forms-platform-utils.js +69 -0
- package/dist/native/forms-platform.js +429 -0
- package/dist/native/index.js +1879 -0
- package/dist/native/local-offline.js +81 -0
- package/dist/native/observability.js +182 -0
- package/dist/native/reachability.js +28 -0
- package/dist/native/redaction.js +44 -0
- package/dist/native/storage/async-storage.native.js +91 -0
- package/dist/native/storage/keyed-store.js +74 -0
- package/dist/native/storage/memory.js +26 -0
- package/dist/native/storage/port.js +16 -0
- package/dist/native/transport.js +27 -0
- package/dist/node/approval.js +1 -0
- package/dist/node/collaboration.js +0 -0
- package/dist/node/contracts-runtime-core.feature.js +1 -0
- package/dist/node/engine/backoff.js +1 -0
- package/dist/node/engine/cache-store.js +1 -0
- package/dist/node/engine/conflict-registry.js +1 -0
- package/dist/node/engine/conflict-resolution.js +1 -0
- package/dist/node/engine/data-engine-host.js +1 -0
- package/dist/node/engine/data-engine-test-helpers.js +1 -0
- package/dist/node/engine/data-engine.js +1 -0
- package/dist/node/engine/dedup.js +1 -0
- package/dist/node/engine/events.js +1 -0
- package/dist/node/engine/index.js +1 -0
- package/dist/node/engine/maintenance.js +1 -0
- package/dist/node/engine/mutation-engine.js +1 -0
- package/dist/node/engine/mutation-queue.js +1 -0
- package/dist/node/engine/mutation-types.js +0 -0
- package/dist/node/engine/no-cache-store.js +1 -0
- package/dist/node/engine/online-only-data-engine.js +1 -0
- package/dist/node/engine/online-only-mutation-engine.js +1 -0
- package/dist/node/engine/query-engine.js +1 -0
- package/dist/node/engine/state.js +1 -0
- package/dist/node/engine/types.js +0 -0
- package/dist/node/forms-platform-csv-columns.js +1 -0
- package/dist/node/forms-platform-csv.js +2 -0
- package/dist/node/forms-platform-management.js +1 -0
- package/dist/node/forms-platform-navigation.js +1 -0
- package/dist/node/forms-platform-submission.js +1 -0
- package/dist/node/forms-platform-types.js +1 -0
- package/dist/node/forms-platform-utils.js +1 -0
- package/dist/node/forms-platform.js +2 -0
- package/dist/node/index.js +3 -0
- package/dist/node/local-offline.js +1 -0
- package/dist/node/observability.js +1 -0
- package/dist/node/reachability.js +1 -0
- package/dist/node/redaction.js +1 -0
- package/dist/node/storage/keyed-store.js +1 -0
- package/dist/node/storage/memory.js +1 -0
- package/dist/node/storage/port.js +1 -0
- package/dist/node/transport.js +2 -0
- package/dist/observability.d.ts +34 -0
- package/dist/observability.js +2 -0
- package/dist/observability.test.d.ts +1 -0
- package/dist/reachability.d.ts +63 -0
- package/dist/reachability.js +2 -0
- package/dist/reachability.test.d.ts +1 -0
- package/dist/redaction.d.ts +34 -0
- package/dist/redaction.js +2 -0
- package/dist/redaction.test.d.ts +1 -0
- package/dist/storage/async-storage.native.d.ts +23 -0
- package/dist/storage/async-storage.native.test.d.ts +1 -0
- package/dist/storage/indexeddb.web.d.ts +13 -0
- package/dist/storage/indexeddb.web.js +2 -0
- package/dist/storage/keyed-store.d.ts +25 -0
- package/dist/storage/keyed-store.js +2 -0
- package/dist/storage/keyed-store.test.d.ts +1 -0
- package/dist/storage/memory.d.ts +11 -0
- package/dist/storage/memory.js +2 -0
- package/dist/storage/memory.test.d.ts +1 -0
- package/dist/storage/port.d.ts +34 -0
- package/dist/storage/port.js +2 -0
- package/dist/transport.d.ts +53 -0
- package/dist/transport.js +3 -0
- package/package.json +219 -0
|
@@ -0,0 +1,1879 @@
|
|
|
1
|
+
// src/approval.ts
|
|
2
|
+
import { OPERATION_EXECUTION_EFFECTS } from "@lssm/lib.contracts-spec/operations";
|
|
3
|
+
function createMemoryApprovalNonceStore(now = () => new Date) {
|
|
4
|
+
const consumed = new Map;
|
|
5
|
+
return {
|
|
6
|
+
async consume(nonce, expiresAt) {
|
|
7
|
+
const currentTime = now().getTime();
|
|
8
|
+
for (const [key, expiry] of consumed) {
|
|
9
|
+
if (expiry <= currentTime)
|
|
10
|
+
consumed.delete(key);
|
|
11
|
+
}
|
|
12
|
+
if (consumed.has(nonce))
|
|
13
|
+
return false;
|
|
14
|
+
consumed.set(nonce, Date.parse(expiresAt));
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function createOperationApprovalPort(options = {}) {
|
|
20
|
+
const now = options.now ?? (() => new Date);
|
|
21
|
+
const nonceStore = options.nonceStore ?? createMemoryApprovalNonceStore(now);
|
|
22
|
+
return {
|
|
23
|
+
async authorize(request) {
|
|
24
|
+
const denial = await validateApprovalRequest(request, now(), options.verifyEvidence);
|
|
25
|
+
if (denial)
|
|
26
|
+
return denial;
|
|
27
|
+
if (!await nonceStore.consume(request.receipt.nonce, request.receipt.expiresAt)) {
|
|
28
|
+
return { effect: "deny", reason: "replayed_receipt" };
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
effect: "allow",
|
|
32
|
+
receiptId: request.receipt.id,
|
|
33
|
+
nonce: request.receipt.nonce
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function operationApprovalInputDigest(input) {
|
|
39
|
+
const bytes = new TextEncoder().encode(canonicalJson(input));
|
|
40
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
41
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
42
|
+
}
|
|
43
|
+
async function validateApprovalRequest(request, now, verifyEvidence) {
|
|
44
|
+
const { receipt } = request;
|
|
45
|
+
const issuedAt = Date.parse(receipt.issuedAt);
|
|
46
|
+
const expiresAt = Date.parse(receipt.expiresAt);
|
|
47
|
+
if (!receipt.id || !receipt.nonce || !receipt.issuer || !receipt.evidenceRef || !hasSubjectScope(receipt.subject) || receipt.effects.some((effect) => !OPERATION_EXECUTION_EFFECTS.includes(effect)) || !Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) {
|
|
48
|
+
return deny("malformed_receipt");
|
|
49
|
+
}
|
|
50
|
+
if (expiresAt <= now.getTime())
|
|
51
|
+
return deny("expired_receipt");
|
|
52
|
+
if (issuedAt > now.getTime())
|
|
53
|
+
return deny("not_yet_valid");
|
|
54
|
+
if (!matchesSubject(receipt.subject, request.subject, ["actor", "userId"])) {
|
|
55
|
+
return deny("wrong_subject");
|
|
56
|
+
}
|
|
57
|
+
if (!matchesSubject(receipt.subject, request.subject, [
|
|
58
|
+
"tenantId",
|
|
59
|
+
"workspaceId",
|
|
60
|
+
"organizationId"
|
|
61
|
+
])) {
|
|
62
|
+
return deny("wrong_tenant");
|
|
63
|
+
}
|
|
64
|
+
if (receipt.operation.key !== request.operation.key || receipt.operation.version !== request.operation.version) {
|
|
65
|
+
return deny("wrong_operation");
|
|
66
|
+
}
|
|
67
|
+
if (receipt.inputDigest !== await operationApprovalInputDigest(request.input)) {
|
|
68
|
+
return deny("wrong_input");
|
|
69
|
+
}
|
|
70
|
+
const approvedEffects = new Set(receipt.effects);
|
|
71
|
+
if (request.requiredEffects.some((effect) => !approvedEffects.has(effect))) {
|
|
72
|
+
return deny("insufficient_effects");
|
|
73
|
+
}
|
|
74
|
+
if (!coversCost(receipt, request)) {
|
|
75
|
+
return deny("insufficient_cost_ceiling");
|
|
76
|
+
}
|
|
77
|
+
if (verifyEvidence && !await verifyEvidence(receipt)) {
|
|
78
|
+
return deny("invalid_evidence");
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
function matchesSubject(receipt, expected, keys) {
|
|
83
|
+
return keys.every((key) => {
|
|
84
|
+
const expectedValue = expected[key];
|
|
85
|
+
return expectedValue == null || receipt[key] === expectedValue;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function coversCost(receipt, request) {
|
|
89
|
+
if (!request.requestedCost)
|
|
90
|
+
return true;
|
|
91
|
+
if (!Number.isFinite(request.requestedCost.amount) || request.requestedCost.amount < 0) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const ceiling = receipt.costCeiling;
|
|
95
|
+
if (!ceiling || !Number.isFinite(ceiling.amount) || ceiling.amount < 0) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return ceiling.amount >= request.requestedCost.amount && (!request.requestedCost.currency || ceiling.currency === request.requestedCost.currency) && (!request.requestedCost.unit || ceiling.unit === request.requestedCost.unit);
|
|
99
|
+
}
|
|
100
|
+
function hasSubjectScope(subject) {
|
|
101
|
+
return Object.values(subject).some((value) => typeof value === "string" && value.length > 0);
|
|
102
|
+
}
|
|
103
|
+
function deny(reason) {
|
|
104
|
+
return { effect: "deny", reason };
|
|
105
|
+
}
|
|
106
|
+
function canonicalJson(value) {
|
|
107
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
108
|
+
return JSON.stringify(value);
|
|
109
|
+
}
|
|
110
|
+
if (typeof value === "number") {
|
|
111
|
+
if (!Number.isFinite(value))
|
|
112
|
+
throw new TypeError("Approval input is not JSON.");
|
|
113
|
+
return JSON.stringify(value);
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
117
|
+
}
|
|
118
|
+
if (value instanceof Date)
|
|
119
|
+
return JSON.stringify(value.toISOString());
|
|
120
|
+
if (typeof value === "object") {
|
|
121
|
+
const entries = Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right));
|
|
122
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
123
|
+
}
|
|
124
|
+
throw new TypeError("Approval input is not JSON.");
|
|
125
|
+
}
|
|
126
|
+
// src/contracts-runtime-core.feature.ts
|
|
127
|
+
import { defineFeature } from "@lssm/lib.contracts-spec/features";
|
|
128
|
+
var ContractsRuntimeCoreFeature = defineFeature({
|
|
129
|
+
meta: {
|
|
130
|
+
key: "libs.contracts-runtime-core",
|
|
131
|
+
version: "1.0.0",
|
|
132
|
+
title: "Contracts Runtime Core",
|
|
133
|
+
description: "Framework-neutral in-house query/cache/offline engine and Transport port for ContractSpec data fetching.",
|
|
134
|
+
domain: "contracts-runtime-core",
|
|
135
|
+
owners: ["@contractspec-core"],
|
|
136
|
+
tags: ["package", "libs", "contracts-runtime-core"],
|
|
137
|
+
stability: "experimental"
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
// src/engine/backoff.ts
|
|
141
|
+
var DEFAULT_BASE_MS = 200;
|
|
142
|
+
var DEFAULT_FACTOR = 2;
|
|
143
|
+
var DEFAULT_MAX_MS = 30000;
|
|
144
|
+
function backoffDelayMs(attempt, options = {}) {
|
|
145
|
+
const base = options.baseMs ?? DEFAULT_BASE_MS;
|
|
146
|
+
const factor = options.factor ?? DEFAULT_FACTOR;
|
|
147
|
+
const max = options.maxMs ?? DEFAULT_MAX_MS;
|
|
148
|
+
const jitter = options.jitter ?? Math.random;
|
|
149
|
+
const exponent = Math.max(0, attempt - 1);
|
|
150
|
+
const raw = Math.min(max, base * factor ** exponent);
|
|
151
|
+
return Math.round(raw * (0.5 + jitter() * 0.5));
|
|
152
|
+
}
|
|
153
|
+
// src/engine/cache-store.ts
|
|
154
|
+
import { invalidationTagMatches } from "@lssm/lib.contracts-spec/query/invalidation";
|
|
155
|
+
var CACHE_PREFIX = "cache:";
|
|
156
|
+
function createCacheStore(storage) {
|
|
157
|
+
const tagIndex = new Map;
|
|
158
|
+
const storageKey = (key) => `${CACHE_PREFIX}${key}`;
|
|
159
|
+
return {
|
|
160
|
+
read: (key) => storage.get(storageKey(key)),
|
|
161
|
+
write: async (entry) => {
|
|
162
|
+
tagIndex.set(entry.key, entry.tags);
|
|
163
|
+
await storage.set(storageKey(entry.key), entry);
|
|
164
|
+
},
|
|
165
|
+
remove: async (key) => {
|
|
166
|
+
tagIndex.delete(key);
|
|
167
|
+
await storage.remove(storageKey(key));
|
|
168
|
+
},
|
|
169
|
+
entries: async () => {
|
|
170
|
+
const keys = await storage.keys(CACHE_PREFIX);
|
|
171
|
+
const all = [];
|
|
172
|
+
for (const key of keys) {
|
|
173
|
+
const entry = await storage.get(key);
|
|
174
|
+
if (entry !== undefined)
|
|
175
|
+
all.push(entry);
|
|
176
|
+
}
|
|
177
|
+
return all;
|
|
178
|
+
},
|
|
179
|
+
invalidate: async (tags, now) => {
|
|
180
|
+
const affected = [];
|
|
181
|
+
for (const [cacheKey, entryTags] of tagIndex.entries()) {
|
|
182
|
+
const matched = entryTags.some((subject) => tags.some((pattern) => invalidationTagMatches(subject, pattern)));
|
|
183
|
+
if (!matched)
|
|
184
|
+
continue;
|
|
185
|
+
const stored = await storage.get(storageKey(cacheKey));
|
|
186
|
+
if (stored === undefined)
|
|
187
|
+
continue;
|
|
188
|
+
await storage.set(storageKey(cacheKey), {
|
|
189
|
+
...stored,
|
|
190
|
+
status: "stale",
|
|
191
|
+
staleAt: now
|
|
192
|
+
});
|
|
193
|
+
affected.push(cacheKey);
|
|
194
|
+
}
|
|
195
|
+
return affected;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// src/engine/conflict-registry.ts
|
|
200
|
+
function fieldMerge(ctx) {
|
|
201
|
+
const server = ctx.serverData;
|
|
202
|
+
const local = ctx.localData;
|
|
203
|
+
const fields = ctx.mergeFields ?? [];
|
|
204
|
+
if (typeof server !== "object" || server === null || typeof local !== "object" || local === null) {
|
|
205
|
+
return { outcome: "surface" };
|
|
206
|
+
}
|
|
207
|
+
const merged = {
|
|
208
|
+
...server
|
|
209
|
+
};
|
|
210
|
+
const localRecord = local;
|
|
211
|
+
for (const field of fields) {
|
|
212
|
+
if (field in localRecord)
|
|
213
|
+
merged[field] = localRecord[field];
|
|
214
|
+
}
|
|
215
|
+
return { outcome: "merged", data: merged };
|
|
216
|
+
}
|
|
217
|
+
function createConflictResolverRegistry() {
|
|
218
|
+
const resolvers = new Map;
|
|
219
|
+
return {
|
|
220
|
+
register(key, resolver) {
|
|
221
|
+
resolvers.set(key, resolver);
|
|
222
|
+
},
|
|
223
|
+
has(key) {
|
|
224
|
+
return resolvers.has(key);
|
|
225
|
+
},
|
|
226
|
+
async resolve(ctx) {
|
|
227
|
+
switch (ctx.strategy) {
|
|
228
|
+
case "server-wins":
|
|
229
|
+
return { outcome: "use-server", data: ctx.serverData };
|
|
230
|
+
case "client-wins":
|
|
231
|
+
return { outcome: "use-client", data: ctx.localData };
|
|
232
|
+
case "field-merge":
|
|
233
|
+
return fieldMerge(ctx);
|
|
234
|
+
case "surface":
|
|
235
|
+
return { outcome: "surface" };
|
|
236
|
+
default: {
|
|
237
|
+
const key = ctx.policy.resolver;
|
|
238
|
+
const resolver = key === undefined ? undefined : resolvers.get(key);
|
|
239
|
+
if (resolver === undefined)
|
|
240
|
+
return { outcome: "surface" };
|
|
241
|
+
return resolver(ctx);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
// src/engine/conflict-resolution.ts
|
|
248
|
+
import { defaultConflictPolicy } from "@lssm/lib.contracts-spec/query/conflict-policy";
|
|
249
|
+
async function restoreSnapshots(snapshots, cache) {
|
|
250
|
+
for (const snapshot of snapshots) {
|
|
251
|
+
if (snapshot.previous === undefined)
|
|
252
|
+
await cache.remove(snapshot.key);
|
|
253
|
+
else
|
|
254
|
+
await cache.write(snapshot.previous);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function resolveQueuedConflict(entry, problem, deps) {
|
|
258
|
+
const policy = entry.conflictPolicy ?? defaultConflictPolicy();
|
|
259
|
+
const primaryKey = entry.rollback[0]?.key;
|
|
260
|
+
const localEntry = primaryKey === undefined ? undefined : await deps.cache.read(primaryKey);
|
|
261
|
+
const serverData = problem.metadata?.serverData;
|
|
262
|
+
const resolution = await deps.resolvers.resolve({
|
|
263
|
+
strategy: policy.strategy,
|
|
264
|
+
policy,
|
|
265
|
+
localData: localEntry?.data,
|
|
266
|
+
serverData,
|
|
267
|
+
localVersion: entry.versionToken,
|
|
268
|
+
serverVersion: problem.versionToken,
|
|
269
|
+
mergeFields: policy.mergeFields
|
|
270
|
+
});
|
|
271
|
+
deps.events.emit({
|
|
272
|
+
type: "mutation-conflict",
|
|
273
|
+
id: entry.id,
|
|
274
|
+
strategy: policy.strategy,
|
|
275
|
+
outcome: resolution.outcome
|
|
276
|
+
});
|
|
277
|
+
switch (resolution.outcome) {
|
|
278
|
+
case "use-server": {
|
|
279
|
+
await restoreSnapshots(entry.rollback, deps.cache);
|
|
280
|
+
await deps.cache.invalidate(entry.tags, deps.now());
|
|
281
|
+
for (const snapshot of entry.rollback) {
|
|
282
|
+
await deps.queryEngine?.refresh(snapshot.key);
|
|
283
|
+
}
|
|
284
|
+
return "resolved";
|
|
285
|
+
}
|
|
286
|
+
case "use-client":
|
|
287
|
+
await deps.queue.update({
|
|
288
|
+
...entry,
|
|
289
|
+
versionToken: problem.versionToken,
|
|
290
|
+
status: "pending"
|
|
291
|
+
});
|
|
292
|
+
return "re-enqueued";
|
|
293
|
+
case "merged": {
|
|
294
|
+
if (primaryKey !== undefined && localEntry !== undefined) {
|
|
295
|
+
await deps.cache.write({
|
|
296
|
+
...localEntry,
|
|
297
|
+
data: resolution.data,
|
|
298
|
+
versionToken: problem.versionToken,
|
|
299
|
+
status: "warm",
|
|
300
|
+
storedAt: deps.now()
|
|
301
|
+
});
|
|
302
|
+
await deps.queryEngine?.refresh(primaryKey);
|
|
303
|
+
}
|
|
304
|
+
await deps.queue.update({
|
|
305
|
+
...entry,
|
|
306
|
+
versionToken: problem.versionToken,
|
|
307
|
+
status: "pending"
|
|
308
|
+
});
|
|
309
|
+
return "re-enqueued";
|
|
310
|
+
}
|
|
311
|
+
default: {
|
|
312
|
+
if (primaryKey !== undefined) {
|
|
313
|
+
await deps.queryEngine?.emitConflict(primaryKey, {
|
|
314
|
+
versionToken: problem.versionToken,
|
|
315
|
+
serverData,
|
|
316
|
+
clientData: localEntry?.data,
|
|
317
|
+
strategy: policy.strategy
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return "resolved";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// src/local-offline.ts
|
|
325
|
+
var MODES = new Set([
|
|
326
|
+
"online-only",
|
|
327
|
+
"memory-cache",
|
|
328
|
+
"offline-capable"
|
|
329
|
+
]);
|
|
330
|
+
var MODE_ALIASES = {
|
|
331
|
+
"online-only": "online-only",
|
|
332
|
+
online: "online-only",
|
|
333
|
+
disabled: "online-only",
|
|
334
|
+
false: "online-only",
|
|
335
|
+
off: "online-only",
|
|
336
|
+
"memory-cache": "memory-cache",
|
|
337
|
+
memory: "memory-cache",
|
|
338
|
+
cache: "memory-cache",
|
|
339
|
+
"offline-capable": "offline-capable",
|
|
340
|
+
offline: "offline-capable",
|
|
341
|
+
enabled: "offline-capable",
|
|
342
|
+
true: "offline-capable",
|
|
343
|
+
on: "offline-capable"
|
|
344
|
+
};
|
|
345
|
+
function isLocalOfflineMode(value) {
|
|
346
|
+
return typeof value === "string" && MODES.has(value);
|
|
347
|
+
}
|
|
348
|
+
function resolveLocalOfflinePolicy(options = {}) {
|
|
349
|
+
const fallback = options.defaultMode === undefined ? {
|
|
350
|
+
mode: "online-only",
|
|
351
|
+
source: "default",
|
|
352
|
+
reason: "No local/offline policy input was supplied."
|
|
353
|
+
} : normalizePolicy(options.defaultMode, "explicit") ?? {
|
|
354
|
+
mode: "online-only",
|
|
355
|
+
source: "explicit"
|
|
356
|
+
};
|
|
357
|
+
const env = normalizePolicy(readEnvValue(options.env), "env", {
|
|
358
|
+
allowUnknown: true
|
|
359
|
+
});
|
|
360
|
+
const flag = normalizePolicy(options.flag, "feature-flag");
|
|
361
|
+
return flag ?? env ?? fallback;
|
|
362
|
+
}
|
|
363
|
+
function readEnvValue(input) {
|
|
364
|
+
if (typeof input !== "object" || input === null)
|
|
365
|
+
return input;
|
|
366
|
+
if ("CONTRACTSPEC_LOCAL_OFFLINE" in input) {
|
|
367
|
+
return input.CONTRACTSPEC_LOCAL_OFFLINE;
|
|
368
|
+
}
|
|
369
|
+
if ("mode" in input)
|
|
370
|
+
return input;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
function normalizePolicy(input, source, options = {}) {
|
|
374
|
+
if (input === undefined || input === null || input === "")
|
|
375
|
+
return;
|
|
376
|
+
if (typeof input === "boolean") {
|
|
377
|
+
return { mode: input ? "offline-capable" : "online-only", source };
|
|
378
|
+
}
|
|
379
|
+
if (typeof input === "string") {
|
|
380
|
+
const mode = MODE_ALIASES[input.trim().toLowerCase()];
|
|
381
|
+
if (mode !== undefined)
|
|
382
|
+
return { mode, source };
|
|
383
|
+
if (options.allowUnknown)
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (typeof input === "object" && input !== null && "mode" in input) {
|
|
387
|
+
const policy = input;
|
|
388
|
+
const mode = typeof policy.mode === "string" ? MODE_ALIASES[policy.mode.trim().toLowerCase()] : undefined;
|
|
389
|
+
if (mode !== undefined) {
|
|
390
|
+
return {
|
|
391
|
+
mode,
|
|
392
|
+
source,
|
|
393
|
+
...typeof policy.reason === "string" ? { reason: policy.reason } : {}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (options.allowUnknown)
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
throw new Error(`Unsupported localOffline policy value: ${String(input)}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/reachability.ts
|
|
403
|
+
function isNetworkAllowed(signal) {
|
|
404
|
+
return signal !== "offline";
|
|
405
|
+
}
|
|
406
|
+
function createStaticReachabilitySource(signal = "online") {
|
|
407
|
+
const snapshot = { signal, since: Date.now() };
|
|
408
|
+
return {
|
|
409
|
+
getSnapshot: () => snapshot,
|
|
410
|
+
subscribe: () => () => {}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
var reachabilityPortDoc = {
|
|
414
|
+
id: "docs.contracts-runtime-core.reachability",
|
|
415
|
+
title: "Reachability input port (tri-state)",
|
|
416
|
+
summary: "Engine-owned reachability input port; integrations feed it, the engine never imports them.",
|
|
417
|
+
kind: "reference",
|
|
418
|
+
visibility: "internal",
|
|
419
|
+
route: "/docs/contracts-runtime-core/reachability",
|
|
420
|
+
domain: "contracts-runtime-core",
|
|
421
|
+
owners: ["@contractspec-core"],
|
|
422
|
+
tags: ["contracts-runtime-core", "reachability", "offline", "port"],
|
|
423
|
+
body: "Tri-state OnlineSignal ('online' | 'restricted' | 'offline'). 'online' allows normal fetch + queue drain. 'restricted' is online-degraded: the engine fetches and drains the queue as if online but may mark results stale-eligible; it never suspends network nor activates the offline queue. Only 'offline' suspends outbound network and activates the durable offline mutation queue. The engine reads reachability solely through ReachabilitySource and MUST NOT import integrations/* — concrete sources (runtime-local networkReachability) are wired via an integration→port adapter (plan §2.3/§4.2, Architect finding 1). isNetworkAllowed mirrors runtime-local's outboundNetworkAllowed = reachability !== 'offline'. createStaticReachabilitySource('online') is the B1a default and online test fixture."
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// src/storage/memory.ts
|
|
427
|
+
function createMemoryStorage() {
|
|
428
|
+
const store = new Map;
|
|
429
|
+
return {
|
|
430
|
+
get: async (key) => {
|
|
431
|
+
const value = store.get(key);
|
|
432
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
433
|
+
},
|
|
434
|
+
set: async (key, value) => {
|
|
435
|
+
store.set(key, structuredClone(value));
|
|
436
|
+
},
|
|
437
|
+
remove: async (key) => {
|
|
438
|
+
store.delete(key);
|
|
439
|
+
},
|
|
440
|
+
keys: async (prefix) => {
|
|
441
|
+
const all = [...store.keys()];
|
|
442
|
+
return prefix === undefined ? all : all.filter((key) => key.startsWith(prefix));
|
|
443
|
+
},
|
|
444
|
+
clear: async () => {
|
|
445
|
+
store.clear();
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/engine/events.ts
|
|
451
|
+
function createEngineEvents() {
|
|
452
|
+
const listeners = new Set;
|
|
453
|
+
return {
|
|
454
|
+
emit(event) {
|
|
455
|
+
for (const listener of listeners)
|
|
456
|
+
listener(event);
|
|
457
|
+
},
|
|
458
|
+
subscribe(listener) {
|
|
459
|
+
listeners.add(listener);
|
|
460
|
+
return () => {
|
|
461
|
+
listeners.delete(listener);
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/engine/maintenance.ts
|
|
468
|
+
function createMaintenance(deps) {
|
|
469
|
+
return {
|
|
470
|
+
async collectGarbage() {
|
|
471
|
+
const ts = deps.now();
|
|
472
|
+
let evicted = 0;
|
|
473
|
+
for (const entry of await deps.cache.entries()) {
|
|
474
|
+
if (entry.expiresAt !== undefined && ts >= entry.expiresAt) {
|
|
475
|
+
await deps.cache.remove(entry.key);
|
|
476
|
+
evicted += 1;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
deps.events.emit({ type: "gc-collected", evicted });
|
|
480
|
+
return evicted;
|
|
481
|
+
},
|
|
482
|
+
async revalidateStale() {
|
|
483
|
+
const revalidate = deps.revalidate;
|
|
484
|
+
if (revalidate === undefined)
|
|
485
|
+
return 0;
|
|
486
|
+
const ts = deps.now();
|
|
487
|
+
let count = 0;
|
|
488
|
+
for (const entry of await deps.cache.entries()) {
|
|
489
|
+
const stale = entry.staleAt !== undefined && ts >= entry.staleAt;
|
|
490
|
+
if (!stale || entry.envelope === undefined)
|
|
491
|
+
continue;
|
|
492
|
+
await revalidate(entry.envelope);
|
|
493
|
+
deps.events.emit({ type: "revalidated", key: entry.key });
|
|
494
|
+
count += 1;
|
|
495
|
+
}
|
|
496
|
+
return count;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/engine/mutation-engine.ts
|
|
502
|
+
import {
|
|
503
|
+
mergeSafeEconomicEvidenceRefIds,
|
|
504
|
+
parseSafeEconomicEvidenceRefId
|
|
505
|
+
} from "@lssm/lib.contracts-spec/operations";
|
|
506
|
+
import { defaultConflictPolicy as defaultConflictPolicy2 } from "@lssm/lib.contracts-spec/query/conflict-policy";
|
|
507
|
+
var DEFAULT_MAX_ATTEMPTS = 5;
|
|
508
|
+
function mergeEconomicEvidenceRefs(...groups) {
|
|
509
|
+
return mergeSafeEconomicEvidenceRefIds(...groups);
|
|
510
|
+
}
|
|
511
|
+
function toRuntimeEconomicEvidenceRef(ref) {
|
|
512
|
+
const safeRef = parseSafeEconomicEvidenceRefId(ref);
|
|
513
|
+
if (!safeRef) {
|
|
514
|
+
throw new Error("EconomicEvidenceInvalidRef: unsafe or invalid economic evidence ref");
|
|
515
|
+
}
|
|
516
|
+
return safeRef;
|
|
517
|
+
}
|
|
518
|
+
function createMutationEngine(options) {
|
|
519
|
+
const { transport, cache, queue, reachability, resolvers, events } = options;
|
|
520
|
+
const now = options.now ?? (() => Date.now());
|
|
521
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
522
|
+
const deps = {
|
|
523
|
+
cache,
|
|
524
|
+
queue,
|
|
525
|
+
resolvers,
|
|
526
|
+
events,
|
|
527
|
+
now,
|
|
528
|
+
queryEngine: options.queryEngine
|
|
529
|
+
};
|
|
530
|
+
let paused = false;
|
|
531
|
+
let seq = 0;
|
|
532
|
+
let draining = null;
|
|
533
|
+
const outcomes = new Map;
|
|
534
|
+
const toEnvelope = (entry, economicEvidenceRefs = entry.economicEvidenceRefs) => ({
|
|
535
|
+
operation: entry.operation,
|
|
536
|
+
input: entry.input,
|
|
537
|
+
tags: entry.tags,
|
|
538
|
+
economicEvidenceRefs
|
|
539
|
+
});
|
|
540
|
+
async function applyOptimistic(writes) {
|
|
541
|
+
const snapshots = [];
|
|
542
|
+
for (const write of writes) {
|
|
543
|
+
const previous = await cache.read(write.key);
|
|
544
|
+
snapshots.push({ key: write.key, previous });
|
|
545
|
+
await cache.write({
|
|
546
|
+
key: write.key,
|
|
547
|
+
data: write.data,
|
|
548
|
+
versionToken: write.versionToken ?? previous?.versionToken,
|
|
549
|
+
status: "warm",
|
|
550
|
+
tags: previous?.tags ?? [],
|
|
551
|
+
storedAt: now(),
|
|
552
|
+
staleAt: previous?.staleAt,
|
|
553
|
+
expiresAt: previous?.expiresAt
|
|
554
|
+
});
|
|
555
|
+
await options.queryEngine?.refresh(write.key);
|
|
556
|
+
}
|
|
557
|
+
return snapshots;
|
|
558
|
+
}
|
|
559
|
+
async function commitSuccess(entry, result) {
|
|
560
|
+
for (const snapshot of entry.rollback) {
|
|
561
|
+
const current = await cache.read(snapshot.key);
|
|
562
|
+
if (current !== undefined) {
|
|
563
|
+
await cache.write({
|
|
564
|
+
...current,
|
|
565
|
+
versionToken: result.versionToken ?? current.versionToken,
|
|
566
|
+
status: "stale",
|
|
567
|
+
staleAt: now()
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
await options.queryEngine?.refresh(snapshot.key);
|
|
571
|
+
}
|
|
572
|
+
await cache.invalidate(entry.tags, now());
|
|
573
|
+
}
|
|
574
|
+
async function replayEntry(entry) {
|
|
575
|
+
let safeEntryEconomicEvidenceRefs;
|
|
576
|
+
try {
|
|
577
|
+
safeEntryEconomicEvidenceRefs = mergeEconomicEvidenceRefs(entry.economicEvidenceRefs);
|
|
578
|
+
} catch {
|
|
579
|
+
await restoreSnapshots(entry.rollback, cache);
|
|
580
|
+
for (const snapshot of entry.rollback) {
|
|
581
|
+
await options.queryEngine?.refresh(snapshot.key);
|
|
582
|
+
}
|
|
583
|
+
await queue.remove(entry.id);
|
|
584
|
+
events.emit({
|
|
585
|
+
type: "mutation-failed",
|
|
586
|
+
id: entry.id,
|
|
587
|
+
code: "ECONOMIC_EVIDENCE_INVALID_REF"
|
|
588
|
+
});
|
|
589
|
+
outcomes.set(entry.id, {
|
|
590
|
+
id: entry.id,
|
|
591
|
+
status: "failed",
|
|
592
|
+
problem: {
|
|
593
|
+
type: "about:blank",
|
|
594
|
+
title: "Economic evidence ref rejected",
|
|
595
|
+
status: 422,
|
|
596
|
+
code: "ECONOMIC_EVIDENCE_INVALID_REF",
|
|
597
|
+
category: "validation",
|
|
598
|
+
severity: "error",
|
|
599
|
+
retryable: false
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
const attempts = entry.attempts + 1;
|
|
605
|
+
await queue.update({ ...entry, status: "in-flight", attempts });
|
|
606
|
+
const result = await transport.execute(toEnvelope(entry, safeEntryEconomicEvidenceRefs), options.contextProvider?.());
|
|
607
|
+
if (result.ok) {
|
|
608
|
+
const economicEvidenceRefs = mergeEconomicEvidenceRefs(safeEntryEconomicEvidenceRefs, result.data.economicEvidenceRefs);
|
|
609
|
+
await commitSuccess(entry, result.data);
|
|
610
|
+
await queue.remove(entry.id);
|
|
611
|
+
events.emit({
|
|
612
|
+
type: "mutation-replayed",
|
|
613
|
+
id: entry.id,
|
|
614
|
+
queueDepth: await queue.depth()
|
|
615
|
+
});
|
|
616
|
+
if (economicEvidenceRefs.length > 0) {
|
|
617
|
+
events.emit({
|
|
618
|
+
type: "economic-evidence-attached",
|
|
619
|
+
operationKey: entry.operation.key,
|
|
620
|
+
evidenceRefs: economicEvidenceRefs.map(toRuntimeEconomicEvidenceRef)
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
outcomes.set(entry.id, {
|
|
624
|
+
id: entry.id,
|
|
625
|
+
status: "done",
|
|
626
|
+
data: result.data.data,
|
|
627
|
+
economicEvidenceRefs
|
|
628
|
+
});
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
const { problem } = result;
|
|
632
|
+
if (problem.category === "auth") {
|
|
633
|
+
paused = true;
|
|
634
|
+
await queue.update({ ...entry, status: "paused-auth", attempts });
|
|
635
|
+
events.emit({ type: "auth-expired", id: entry.id });
|
|
636
|
+
outcomes.set(entry.id, { id: entry.id, status: "paused-auth", problem });
|
|
637
|
+
if (options.refreshAuth !== undefined && await options.refreshAuth()) {
|
|
638
|
+
paused = false;
|
|
639
|
+
await queue.update({ ...entry, status: "pending", attempts });
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
if (problem.category === "conflict") {
|
|
645
|
+
const disposition = await resolveQueuedConflict(entry, problem, deps);
|
|
646
|
+
if (disposition === "resolved") {
|
|
647
|
+
await queue.remove(entry.id);
|
|
648
|
+
outcomes.set(entry.id, { id: entry.id, status: "done", problem });
|
|
649
|
+
}
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
if (problem.retryable && attempts < maxAttempts) {
|
|
653
|
+
const delayMs = backoffDelayMs(attempts, options.backoff);
|
|
654
|
+
await queue.update({ ...entry, status: "failed", attempts });
|
|
655
|
+
events.emit({
|
|
656
|
+
type: "mutation-retry",
|
|
657
|
+
id: entry.id,
|
|
658
|
+
attempt: attempts,
|
|
659
|
+
delayMs
|
|
660
|
+
});
|
|
661
|
+
outcomes.set(entry.id, { id: entry.id, status: "failed", problem });
|
|
662
|
+
options.schedule?.(() => {
|
|
663
|
+
drain();
|
|
664
|
+
}, delayMs);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
await restoreSnapshots(entry.rollback, cache);
|
|
668
|
+
for (const snapshot of entry.rollback) {
|
|
669
|
+
await options.queryEngine?.refresh(snapshot.key);
|
|
670
|
+
}
|
|
671
|
+
await queue.remove(entry.id);
|
|
672
|
+
events.emit({ type: "mutation-failed", id: entry.id, code: result.code });
|
|
673
|
+
outcomes.set(entry.id, { id: entry.id, status: "failed", problem });
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
async function runDrain() {
|
|
677
|
+
if (paused)
|
|
678
|
+
return;
|
|
679
|
+
if (!isNetworkAllowed(reachability.getSnapshot().signal))
|
|
680
|
+
return;
|
|
681
|
+
for (const entry of await queue.list()) {
|
|
682
|
+
if (entry.status === "paused-auth")
|
|
683
|
+
continue;
|
|
684
|
+
if (!await replayEntry(entry))
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
events.emit({ type: "queue-drained", remaining: await queue.depth() });
|
|
688
|
+
}
|
|
689
|
+
function drain() {
|
|
690
|
+
if (draining !== null)
|
|
691
|
+
return draining;
|
|
692
|
+
draining = runDrain().finally(() => {
|
|
693
|
+
draining = null;
|
|
694
|
+
});
|
|
695
|
+
return draining;
|
|
696
|
+
}
|
|
697
|
+
async function mutate(request, mutateOptions = {}) {
|
|
698
|
+
mergeEconomicEvidenceRefs(request.economicEvidenceRefs);
|
|
699
|
+
const enqueuedAt = now();
|
|
700
|
+
seq += 1;
|
|
701
|
+
const id = `${enqueuedAt}-${seq}`;
|
|
702
|
+
const rollback = mutateOptions.optimistic ? await applyOptimistic(mutateOptions.optimistic) : [];
|
|
703
|
+
await queue.enqueue({
|
|
704
|
+
...request,
|
|
705
|
+
conflictPolicy: request.conflictPolicy ?? defaultConflictPolicy2(),
|
|
706
|
+
id,
|
|
707
|
+
enqueuedAt,
|
|
708
|
+
attempts: 0,
|
|
709
|
+
status: "pending",
|
|
710
|
+
rollback
|
|
711
|
+
});
|
|
712
|
+
events.emit({
|
|
713
|
+
type: "mutation-enqueued",
|
|
714
|
+
id,
|
|
715
|
+
queueDepth: await queue.depth()
|
|
716
|
+
});
|
|
717
|
+
if (!paused && isNetworkAllowed(reachability.getSnapshot().signal)) {
|
|
718
|
+
await drain();
|
|
719
|
+
}
|
|
720
|
+
return outcomes.get(id) ?? {
|
|
721
|
+
id,
|
|
722
|
+
status: "pending"
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
async function resume() {
|
|
726
|
+
paused = false;
|
|
727
|
+
for (const entry of await queue.list()) {
|
|
728
|
+
if (entry.status === "paused-auth") {
|
|
729
|
+
await queue.update({ ...entry, status: "pending" });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
await drain();
|
|
733
|
+
}
|
|
734
|
+
const unsubscribe = reachability.subscribe((snapshot) => {
|
|
735
|
+
if (!paused && isNetworkAllowed(snapshot.signal))
|
|
736
|
+
drain();
|
|
737
|
+
});
|
|
738
|
+
return { mutate, drain, resume, dispose: unsubscribe };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/engine/mutation-queue.ts
|
|
742
|
+
var QUEUE_PREFIX = "queue:";
|
|
743
|
+
function createMutationQueue(storage) {
|
|
744
|
+
const storageKey = (id) => `${QUEUE_PREFIX}${id}`;
|
|
745
|
+
async function readAll() {
|
|
746
|
+
const keys = await storage.keys(QUEUE_PREFIX);
|
|
747
|
+
const entries = [];
|
|
748
|
+
for (const key of keys) {
|
|
749
|
+
const entry = await storage.get(key);
|
|
750
|
+
if (entry !== undefined)
|
|
751
|
+
entries.push(entry);
|
|
752
|
+
}
|
|
753
|
+
entries.sort((a, b) => a.enqueuedAt === b.enqueuedAt ? a.id.localeCompare(b.id) : a.enqueuedAt - b.enqueuedAt);
|
|
754
|
+
return entries;
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
enqueue: (entry) => storage.set(storageKey(entry.id), entry),
|
|
758
|
+
update: (entry) => storage.set(storageKey(entry.id), entry),
|
|
759
|
+
remove: (id) => storage.remove(storageKey(id)),
|
|
760
|
+
list: () => readAll(),
|
|
761
|
+
depth: async () => (await storage.keys(QUEUE_PREFIX)).length
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/engine/online-only-data-engine.ts
|
|
766
|
+
import { createQueryKey } from "@lssm/lib.contracts-spec/query/key";
|
|
767
|
+
|
|
768
|
+
// src/engine/state.ts
|
|
769
|
+
function computeIsStale(entry, now) {
|
|
770
|
+
if (entry?.staleAt === undefined)
|
|
771
|
+
return false;
|
|
772
|
+
return now >= entry.staleAt;
|
|
773
|
+
}
|
|
774
|
+
function idleState() {
|
|
775
|
+
return {
|
|
776
|
+
status: "idle",
|
|
777
|
+
fetchStatus: "idle",
|
|
778
|
+
cacheStatus: "empty",
|
|
779
|
+
isStale: false,
|
|
780
|
+
isOffline: false
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function loadingState(entry, now) {
|
|
784
|
+
if (entry === undefined) {
|
|
785
|
+
return {
|
|
786
|
+
status: "loading",
|
|
787
|
+
fetchStatus: "fetching",
|
|
788
|
+
cacheStatus: "empty",
|
|
789
|
+
isStale: false,
|
|
790
|
+
isOffline: false
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
const isStale = computeIsStale(entry, now);
|
|
794
|
+
return {
|
|
795
|
+
status: "success",
|
|
796
|
+
fetchStatus: "fetching",
|
|
797
|
+
data: entry.data,
|
|
798
|
+
cacheStatus: isStale ? "stale" : entry.status,
|
|
799
|
+
isStale,
|
|
800
|
+
isOffline: false,
|
|
801
|
+
versionToken: entry.versionToken,
|
|
802
|
+
dataUpdatedAt: entry.storedAt,
|
|
803
|
+
...entry.pageInfo === undefined ? {} : { pageInfo: entry.pageInfo }
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
function successState(entry, now) {
|
|
807
|
+
const isStale = computeIsStale(entry, now);
|
|
808
|
+
return {
|
|
809
|
+
status: "success",
|
|
810
|
+
fetchStatus: "idle",
|
|
811
|
+
data: entry.data,
|
|
812
|
+
cacheStatus: isStale ? "stale" : entry.status,
|
|
813
|
+
isStale,
|
|
814
|
+
isOffline: false,
|
|
815
|
+
versionToken: entry.versionToken,
|
|
816
|
+
dataUpdatedAt: entry.storedAt,
|
|
817
|
+
...entry.pageInfo === undefined ? {} : { pageInfo: entry.pageInfo }
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
function errorState(problem, entry, now) {
|
|
821
|
+
return {
|
|
822
|
+
status: "error",
|
|
823
|
+
fetchStatus: "idle",
|
|
824
|
+
data: entry?.data,
|
|
825
|
+
error: problem,
|
|
826
|
+
cacheStatus: entry === undefined ? "empty" : entry.status,
|
|
827
|
+
isStale: computeIsStale(entry, now),
|
|
828
|
+
isOffline: false,
|
|
829
|
+
versionToken: entry?.versionToken,
|
|
830
|
+
dataUpdatedAt: entry?.storedAt,
|
|
831
|
+
errorUpdatedAt: now
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
function authExpiredState(problem, entry, now) {
|
|
835
|
+
return {
|
|
836
|
+
status: "auth-expired",
|
|
837
|
+
fetchStatus: "idle",
|
|
838
|
+
data: entry?.data,
|
|
839
|
+
error: problem,
|
|
840
|
+
cacheStatus: entry === undefined ? "empty" : entry.status,
|
|
841
|
+
isStale: computeIsStale(entry, now),
|
|
842
|
+
isOffline: false,
|
|
843
|
+
versionToken: entry?.versionToken,
|
|
844
|
+
dataUpdatedAt: entry?.storedAt,
|
|
845
|
+
errorUpdatedAt: now
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function conflictState(entry, conflict, now) {
|
|
849
|
+
return {
|
|
850
|
+
status: entry === undefined ? "idle" : "success",
|
|
851
|
+
fetchStatus: "idle",
|
|
852
|
+
data: entry?.data,
|
|
853
|
+
cacheStatus: "conflict",
|
|
854
|
+
isStale: computeIsStale(entry, now),
|
|
855
|
+
isOffline: false,
|
|
856
|
+
conflict,
|
|
857
|
+
versionToken: conflict.versionToken ?? entry?.versionToken,
|
|
858
|
+
dataUpdatedAt: entry?.storedAt
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function offlineState(entry, now) {
|
|
862
|
+
return {
|
|
863
|
+
status: entry === undefined ? "idle" : "success",
|
|
864
|
+
fetchStatus: "idle",
|
|
865
|
+
data: entry?.data,
|
|
866
|
+
cacheStatus: "offline",
|
|
867
|
+
isStale: computeIsStale(entry, now),
|
|
868
|
+
isOffline: true,
|
|
869
|
+
versionToken: entry?.versionToken,
|
|
870
|
+
dataUpdatedAt: entry?.storedAt,
|
|
871
|
+
...entry?.pageInfo === undefined ? {} : { pageInfo: entry.pageInfo }
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/engine/online-only-data-engine.ts
|
|
876
|
+
function createOnlineOnlyDataEngine(options, resolvedPolicy) {
|
|
877
|
+
const { transport } = options;
|
|
878
|
+
const advanced = options.advanced ?? {};
|
|
879
|
+
const now = advanced.now ?? (() => Date.now());
|
|
880
|
+
const reachability = advanced.reachability ?? createStaticReachabilitySource("online");
|
|
881
|
+
const events = advanced.events ?? createEngineEvents();
|
|
882
|
+
const resolvers = advanced.resolvers ?? createConflictResolverRegistry();
|
|
883
|
+
const localOffline = resolvedPolicy ?? resolveLocalOfflinePolicy({
|
|
884
|
+
defaultMode: options.localOffline,
|
|
885
|
+
env: options.env,
|
|
886
|
+
flag: options.flag
|
|
887
|
+
});
|
|
888
|
+
const states = new Map;
|
|
889
|
+
const listeners = new Map;
|
|
890
|
+
let seq = 0;
|
|
891
|
+
function emit(key, state) {
|
|
892
|
+
states.set(key, state);
|
|
893
|
+
for (const listener of listeners.get(key) ?? [])
|
|
894
|
+
listener(state);
|
|
895
|
+
}
|
|
896
|
+
const queryEngine = {
|
|
897
|
+
async query(envelope, ctx) {
|
|
898
|
+
const key = createQueryKey(envelope);
|
|
899
|
+
const signal = reachability.getSnapshot().signal;
|
|
900
|
+
if (!isNetworkAllowed(signal)) {
|
|
901
|
+
const state = offlineState(undefined, now());
|
|
902
|
+
emit(key, state);
|
|
903
|
+
return state;
|
|
904
|
+
}
|
|
905
|
+
emit(key, loadingState(undefined, now()));
|
|
906
|
+
const startedAt = now();
|
|
907
|
+
const result = await transport.execute(envelope, ctx);
|
|
908
|
+
const settledAt = now();
|
|
909
|
+
if (!result.ok) {
|
|
910
|
+
events.emit({
|
|
911
|
+
type: "fetch-error",
|
|
912
|
+
key,
|
|
913
|
+
code: result.code,
|
|
914
|
+
latencyMs: settledAt - startedAt
|
|
915
|
+
});
|
|
916
|
+
const state = result.problem.category === "auth" ? authExpiredState(result.problem, undefined, settledAt) : errorState(result.problem, undefined, settledAt);
|
|
917
|
+
emit(key, state);
|
|
918
|
+
return state;
|
|
919
|
+
}
|
|
920
|
+
events.emit({
|
|
921
|
+
type: "fetch-success",
|
|
922
|
+
key,
|
|
923
|
+
latencyMs: settledAt - startedAt
|
|
924
|
+
});
|
|
925
|
+
const entry = {
|
|
926
|
+
key,
|
|
927
|
+
data: result.data.data,
|
|
928
|
+
versionToken: result.data.versionToken ?? result.versionToken,
|
|
929
|
+
status: signal === "restricted" ? "warm" : "fresh",
|
|
930
|
+
tags: [],
|
|
931
|
+
storedAt: settledAt,
|
|
932
|
+
...result.data.pageInfo === undefined ? {} : { pageInfo: result.data.pageInfo }
|
|
933
|
+
};
|
|
934
|
+
const state = successState(entry, settledAt);
|
|
935
|
+
emit(key, state);
|
|
936
|
+
return state;
|
|
937
|
+
},
|
|
938
|
+
getState(envelope) {
|
|
939
|
+
return states.get(createQueryKey(envelope)) ?? idleState();
|
|
940
|
+
},
|
|
941
|
+
subscribe(envelope, listener) {
|
|
942
|
+
const key = createQueryKey(envelope);
|
|
943
|
+
let set = listeners.get(key);
|
|
944
|
+
if (set === undefined) {
|
|
945
|
+
set = new Set;
|
|
946
|
+
listeners.set(key, set);
|
|
947
|
+
}
|
|
948
|
+
set.add(listener);
|
|
949
|
+
return () => set?.delete(listener);
|
|
950
|
+
},
|
|
951
|
+
async invalidateTags() {
|
|
952
|
+
return [];
|
|
953
|
+
},
|
|
954
|
+
async refresh(key) {
|
|
955
|
+
const state = states.get(key) ?? idleState();
|
|
956
|
+
emit(key, state);
|
|
957
|
+
return state;
|
|
958
|
+
},
|
|
959
|
+
async emitConflict(key, conflict) {
|
|
960
|
+
const state = conflictState(undefined, conflict, now());
|
|
961
|
+
emit(key, state);
|
|
962
|
+
return state;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
const mutationEngine = {
|
|
966
|
+
async mutate(request, mutateOptions = {}) {
|
|
967
|
+
seq += 1;
|
|
968
|
+
const id = `${now()}-${seq}`;
|
|
969
|
+
if (!isNetworkAllowed(reachability.getSnapshot().signal)) {
|
|
970
|
+
return {
|
|
971
|
+
id,
|
|
972
|
+
status: "disabled-offline",
|
|
973
|
+
problem: offlineQueueDisabledProblem()
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const result = await transport.execute({
|
|
977
|
+
operation: request.operation,
|
|
978
|
+
input: request.input,
|
|
979
|
+
tags: request.tags
|
|
980
|
+
}, mutateOptions.ctx ?? advanced.contextProvider?.());
|
|
981
|
+
if (result.ok)
|
|
982
|
+
return { id, status: "done", data: result.data.data };
|
|
983
|
+
if (result.problem.category === "auth") {
|
|
984
|
+
return { id, status: "paused-auth", problem: result.problem };
|
|
985
|
+
}
|
|
986
|
+
return { id, status: "failed", problem: result.problem };
|
|
987
|
+
},
|
|
988
|
+
async drain() {},
|
|
989
|
+
async resume() {},
|
|
990
|
+
dispose() {}
|
|
991
|
+
};
|
|
992
|
+
return {
|
|
993
|
+
...queryEngine,
|
|
994
|
+
...mutationEngine,
|
|
995
|
+
maintenance: {
|
|
996
|
+
collectGarbage: async () => 0,
|
|
997
|
+
revalidateStale: async () => 0
|
|
998
|
+
},
|
|
999
|
+
events,
|
|
1000
|
+
resolvers,
|
|
1001
|
+
localOffline
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
function offlineQueueDisabledProblem() {
|
|
1005
|
+
return {
|
|
1006
|
+
type: "about:blank",
|
|
1007
|
+
title: "Offline mutation queue disabled",
|
|
1008
|
+
status: 409,
|
|
1009
|
+
code: "OFFLINE_QUEUE_DISABLED",
|
|
1010
|
+
category: "conflict",
|
|
1011
|
+
severity: "warning",
|
|
1012
|
+
retryable: false,
|
|
1013
|
+
detail: "localOffline=online-only does not cache local data or queue offline mutations."
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/engine/query-engine.ts
|
|
1018
|
+
import { createQueryKey as createQueryKey2 } from "@lssm/lib.contracts-spec/query/key";
|
|
1019
|
+
|
|
1020
|
+
// src/engine/dedup.ts
|
|
1021
|
+
function createInflightRegistry() {
|
|
1022
|
+
const inflight = new Map;
|
|
1023
|
+
return {
|
|
1024
|
+
run(key, factory) {
|
|
1025
|
+
const existing = inflight.get(key);
|
|
1026
|
+
if (existing !== undefined)
|
|
1027
|
+
return existing;
|
|
1028
|
+
const promise = factory().finally(() => {
|
|
1029
|
+
inflight.delete(key);
|
|
1030
|
+
});
|
|
1031
|
+
inflight.set(key, promise);
|
|
1032
|
+
return promise;
|
|
1033
|
+
},
|
|
1034
|
+
size() {
|
|
1035
|
+
return inflight.size;
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/engine/query-engine.ts
|
|
1041
|
+
var DEFAULT_STALE_TIME_MS = 0;
|
|
1042
|
+
function createQueryEngine(options) {
|
|
1043
|
+
const { transport } = options;
|
|
1044
|
+
const storage = options.storage ?? createMemoryStorage();
|
|
1045
|
+
const reachability = options.reachability ?? createStaticReachabilitySource("online");
|
|
1046
|
+
const now = options.now ?? (() => Date.now());
|
|
1047
|
+
const defaultStaleTimeMs = options.defaultStaleTimeMs ?? DEFAULT_STALE_TIME_MS;
|
|
1048
|
+
const cache = options.cacheStore ?? createCacheStore(storage);
|
|
1049
|
+
const events = options.events ?? createEngineEvents();
|
|
1050
|
+
const inflight = createInflightRegistry();
|
|
1051
|
+
const states = new Map;
|
|
1052
|
+
const listeners = new Map;
|
|
1053
|
+
function emit(key, state) {
|
|
1054
|
+
states.set(key, state);
|
|
1055
|
+
const set = listeners.get(key);
|
|
1056
|
+
if (set === undefined)
|
|
1057
|
+
return;
|
|
1058
|
+
for (const listener of set)
|
|
1059
|
+
listener(state);
|
|
1060
|
+
}
|
|
1061
|
+
async function query(envelope, ctx) {
|
|
1062
|
+
const key = createQueryKey2(envelope);
|
|
1063
|
+
const signal = reachability.getSnapshot().signal;
|
|
1064
|
+
const entry = await cache.read(key);
|
|
1065
|
+
if (!isNetworkAllowed(signal)) {
|
|
1066
|
+
const state = offlineState(entry, now());
|
|
1067
|
+
emit(key, state);
|
|
1068
|
+
return state;
|
|
1069
|
+
}
|
|
1070
|
+
if (entry !== undefined && !computeIsStale(entry, now())) {
|
|
1071
|
+
events.emit({ type: "cache-hit", key });
|
|
1072
|
+
const state = successState(entry, now());
|
|
1073
|
+
emit(key, state);
|
|
1074
|
+
return state;
|
|
1075
|
+
}
|
|
1076
|
+
events.emit({ type: "cache-miss", key });
|
|
1077
|
+
emit(key, loadingState(entry, now()));
|
|
1078
|
+
const startedAt = now();
|
|
1079
|
+
const result = await inflight.run(key, () => transport.execute(envelope, ctx));
|
|
1080
|
+
const settledAt = now();
|
|
1081
|
+
if (!result.ok) {
|
|
1082
|
+
events.emit({
|
|
1083
|
+
type: "fetch-error",
|
|
1084
|
+
key,
|
|
1085
|
+
code: result.code,
|
|
1086
|
+
latencyMs: settledAt - startedAt
|
|
1087
|
+
});
|
|
1088
|
+
const state = result.problem.category === "auth" ? authExpiredState(result.problem, entry, settledAt) : errorState(result.problem, entry, settledAt);
|
|
1089
|
+
emit(key, state);
|
|
1090
|
+
return state;
|
|
1091
|
+
}
|
|
1092
|
+
events.emit({
|
|
1093
|
+
type: "fetch-success",
|
|
1094
|
+
key,
|
|
1095
|
+
latencyMs: settledAt - startedAt
|
|
1096
|
+
});
|
|
1097
|
+
const staleTimeMs = envelope.cache?.staleTimeMs ?? defaultStaleTimeMs;
|
|
1098
|
+
const cacheTimeMs = envelope.cache?.cacheTimeMs;
|
|
1099
|
+
const written = {
|
|
1100
|
+
key,
|
|
1101
|
+
data: result.data.data,
|
|
1102
|
+
versionToken: result.data.versionToken ?? result.versionToken,
|
|
1103
|
+
status: signal === "restricted" ? "warm" : "fresh",
|
|
1104
|
+
tags: envelope.tags ?? [],
|
|
1105
|
+
storedAt: settledAt,
|
|
1106
|
+
staleAt: settledAt + staleTimeMs,
|
|
1107
|
+
envelope,
|
|
1108
|
+
...cacheTimeMs === undefined ? {} : { expiresAt: settledAt + cacheTimeMs },
|
|
1109
|
+
...result.data.pageInfo === undefined ? {} : { pageInfo: result.data.pageInfo }
|
|
1110
|
+
};
|
|
1111
|
+
await cache.write(written);
|
|
1112
|
+
const state = successState(written, settledAt);
|
|
1113
|
+
emit(key, state);
|
|
1114
|
+
return state;
|
|
1115
|
+
}
|
|
1116
|
+
async function refresh(key) {
|
|
1117
|
+
const entry = await cache.read(key);
|
|
1118
|
+
const state = entry === undefined ? idleState() : successState(entry, now());
|
|
1119
|
+
emit(key, state);
|
|
1120
|
+
return state;
|
|
1121
|
+
}
|
|
1122
|
+
async function emitConflict(key, conflict) {
|
|
1123
|
+
const entry = await cache.read(key);
|
|
1124
|
+
const state = conflictState(entry, conflict, now());
|
|
1125
|
+
emit(key, state);
|
|
1126
|
+
return state;
|
|
1127
|
+
}
|
|
1128
|
+
function getState(envelope) {
|
|
1129
|
+
const existing = states.get(createQueryKey2(envelope));
|
|
1130
|
+
return existing ?? idleState();
|
|
1131
|
+
}
|
|
1132
|
+
function subscribe(envelope, listener) {
|
|
1133
|
+
const key = createQueryKey2(envelope);
|
|
1134
|
+
let set = listeners.get(key);
|
|
1135
|
+
if (set === undefined) {
|
|
1136
|
+
set = new Set;
|
|
1137
|
+
listeners.set(key, set);
|
|
1138
|
+
}
|
|
1139
|
+
set.add(listener);
|
|
1140
|
+
return () => {
|
|
1141
|
+
listeners.get(key)?.delete(listener);
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
async function invalidateTags(tags) {
|
|
1145
|
+
const affected = await cache.invalidate(tags, now());
|
|
1146
|
+
for (const key of affected) {
|
|
1147
|
+
const entry = await cache.read(key);
|
|
1148
|
+
if (entry !== undefined)
|
|
1149
|
+
emit(key, successState(entry, now()));
|
|
1150
|
+
}
|
|
1151
|
+
return affected;
|
|
1152
|
+
}
|
|
1153
|
+
return { query, getState, subscribe, invalidateTags, refresh, emitConflict };
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// src/engine/data-engine.ts
|
|
1157
|
+
function createDataEngine(options) {
|
|
1158
|
+
assertNoLegacyLowLevelOptions(options);
|
|
1159
|
+
const advanced = options.advanced ?? {};
|
|
1160
|
+
const policy = resolveLocalOfflinePolicy({
|
|
1161
|
+
defaultMode: options.localOffline,
|
|
1162
|
+
env: options.env,
|
|
1163
|
+
flag: options.flag
|
|
1164
|
+
});
|
|
1165
|
+
assertModeCompatibleWithAdvanced(policy.mode, advanced);
|
|
1166
|
+
if (policy.mode === "online-only")
|
|
1167
|
+
return createOnlineOnlyDataEngine(options, policy);
|
|
1168
|
+
const now = advanced.now ?? (() => Date.now());
|
|
1169
|
+
const storage = advanced.storage ?? createMemoryStorage();
|
|
1170
|
+
const events = advanced.events ?? createEngineEvents();
|
|
1171
|
+
const cacheStore = advanced.cacheStore ?? createCacheStore(storage);
|
|
1172
|
+
const reachability = advanced.reachability ?? createStaticReachabilitySource("online");
|
|
1173
|
+
const resolvers = advanced.resolvers ?? createConflictResolverRegistry();
|
|
1174
|
+
const queue = createMutationQueue(storage);
|
|
1175
|
+
const queryEngine = createQueryEngine({
|
|
1176
|
+
...advanced,
|
|
1177
|
+
transport: options.transport,
|
|
1178
|
+
now,
|
|
1179
|
+
storage,
|
|
1180
|
+
events,
|
|
1181
|
+
cacheStore,
|
|
1182
|
+
reachability
|
|
1183
|
+
});
|
|
1184
|
+
const mutationEngine = createMutationEngine({
|
|
1185
|
+
transport: options.transport,
|
|
1186
|
+
cache: cacheStore,
|
|
1187
|
+
queue,
|
|
1188
|
+
reachability,
|
|
1189
|
+
resolvers,
|
|
1190
|
+
events,
|
|
1191
|
+
now,
|
|
1192
|
+
maxAttempts: advanced.maxAttempts,
|
|
1193
|
+
queryEngine,
|
|
1194
|
+
contextProvider: advanced.contextProvider,
|
|
1195
|
+
refreshAuth: advanced.refreshAuth,
|
|
1196
|
+
schedule: advanced.schedule,
|
|
1197
|
+
backoff: advanced.backoff
|
|
1198
|
+
});
|
|
1199
|
+
const maintenance = createMaintenance({
|
|
1200
|
+
cache: cacheStore,
|
|
1201
|
+
events,
|
|
1202
|
+
now,
|
|
1203
|
+
revalidate: (envelope) => queryEngine.query(envelope)
|
|
1204
|
+
});
|
|
1205
|
+
return {
|
|
1206
|
+
...queryEngine,
|
|
1207
|
+
...mutationEngine,
|
|
1208
|
+
maintenance,
|
|
1209
|
+
events,
|
|
1210
|
+
resolvers,
|
|
1211
|
+
localOffline: policy
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
function assertModeCompatibleWithAdvanced(mode, advanced) {
|
|
1215
|
+
if (mode === "offline-capable") {
|
|
1216
|
+
if (advanced.storage === undefined) {
|
|
1217
|
+
throw new Error("localOffline=offline-capable requires advanced.storage; pass a durable StoragePort or explicit platform storage adapter.");
|
|
1218
|
+
}
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (advanced.storage !== undefined) {
|
|
1222
|
+
throw new Error(`localOffline=${mode} rejects advanced.storage; this mode must not use durable local persistence.`);
|
|
1223
|
+
}
|
|
1224
|
+
if (advanced.cacheStore !== undefined) {
|
|
1225
|
+
throw new Error(`localOffline=${mode} rejects advanced.cacheStore; this mode owns cache semantics.`);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
function assertNoLegacyLowLevelOptions(options) {
|
|
1229
|
+
const legacy = options;
|
|
1230
|
+
if (legacy.storage !== undefined) {
|
|
1231
|
+
throw new Error("Use advanced.storage instead of top-level storage.");
|
|
1232
|
+
}
|
|
1233
|
+
if (legacy.cacheStore !== undefined) {
|
|
1234
|
+
throw new Error("Use advanced.cacheStore instead of top-level cacheStore.");
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// src/engine/data-engine-host.ts
|
|
1238
|
+
function readHostEnv() {
|
|
1239
|
+
const proc = globalThis.process;
|
|
1240
|
+
return proc?.env;
|
|
1241
|
+
}
|
|
1242
|
+
function createDataEngineFromEnv(options) {
|
|
1243
|
+
return createDataEngine({
|
|
1244
|
+
...options,
|
|
1245
|
+
env: options.env ?? readHostEnv()
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
// src/engine/no-cache-store.ts
|
|
1249
|
+
function createNoCacheStore() {
|
|
1250
|
+
return {
|
|
1251
|
+
read: async (_key) => {
|
|
1252
|
+
return;
|
|
1253
|
+
},
|
|
1254
|
+
write: async () => {},
|
|
1255
|
+
remove: async () => {},
|
|
1256
|
+
entries: async () => [],
|
|
1257
|
+
invalidate: async (_tags, _now) => []
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
// src/engine/online-only-mutation-engine.ts
|
|
1261
|
+
function offlineDisabledProblem() {
|
|
1262
|
+
return {
|
|
1263
|
+
type: "https://contractspec.io/problems/offline-queue-disabled",
|
|
1264
|
+
title: "Offline mutation queue disabled",
|
|
1265
|
+
status: 503,
|
|
1266
|
+
code: "OFFLINE_QUEUE_DISABLED",
|
|
1267
|
+
category: "policy",
|
|
1268
|
+
severity: "warning",
|
|
1269
|
+
retryable: true,
|
|
1270
|
+
detail: "localOffline is online-only; offline mutations are not queued or replayed."
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
var toEnvelope = (request) => ({
|
|
1274
|
+
operation: request.operation,
|
|
1275
|
+
input: request.input,
|
|
1276
|
+
tags: request.tags
|
|
1277
|
+
});
|
|
1278
|
+
function createOnlineOnlyMutationEngine(options) {
|
|
1279
|
+
const now = options.now ?? (() => Date.now());
|
|
1280
|
+
let seq = 0;
|
|
1281
|
+
async function mutate(request, mutateOptions = {}) {
|
|
1282
|
+
seq += 1;
|
|
1283
|
+
const id = `${now()}-${seq}`;
|
|
1284
|
+
if (!isNetworkAllowed(options.reachability.getSnapshot().signal)) {
|
|
1285
|
+
return {
|
|
1286
|
+
id,
|
|
1287
|
+
status: "disabled-offline",
|
|
1288
|
+
problem: offlineDisabledProblem()
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
const result = await options.transport.execute(toEnvelope(request), mutateOptions.ctx);
|
|
1292
|
+
if (result.ok)
|
|
1293
|
+
return { id, status: "done", data: result.data.data };
|
|
1294
|
+
return { id, status: "failed", problem: result.problem };
|
|
1295
|
+
}
|
|
1296
|
+
return {
|
|
1297
|
+
mutate,
|
|
1298
|
+
drain: async () => {},
|
|
1299
|
+
resume: async () => {},
|
|
1300
|
+
dispose: () => {}
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
// src/forms-platform-types.ts
|
|
1304
|
+
class FormsPlatformError extends Error {
|
|
1305
|
+
code;
|
|
1306
|
+
constructor(code, message) {
|
|
1307
|
+
super(message);
|
|
1308
|
+
this.code = code;
|
|
1309
|
+
this.name = "FormsPlatformError";
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// src/forms-platform-utils.ts
|
|
1314
|
+
import {
|
|
1315
|
+
compileFormDocument,
|
|
1316
|
+
validateFormDocument
|
|
1317
|
+
} from "@lssm/lib.contracts-spec/forms";
|
|
1318
|
+
function requireFormDefinition(definition, scope) {
|
|
1319
|
+
const matches = definition?.owner.product === scope.product && definition.owner.tenantRef === scope.tenantRef && definition.owner.workspaceRef === scope.workspaceRef && definition.owner.ownerRef === scope.ownerRef;
|
|
1320
|
+
if (!definition || !matches) {
|
|
1321
|
+
throw new FormsPlatformError("FORM_NOT_FOUND", "Form definition was not found in the caller scope");
|
|
1322
|
+
}
|
|
1323
|
+
return definition;
|
|
1324
|
+
}
|
|
1325
|
+
function validateRuntimeFormDocument(document) {
|
|
1326
|
+
const issues = validateFormDocument(document);
|
|
1327
|
+
if (issues.length) {
|
|
1328
|
+
throw new FormsPlatformError("INVALID_FORM_DOCUMENT", issues.map((issue) => issue.message).join("; "));
|
|
1329
|
+
}
|
|
1330
|
+
compileFormDocument(document);
|
|
1331
|
+
}
|
|
1332
|
+
function formAssuranceMeetsRequirement(assurance, requirement) {
|
|
1333
|
+
if (requirement === "none")
|
|
1334
|
+
return true;
|
|
1335
|
+
if (requirement === "either")
|
|
1336
|
+
return assurance !== "anonymous";
|
|
1337
|
+
if (requirement === "both")
|
|
1338
|
+
return assurance === "both";
|
|
1339
|
+
return assurance === requirement || assurance === "both";
|
|
1340
|
+
}
|
|
1341
|
+
function resolveFormResponseStatus(document, assurance) {
|
|
1342
|
+
if (formAssuranceMeetsRequirement(assurance, document.verification.requirement)) {
|
|
1343
|
+
return "completed";
|
|
1344
|
+
}
|
|
1345
|
+
return document.verification.unverifiedDisposition === "pending-hidden" ? "pending-verification" : "completed-unverified";
|
|
1346
|
+
}
|
|
1347
|
+
function resolveFormLocale(document, candidates) {
|
|
1348
|
+
const supported = new Set(document.content.enabledLocales.map((locale) => locale.toLowerCase()));
|
|
1349
|
+
for (const candidate of candidates) {
|
|
1350
|
+
if (!candidate)
|
|
1351
|
+
continue;
|
|
1352
|
+
const normalized = candidate.trim().toLowerCase();
|
|
1353
|
+
const exact = document.content.enabledLocales.find((locale) => locale.toLowerCase() === normalized);
|
|
1354
|
+
if (supported.has(normalized) && exact)
|
|
1355
|
+
return exact;
|
|
1356
|
+
const language = normalized.split("-")[0];
|
|
1357
|
+
const match = document.content.enabledLocales.find((locale) => locale.toLowerCase().split("-")[0] === language);
|
|
1358
|
+
if (match)
|
|
1359
|
+
return match;
|
|
1360
|
+
}
|
|
1361
|
+
return document.content.defaultLocale;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/forms-platform-management.ts
|
|
1365
|
+
function createFormsManagementService(dependencies) {
|
|
1366
|
+
const emit = async (key, payload) => dependencies.emit?.(key, payload);
|
|
1367
|
+
return {
|
|
1368
|
+
async create(scope, document) {
|
|
1369
|
+
validateRuntimeFormDocument(document);
|
|
1370
|
+
const now = dependencies.now();
|
|
1371
|
+
const definition = {
|
|
1372
|
+
id: dependencies.id("definition"),
|
|
1373
|
+
owner: scope,
|
|
1374
|
+
status: "draft",
|
|
1375
|
+
draft: document,
|
|
1376
|
+
draftVersion: 1,
|
|
1377
|
+
createdAt: now,
|
|
1378
|
+
updatedAt: now
|
|
1379
|
+
};
|
|
1380
|
+
await dependencies.store.insertDefinition(definition);
|
|
1381
|
+
await emit("forms.definition.created", {
|
|
1382
|
+
definitionId: definition.id,
|
|
1383
|
+
occurredAt: now
|
|
1384
|
+
});
|
|
1385
|
+
return definition;
|
|
1386
|
+
},
|
|
1387
|
+
async saveDraft(scope, definitionId, expectedVersion, document) {
|
|
1388
|
+
validateRuntimeFormDocument(document);
|
|
1389
|
+
const current = requireFormDefinition(await dependencies.store.getDefinition(scope, definitionId), scope);
|
|
1390
|
+
if (current.status === "archived") {
|
|
1391
|
+
throw new FormsPlatformError("FORM_ARCHIVED", "Archived forms cannot be edited");
|
|
1392
|
+
}
|
|
1393
|
+
const next = {
|
|
1394
|
+
...current,
|
|
1395
|
+
draft: document,
|
|
1396
|
+
draftVersion: expectedVersion + 1,
|
|
1397
|
+
updatedAt: dependencies.now()
|
|
1398
|
+
};
|
|
1399
|
+
if (!await dependencies.store.updateDefinition(next, expectedVersion)) {
|
|
1400
|
+
throw new FormsPlatformError("DRAFT_CONFLICT", "The form draft changed; reload before saving");
|
|
1401
|
+
}
|
|
1402
|
+
await emit("forms.draft.saved", {
|
|
1403
|
+
definitionId,
|
|
1404
|
+
occurredAt: next.updatedAt
|
|
1405
|
+
});
|
|
1406
|
+
return next;
|
|
1407
|
+
},
|
|
1408
|
+
async publish(scope, definitionId, expectedVersion) {
|
|
1409
|
+
return dependencies.store.transaction(async (store) => {
|
|
1410
|
+
const current = requireFormDefinition(await store.getDefinition(scope, definitionId), scope);
|
|
1411
|
+
if (current.draftVersion !== expectedVersion) {
|
|
1412
|
+
throw new FormsPlatformError("DRAFT_CONFLICT", "The form draft changed before publish");
|
|
1413
|
+
}
|
|
1414
|
+
validateRuntimeFormDocument(current.draft);
|
|
1415
|
+
const previous = current.publishedRevisionId ? await store.getRevision(current.publishedRevisionId) : undefined;
|
|
1416
|
+
const publishedAt = dependencies.now();
|
|
1417
|
+
const revision = {
|
|
1418
|
+
id: dependencies.id("revision"),
|
|
1419
|
+
definitionId,
|
|
1420
|
+
revision: (previous?.revision ?? 0) + 1,
|
|
1421
|
+
document: structuredClone(current.draft),
|
|
1422
|
+
digest: await dependencies.digest(JSON.stringify(current.draft)),
|
|
1423
|
+
publishedAt
|
|
1424
|
+
};
|
|
1425
|
+
const definition = {
|
|
1426
|
+
...current,
|
|
1427
|
+
status: "published",
|
|
1428
|
+
publishedRevisionId: revision.id,
|
|
1429
|
+
publicToken: current.publicToken ?? dependencies.publicToken(),
|
|
1430
|
+
updatedAt: publishedAt
|
|
1431
|
+
};
|
|
1432
|
+
await store.insertRevision(revision);
|
|
1433
|
+
if (!await store.updateDefinition(definition, expectedVersion)) {
|
|
1434
|
+
throw new FormsPlatformError("DRAFT_CONFLICT", "The form changed during publish");
|
|
1435
|
+
}
|
|
1436
|
+
await emit("forms.definition.published", {
|
|
1437
|
+
definitionId,
|
|
1438
|
+
revisionId: revision.id,
|
|
1439
|
+
occurredAt: publishedAt
|
|
1440
|
+
});
|
|
1441
|
+
return { definition, revision };
|
|
1442
|
+
});
|
|
1443
|
+
},
|
|
1444
|
+
async close(scope, definitionId) {
|
|
1445
|
+
const current = requireFormDefinition(await dependencies.store.getDefinition(scope, definitionId), scope);
|
|
1446
|
+
if (current.status !== "published") {
|
|
1447
|
+
throw new FormsPlatformError("FORM_NOT_PUBLISHED", "Only a published form can be closed");
|
|
1448
|
+
}
|
|
1449
|
+
const next = {
|
|
1450
|
+
...current,
|
|
1451
|
+
status: "closed",
|
|
1452
|
+
updatedAt: dependencies.now()
|
|
1453
|
+
};
|
|
1454
|
+
await dependencies.store.updateDefinition(next);
|
|
1455
|
+
await emit("forms.definition.closed", {
|
|
1456
|
+
definitionId,
|
|
1457
|
+
occurredAt: next.updatedAt
|
|
1458
|
+
});
|
|
1459
|
+
return next;
|
|
1460
|
+
},
|
|
1461
|
+
async archive(scope, definitionId) {
|
|
1462
|
+
const current = requireFormDefinition(await dependencies.store.getDefinition(scope, definitionId), scope);
|
|
1463
|
+
if (current.status === "archived")
|
|
1464
|
+
return current;
|
|
1465
|
+
const next = {
|
|
1466
|
+
...current,
|
|
1467
|
+
status: "archived",
|
|
1468
|
+
updatedAt: dependencies.now()
|
|
1469
|
+
};
|
|
1470
|
+
await dependencies.store.updateDefinition(next);
|
|
1471
|
+
await emit("forms.definition.archived", {
|
|
1472
|
+
definitionId,
|
|
1473
|
+
occurredAt: next.updatedAt
|
|
1474
|
+
});
|
|
1475
|
+
return next;
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// src/forms-platform-submission.ts
|
|
1481
|
+
import {
|
|
1482
|
+
buildZodWithRelations,
|
|
1483
|
+
compileFormDocument as compileFormDocument2,
|
|
1484
|
+
FORM_AI_FULL_DATA_CONSENT_NOTICE_VERSION
|
|
1485
|
+
} from "@lssm/lib.contracts-spec/forms";
|
|
1486
|
+
function validateCanonicalFormAnswers(document, answers) {
|
|
1487
|
+
const questionsById = new Map(document.questions.map((question) => [question.id, question]));
|
|
1488
|
+
const portableFieldNames = new Set(Object.keys(document.formSpec?.modelFields ?? {}));
|
|
1489
|
+
const unknownIds = Object.keys(answers).filter((id) => !questionsById.has(id) && !portableFieldNames.has(id));
|
|
1490
|
+
if (unknownIds.length) {
|
|
1491
|
+
throw new FormsPlatformError("INVALID_RESPONSE", `Unknown question IDs: ${unknownIds.join(", ")}`);
|
|
1492
|
+
}
|
|
1493
|
+
for (const question of document.questions) {
|
|
1494
|
+
if (!["single-choice", "multi-select", "dropdown"].includes(question.kind))
|
|
1495
|
+
continue;
|
|
1496
|
+
const value = answers[question.id];
|
|
1497
|
+
if (value == null)
|
|
1498
|
+
continue;
|
|
1499
|
+
const allowed = new Set(question.options?.map((option) => option.value));
|
|
1500
|
+
const valid = question.kind === "multi-select" ? Array.isArray(value) && value.every((item) => typeof item === "string" && allowed.has(item)) : typeof value === "string" && allowed.has(value);
|
|
1501
|
+
if (!valid)
|
|
1502
|
+
throw new FormsPlatformError("INVALID_RESPONSE", `Answer for ${question.id} is not a declared option`);
|
|
1503
|
+
}
|
|
1504
|
+
const runtimeAnswers = Object.fromEntries(Object.entries(answers).map(([id, value]) => [
|
|
1505
|
+
questionsById.get(id)?.name ?? id,
|
|
1506
|
+
value
|
|
1507
|
+
]));
|
|
1508
|
+
const result = buildZodWithRelations(compileFormDocument2(document)).safeParse(runtimeAnswers);
|
|
1509
|
+
if (!result.success) {
|
|
1510
|
+
throw new FormsPlatformError("INVALID_RESPONSE", `Response does not match the published form: ${String(result.error)}`);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
function publicDocument(document) {
|
|
1514
|
+
const {
|
|
1515
|
+
kind,
|
|
1516
|
+
version,
|
|
1517
|
+
id,
|
|
1518
|
+
titleKey,
|
|
1519
|
+
descriptionKey,
|
|
1520
|
+
content,
|
|
1521
|
+
questions,
|
|
1522
|
+
pages,
|
|
1523
|
+
verification,
|
|
1524
|
+
formSpec,
|
|
1525
|
+
ai,
|
|
1526
|
+
beforeDestination,
|
|
1527
|
+
afterDestination
|
|
1528
|
+
} = document;
|
|
1529
|
+
return structuredClone({
|
|
1530
|
+
kind,
|
|
1531
|
+
version,
|
|
1532
|
+
id,
|
|
1533
|
+
titleKey,
|
|
1534
|
+
descriptionKey,
|
|
1535
|
+
content,
|
|
1536
|
+
questions,
|
|
1537
|
+
pages,
|
|
1538
|
+
verification,
|
|
1539
|
+
formSpec,
|
|
1540
|
+
ai: {
|
|
1541
|
+
mode: ai.mode,
|
|
1542
|
+
dataMode: ai.dataMode,
|
|
1543
|
+
...ai.dataMode === "full-with-consent" ? {
|
|
1544
|
+
consentNoticeVersion: FORM_AI_FULL_DATA_CONSENT_NOTICE_VERSION
|
|
1545
|
+
} : {}
|
|
1546
|
+
},
|
|
1547
|
+
beforeDestination,
|
|
1548
|
+
afterDestination
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
async function resolveAssurance(dependencies, evidenceRef, definitionId, publicToken) {
|
|
1552
|
+
if (!evidenceRef)
|
|
1553
|
+
return "anonymous";
|
|
1554
|
+
if (!dependencies.resolveAssurance) {
|
|
1555
|
+
throw new FormsPlatformError("VERIFICATION_UNAVAILABLE", "Verification evidence cannot be resolved");
|
|
1556
|
+
}
|
|
1557
|
+
return dependencies.resolveAssurance(evidenceRef, definitionId, publicToken);
|
|
1558
|
+
}
|
|
1559
|
+
function createFormsSubmissionService(dependencies) {
|
|
1560
|
+
return {
|
|
1561
|
+
async getPublic(publicToken, localeCandidates) {
|
|
1562
|
+
const definition = await dependencies.store.getDefinitionByPublicToken(publicToken);
|
|
1563
|
+
if (!definition || definition.status !== "published" || !definition.publishedRevisionId) {
|
|
1564
|
+
throw new FormsPlatformError("FORM_NOT_FOUND", "Published form was not found");
|
|
1565
|
+
}
|
|
1566
|
+
const revision = await dependencies.store.getRevision(definition.publishedRevisionId, { publicToken });
|
|
1567
|
+
if (!revision) {
|
|
1568
|
+
throw new FormsPlatformError("REVISION_NOT_FOUND", "Published form revision was not found");
|
|
1569
|
+
}
|
|
1570
|
+
return {
|
|
1571
|
+
definitionId: definition.id,
|
|
1572
|
+
revisionId: revision.id,
|
|
1573
|
+
revision: revision.revision,
|
|
1574
|
+
document: publicDocument(revision.document),
|
|
1575
|
+
locale: resolveFormLocale(revision.document, localeCandidates)
|
|
1576
|
+
};
|
|
1577
|
+
},
|
|
1578
|
+
async submit(input) {
|
|
1579
|
+
const definition = await dependencies.store.getDefinitionByPublicToken(input.publicToken);
|
|
1580
|
+
if (!definition || definition.status !== "published") {
|
|
1581
|
+
throw new FormsPlatformError("FORM_NOT_FOUND", "Form is not accepting responses");
|
|
1582
|
+
}
|
|
1583
|
+
const revision = await dependencies.store.getRevision(input.revisionId, {
|
|
1584
|
+
publicToken: input.publicToken
|
|
1585
|
+
});
|
|
1586
|
+
if (!revision || revision.definitionId !== definition.id) {
|
|
1587
|
+
throw new FormsPlatformError("REVISION_MISMATCH", "Response revision does not match the public session");
|
|
1588
|
+
}
|
|
1589
|
+
validateCanonicalFormAnswers(revision.document, input.answers);
|
|
1590
|
+
const assurance = await resolveAssurance(dependencies, input.verificationEvidenceRef, definition.id, input.publicToken);
|
|
1591
|
+
if (revision.document.verification.timing === "before" && !formAssuranceMeetsRequirement(assurance, revision.document.verification.requirement)) {
|
|
1592
|
+
throw new FormsPlatformError("VERIFICATION_REQUIRED", "Verification is required before submission");
|
|
1593
|
+
}
|
|
1594
|
+
const now = dependencies.now();
|
|
1595
|
+
const response = {
|
|
1596
|
+
id: dependencies.id("response"),
|
|
1597
|
+
definitionId: definition.id,
|
|
1598
|
+
revisionId: revision.id,
|
|
1599
|
+
locale: resolveFormLocale(revision.document, [input.locale]),
|
|
1600
|
+
status: resolveFormResponseStatus(revision.document, assurance),
|
|
1601
|
+
authorAssurance: assurance,
|
|
1602
|
+
answers: structuredClone(input.answers),
|
|
1603
|
+
createdAt: now,
|
|
1604
|
+
updatedAt: now,
|
|
1605
|
+
submittedAt: now
|
|
1606
|
+
};
|
|
1607
|
+
const stored = await dependencies.store.insertResponseIdempotently(response, input.publicToken, input.idempotencyKey);
|
|
1608
|
+
if (!stored.inserted)
|
|
1609
|
+
return stored.response;
|
|
1610
|
+
await dependencies.emit?.("forms.response.submitted", {
|
|
1611
|
+
definitionId: response.definitionId,
|
|
1612
|
+
revisionId: response.revisionId,
|
|
1613
|
+
responseId: response.id,
|
|
1614
|
+
occurredAt: now
|
|
1615
|
+
});
|
|
1616
|
+
return stored.response;
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// src/forms-platform-csv-columns.ts
|
|
1622
|
+
import { flattenPortableFormFields } from "@lssm/lib.contracts-spec/forms";
|
|
1623
|
+
function formCsvColumns(document) {
|
|
1624
|
+
const questionNames = new Set(document.questions.map(({ name }) => name));
|
|
1625
|
+
const questions = document.questions.map(questionColumn);
|
|
1626
|
+
const portableSensitive = new Set(document.formSpec?.policy?.pii ?? []);
|
|
1627
|
+
const allFields = flattenPortableFormFields(document.formSpec?.fields ?? []);
|
|
1628
|
+
const portable = Object.entries(document.formSpec?.modelFields ?? {}).flatMap(([name, modelField]) => {
|
|
1629
|
+
if (questionNames.has(name))
|
|
1630
|
+
return [];
|
|
1631
|
+
const field = allFields.find((candidate) => candidate.name === name);
|
|
1632
|
+
return [
|
|
1633
|
+
{
|
|
1634
|
+
id: name,
|
|
1635
|
+
labelKey: field?.labelI18n ?? name,
|
|
1636
|
+
sensitive: portableSensitive.has(name) || [...portableSensitive].some((path) => path.startsWith(`${name}.`)) || field?.kind === "email" || field?.kind === "phone" || modelField.type === "email" || modelField.type === "phone"
|
|
1637
|
+
}
|
|
1638
|
+
];
|
|
1639
|
+
});
|
|
1640
|
+
return [...questions, ...portable];
|
|
1641
|
+
}
|
|
1642
|
+
function questionColumn(question) {
|
|
1643
|
+
return {
|
|
1644
|
+
id: question.id,
|
|
1645
|
+
labelKey: question.labelKey,
|
|
1646
|
+
sensitive: question.sensitive === true || question.kind === "email" || question.kind === "phone"
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// src/forms-platform-csv.ts
|
|
1651
|
+
var RESPONSE_EXTENSIONS_COLUMN = "response_extensions:AI follow-ups";
|
|
1652
|
+
function csvCell(value) {
|
|
1653
|
+
const raw = Array.isArray(value) ? value.every((item) => typeof item !== "object") ? value.join(";") : JSON.stringify(value) : value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
1654
|
+
const protectedValue = /^[\s]*[=+\-@]/.test(raw) ? `'${raw}` : raw;
|
|
1655
|
+
return `"${protectedValue.replaceAll('"', '""')}"`;
|
|
1656
|
+
}
|
|
1657
|
+
function csvRow(values) {
|
|
1658
|
+
return `${values.map(csvCell).join(",")}\r
|
|
1659
|
+
`;
|
|
1660
|
+
}
|
|
1661
|
+
function responseRow(revision, response, options) {
|
|
1662
|
+
if (response.revisionId !== revision.id) {
|
|
1663
|
+
throw new Error("CSV responses must belong to the exported revision");
|
|
1664
|
+
}
|
|
1665
|
+
const columns = formCsvColumns(revision.document);
|
|
1666
|
+
const baseIds = new Set(columns.map(({ id }) => id));
|
|
1667
|
+
const extensions = Object.fromEntries(Object.entries(response.answers).filter(([id]) => !baseIds.has(id)));
|
|
1668
|
+
return [
|
|
1669
|
+
response.id,
|
|
1670
|
+
response.status,
|
|
1671
|
+
response.locale,
|
|
1672
|
+
response.submittedAt ?? "",
|
|
1673
|
+
...columns.map((column) => {
|
|
1674
|
+
const mayIncludeSensitive = options.includeSensitive && options.sensitiveGrant?.authorized;
|
|
1675
|
+
if (!mayIncludeSensitive && column.sensitive) {
|
|
1676
|
+
return "[REDACTED]";
|
|
1677
|
+
}
|
|
1678
|
+
return response.answers[column.id];
|
|
1679
|
+
}),
|
|
1680
|
+
options.includeSensitive && options.sensitiveGrant?.authorized ? extensions : "[REDACTED]"
|
|
1681
|
+
];
|
|
1682
|
+
}
|
|
1683
|
+
function csvHeaders(revision, locale) {
|
|
1684
|
+
const resolved = resolveFormLocale(revision.document, [locale]);
|
|
1685
|
+
const catalog = revision.document.content.catalogs[resolved] ?? {};
|
|
1686
|
+
return [
|
|
1687
|
+
"response_id",
|
|
1688
|
+
"status",
|
|
1689
|
+
"locale",
|
|
1690
|
+
"submitted_at",
|
|
1691
|
+
...formCsvColumns(revision.document).map((column) => `${column.id}:${catalog[column.labelKey] ?? column.labelKey}`),
|
|
1692
|
+
RESPONSE_EXTENSIONS_COLUMN
|
|
1693
|
+
];
|
|
1694
|
+
}
|
|
1695
|
+
function serializeFormResponsesCsv(revision, responses, options = {}) {
|
|
1696
|
+
return `\uFEFF${csvRow(csvHeaders(revision, options.locale))}${responses.map((response) => csvRow(responseRow(revision, response, options))).join("")}`;
|
|
1697
|
+
}
|
|
1698
|
+
async function* streamFormResponsesCsv(revision, responses, options = {}) {
|
|
1699
|
+
yield `\uFEFF${csvRow(csvHeaders(revision, options.locale))}`;
|
|
1700
|
+
for await (const response of responses) {
|
|
1701
|
+
yield csvRow(responseRow(revision, response, options));
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
// src/forms-platform-navigation.ts
|
|
1705
|
+
import {
|
|
1706
|
+
evalPredicate
|
|
1707
|
+
} from "@lssm/lib.contracts-spec/forms";
|
|
1708
|
+
function resolveNextFormPage(document, currentPageId, answers) {
|
|
1709
|
+
const currentIndex = document.pages.findIndex((page) => page.id === currentPageId);
|
|
1710
|
+
if (currentIndex < 0) {
|
|
1711
|
+
throw new FormsPlatformError("PAGE_NOT_FOUND", "Current form page does not exist");
|
|
1712
|
+
}
|
|
1713
|
+
const rule = document.pages[currentIndex]?.nextPageRules?.find((candidate) => evalPredicate(answers, candidate.when));
|
|
1714
|
+
return rule?.nextPageId ?? document.pages[currentIndex]?.defaultNextPageId ?? document.pages[currentIndex + 1]?.id;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// src/forms-platform.ts
|
|
1718
|
+
function createFormsPlatformService(dependencies) {
|
|
1719
|
+
return {
|
|
1720
|
+
...createFormsManagementService(dependencies),
|
|
1721
|
+
...createFormsSubmissionService(dependencies)
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
// src/redaction.ts
|
|
1725
|
+
var SECRET_KEY_PATTERN = /(?:token|secret|password|passwd|authorization|api[_-]?key|credential|cookie|session)/i;
|
|
1726
|
+
var REDACTED = "[REDACTED]";
|
|
1727
|
+
function redactSecrets(value) {
|
|
1728
|
+
if (Array.isArray(value))
|
|
1729
|
+
return value.map(redactSecrets);
|
|
1730
|
+
if (value === null || typeof value !== "object")
|
|
1731
|
+
return value;
|
|
1732
|
+
const out = {};
|
|
1733
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1734
|
+
out[key] = SECRET_KEY_PATTERN.test(key) ? REDACTED : redactSecrets(child);
|
|
1735
|
+
}
|
|
1736
|
+
return out;
|
|
1737
|
+
}
|
|
1738
|
+
var identityEncryption = {
|
|
1739
|
+
encrypt: (value) => value,
|
|
1740
|
+
decrypt: (value) => value
|
|
1741
|
+
};
|
|
1742
|
+
function hashKey(key) {
|
|
1743
|
+
let hash = 2166136261;
|
|
1744
|
+
for (let index = 0;index < key.length; index += 1) {
|
|
1745
|
+
hash ^= key.charCodeAt(index);
|
|
1746
|
+
hash = Math.imul(hash, 16777619);
|
|
1747
|
+
}
|
|
1748
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
1749
|
+
}
|
|
1750
|
+
var redactionDoc = {
|
|
1751
|
+
id: "docs.contracts-runtime-core.redaction",
|
|
1752
|
+
title: "Persisted-state redaction + encryption-at-rest seam",
|
|
1753
|
+
summary: "Secret redaction for durable state plus an encryption-at-rest seam and a PII-safe key hash for logs.",
|
|
1754
|
+
kind: "reference",
|
|
1755
|
+
visibility: "internal",
|
|
1756
|
+
route: "/docs/contracts-runtime-core/redaction",
|
|
1757
|
+
domain: "contracts-runtime-core",
|
|
1758
|
+
owners: ["@contractspec-core"],
|
|
1759
|
+
tags: ["contracts-runtime-core", "security", "redaction", "encryption"],
|
|
1760
|
+
body: "redactSecrets recursively strips secret-ish keys (token/secret/password/authorization/api-key/credential/cookie/session) so durable cache+queue state never persists secrets (AC12). EncryptionSeam is the encryption-at-rest seam — default identityEncryption (no-op); sensitive entities plug a real cipher transparently to the storage core. hashKey (FNV-1a) lets observability correlate on a stable key id without logging the raw query key (which embeds input/PII)."
|
|
1761
|
+
};
|
|
1762
|
+
// src/storage/keyed-store.ts
|
|
1763
|
+
function createKeyedStorage(driver, options = {}) {
|
|
1764
|
+
const encryption = options.encryption ?? identityEncryption;
|
|
1765
|
+
const redact = options.redact;
|
|
1766
|
+
const prefix = options.namespace ?? "";
|
|
1767
|
+
const toDriverKey = (key) => `${prefix}${key}`;
|
|
1768
|
+
const fromDriverKey = (key) => prefix !== "" && key.startsWith(prefix) ? key.slice(prefix.length) : key;
|
|
1769
|
+
return {
|
|
1770
|
+
async get(key) {
|
|
1771
|
+
const raw = await driver.getItem(toDriverKey(key));
|
|
1772
|
+
if (raw === null)
|
|
1773
|
+
return;
|
|
1774
|
+
const json = await encryption.decrypt(raw);
|
|
1775
|
+
return JSON.parse(json);
|
|
1776
|
+
},
|
|
1777
|
+
async set(key, value) {
|
|
1778
|
+
const json = JSON.stringify(redact === undefined ? value : redact(value));
|
|
1779
|
+
await driver.setItem(toDriverKey(key), await encryption.encrypt(json));
|
|
1780
|
+
},
|
|
1781
|
+
async remove(key) {
|
|
1782
|
+
await driver.removeItem(toDriverKey(key));
|
|
1783
|
+
},
|
|
1784
|
+
async keys(filter) {
|
|
1785
|
+
const raw = await driver.keys();
|
|
1786
|
+
const scoped = prefix === "" ? raw : raw.filter((key) => key.startsWith(prefix)).map(fromDriverKey);
|
|
1787
|
+
return filter === undefined ? scoped : scoped.filter((key) => key.startsWith(filter));
|
|
1788
|
+
},
|
|
1789
|
+
async clear() {
|
|
1790
|
+
await driver.clear();
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
// src/storage/port.ts
|
|
1795
|
+
var storagePortDoc = {
|
|
1796
|
+
id: "docs.contracts-runtime-core.storage-port",
|
|
1797
|
+
title: "Durable storage port (I/O-free contract)",
|
|
1798
|
+
summary: "Value-agnostic durable key/value port; platform impls own serialization, redaction, and encryption-at-rest.",
|
|
1799
|
+
kind: "reference",
|
|
1800
|
+
visibility: "internal",
|
|
1801
|
+
route: "/docs/contracts-runtime-core/storage-port",
|
|
1802
|
+
domain: "contracts-runtime-core",
|
|
1803
|
+
owners: ["@contractspec-core"],
|
|
1804
|
+
tags: ["contracts-runtime-core", "storage", "persistence", "port"],
|
|
1805
|
+
body: "Durable key/value StoragePort: get/set/remove/keys/clear. Intentionally value-agnostic — the engine layers CacheEntry and MutationQueueEntry shapes on top and namespaces by key prefix. keys(prefix?) supports GC and offline-queue scans. Implementations persist JSON-serializable values and own serialization, per-contract secret redaction, and the encryption-at-rest seam (AC12). B1a ships an in-memory default (createMemoryStorage); IndexedDB (web) and SQLite/AsyncStorage (native) platform variants land in B1b/#7 behind this same contract."
|
|
1806
|
+
};
|
|
1807
|
+
// src/transport.ts
|
|
1808
|
+
var transportPortDoc = {
|
|
1809
|
+
id: "docs.contracts-runtime-core.transport",
|
|
1810
|
+
title: "Transport port + absorbed data-transmission concept map",
|
|
1811
|
+
summary: "One Transport port carries the canonical protocol; each former data-transmission concept has an explicit home.",
|
|
1812
|
+
kind: "reference",
|
|
1813
|
+
visibility: "internal",
|
|
1814
|
+
route: "/docs/contracts-runtime-core/transport",
|
|
1815
|
+
domain: "contracts-runtime-core",
|
|
1816
|
+
owners: ["@contractspec-core"],
|
|
1817
|
+
tags: ["contracts-runtime-core", "transport", "data-transmission", "port"],
|
|
1818
|
+
body: [
|
|
1819
|
+
"The single Transport port: execute(envelope, ctx) → Promise<ContractResult<QueryResultEnvelope>>, plus an optional subscribe(tags, handler) realtime seam. REST/MCP/WebSocket/in-memory are interchangeable adapters behind it.",
|
|
1820
|
+
"",
|
|
1821
|
+
"Absorbed data-transmission concept map (each former concept has exactly one explicit home — no parallel transport types):",
|
|
1822
|
+
"- idempotency → ContractResult success field (ContractResultIdempotency{key,replayed?,firstSeenAt?} on ContractSuccess.idempotency). NOT a transport argument.",
|
|
1823
|
+
"- subscriptions → Transport.subscribe(tags, handler) seam (no spec type, by design); the version token doubles as the realtime invalidation token. Full delivery is a follow-on; the realtime worker migrates onto this seam in Phase 7.",
|
|
1824
|
+
"- invalidation → the InvalidationTag{entity,id?} vocabulary (contracts-spec/query/invalidation) shared by optimistic updates, offline-queue replay, and realtime broadcast; mutations declare the tags they invalidate.",
|
|
1825
|
+
"- consistency levels → the typed QueryEnvelope.consistency request field and ContractSuccess.consistency applied field (QueryConsistency = eventual | read-your-writes | strong, default read-your-writes). Typed, never prose. Subsumes the former DataFetchConsistency incl. read-your-writes.",
|
|
1826
|
+
"",
|
|
1827
|
+
"TransportContext.principal is opaque and carried end-to-end unchanged (AC14, no-strip); policy.auth enforcement stays in authos/identity-rbac."
|
|
1828
|
+
].join(`
|
|
1829
|
+
`)
|
|
1830
|
+
};
|
|
1831
|
+
export {
|
|
1832
|
+
ContractsRuntimeCoreFeature,
|
|
1833
|
+
FormsPlatformError,
|
|
1834
|
+
authExpiredState,
|
|
1835
|
+
backoffDelayMs,
|
|
1836
|
+
computeIsStale,
|
|
1837
|
+
conflictState,
|
|
1838
|
+
createCacheStore,
|
|
1839
|
+
createConflictResolverRegistry,
|
|
1840
|
+
createDataEngine,
|
|
1841
|
+
createDataEngineFromEnv,
|
|
1842
|
+
createEngineEvents,
|
|
1843
|
+
createFormsPlatformService,
|
|
1844
|
+
createInflightRegistry,
|
|
1845
|
+
createKeyedStorage,
|
|
1846
|
+
createMaintenance,
|
|
1847
|
+
createMemoryApprovalNonceStore,
|
|
1848
|
+
createMemoryStorage,
|
|
1849
|
+
createMutationEngine,
|
|
1850
|
+
createMutationQueue,
|
|
1851
|
+
createNoCacheStore,
|
|
1852
|
+
createOnlineOnlyMutationEngine,
|
|
1853
|
+
createOperationApprovalPort,
|
|
1854
|
+
createQueryEngine,
|
|
1855
|
+
createStaticReachabilitySource,
|
|
1856
|
+
errorState,
|
|
1857
|
+
hashKey,
|
|
1858
|
+
identityEncryption,
|
|
1859
|
+
idleState,
|
|
1860
|
+
isLocalOfflineMode,
|
|
1861
|
+
isNetworkAllowed,
|
|
1862
|
+
loadingState,
|
|
1863
|
+
offlineState,
|
|
1864
|
+
operationApprovalInputDigest,
|
|
1865
|
+
reachabilityPortDoc,
|
|
1866
|
+
readHostEnv,
|
|
1867
|
+
redactSecrets,
|
|
1868
|
+
redactionDoc,
|
|
1869
|
+
resolveFormLocale,
|
|
1870
|
+
resolveLocalOfflinePolicy,
|
|
1871
|
+
resolveNextFormPage,
|
|
1872
|
+
resolveQueuedConflict,
|
|
1873
|
+
restoreSnapshots,
|
|
1874
|
+
serializeFormResponsesCsv,
|
|
1875
|
+
storagePortDoc,
|
|
1876
|
+
streamFormResponsesCsv,
|
|
1877
|
+
successState,
|
|
1878
|
+
transportPortDoc
|
|
1879
|
+
};
|