sentinelayer-cli 0.46.4 → 0.48.0
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 +63 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +46 -8
- package/src/auth/service.js +34 -15
- package/src/auth/session-store.js +105 -47
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/auth.js +24 -4
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +607 -57
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +28 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +322 -71
- package/src/session/agent-access.js +137 -0
- package/src/session/agent-registry.js +2 -1
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §EW14 — Live-state hydration: freshness classes, source versioning,
|
|
3
|
+
* revalidation gates, TOCTOU guards, and commerce context.
|
|
4
|
+
*
|
|
5
|
+
* ## Freshness Data Classes
|
|
6
|
+
*
|
|
7
|
+
* Every fact in the ENGRAM graph carries an explicit freshness class:
|
|
8
|
+
* static — immutable facts (e.g. a SHA hash, a contract version)
|
|
9
|
+
* stale_readable — cacheable with a known staleness deadline; reads are
|
|
10
|
+
* marked with an asOf timestamp, never claimed fresh
|
|
11
|
+
* live_required — MUST be fetched from the authoritative source at
|
|
12
|
+
* action time; cached values are insufficient
|
|
13
|
+
* private_session — scoped to a caller session; never shared or cached
|
|
14
|
+
*
|
|
15
|
+
* ## Source Versioning
|
|
16
|
+
*
|
|
17
|
+
* Source adapters carry ETags, content versions, and staleness deadlines.
|
|
18
|
+
* Every fetch records the version it observed. A mismatch at action time
|
|
19
|
+
* triggers re-fetch, never silent substitution.
|
|
20
|
+
*
|
|
21
|
+
* ## TOCTOU Guard
|
|
22
|
+
*
|
|
23
|
+
* Cached graph facts cannot authorize a transaction. Before any
|
|
24
|
+
* authorization-significant action, the caller must revalidate against the
|
|
25
|
+
* live source. The revalidation gate (`mustRevalidate`) enforces this:
|
|
26
|
+
* a graph-level read returns facts with an explicit staleness marker;
|
|
27
|
+
* an authorization path requires a fresh source fetch.
|
|
28
|
+
*
|
|
29
|
+
* ## Source Outage
|
|
30
|
+
*
|
|
31
|
+
* When a source is unavailable, dependent actions are disabled or escalated.
|
|
32
|
+
* No invented substitute facts. The `SourceStatus` enumeration tracks
|
|
33
|
+
* available/degraded/unavailable, and `disabledByOutage` checks whether a
|
|
34
|
+
* requested action is blocked.
|
|
35
|
+
*
|
|
36
|
+
* ## Commerce Context
|
|
37
|
+
*
|
|
38
|
+
* Currency, market, variant, customer eligibility, and quantity are part of
|
|
39
|
+
* the relevant commerce context — never one global price. Every
|
|
40
|
+
* commerce-sensitive operation carries a `CommerceContext` bundle.
|
|
41
|
+
*/
|
|
42
|
+
import { moneySchema } from "./contracts/money.js";
|
|
43
|
+
import { EW05_PREDICATES } from "./compile-graph.js";
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Freshness data classes
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exactly four freshness classes. Every graph fact must be classifiable into
|
|
52
|
+
* one of these, and the class determines what a consumer may do with it.
|
|
53
|
+
*/
|
|
54
|
+
export const FRESHNESS_CLASSES = Object.freeze([
|
|
55
|
+
"static",
|
|
56
|
+
"stale_readable",
|
|
57
|
+
"live_required",
|
|
58
|
+
"private_session",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const FRESHNESS_CLASS_SET = new Set(FRESHNESS_CLASSES);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Classification rules (deterministic, not inferred):
|
|
65
|
+
*
|
|
66
|
+
* static immutable-by-nature facts: contract versions, content
|
|
67
|
+
* hashes, schema identifiers, cryptographic fingerprints.
|
|
68
|
+
* These may be cached indefinitely.
|
|
69
|
+
*
|
|
70
|
+
* stale_readable facts that can be read from cache with a known asOf
|
|
71
|
+
* timestamp and a staleness deadline, but must disclose
|
|
72
|
+
* their age to the consumer. Examples: indexed prices,
|
|
73
|
+
* inventory snapshots, policy documents.
|
|
74
|
+
*
|
|
75
|
+
* live_required facts that MUST be fetched from the authoritative source
|
|
76
|
+
* at action time. Cached values are informative only and
|
|
77
|
+
* cannot authorize. Examples: current inventory count at
|
|
78
|
+
* checkout, payment authorization, delivery estimate for
|
|
79
|
+
* a specific cart.
|
|
80
|
+
*
|
|
81
|
+
* private_session facts scoped to the caller's session: authentication
|
|
82
|
+
* tokens, session-scoped preferences, transient state.
|
|
83
|
+
* Never cached or shared across sessions.
|
|
84
|
+
*
|
|
85
|
+
* @param {{kind: string, sourceKind?: string, isAuth?: boolean, isSessionScoped?: boolean}} params
|
|
86
|
+
* @returns {string} one of FRESHNESS_CLASSES
|
|
87
|
+
*/
|
|
88
|
+
export function classifyFreshness({ kind, sourceKind, isAuth, isSessionScoped } = {}) {
|
|
89
|
+
if (isSessionScoped) return "private_session";
|
|
90
|
+
if (isAuth) return "live_required";
|
|
91
|
+
if (kind === "hash" || kind === "version" || kind === "schema" || kind === "fingerprint") return "static";
|
|
92
|
+
if (sourceKind === "live" || sourceKind === "realtime") return "live_required";
|
|
93
|
+
return "stale_readable";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate that a freshness class string is one of the four canonical values.
|
|
98
|
+
*/
|
|
99
|
+
export function isValidFreshnessClass(value) {
|
|
100
|
+
return FRESHNESS_CLASS_SET.has(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Freshness marker
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A FreshnessMarker records what is known about a fact's age.
|
|
109
|
+
*
|
|
110
|
+
* Rules:
|
|
111
|
+
* - `asOf` is mandatory when `freshnessClass` is `stale_readable` or `live_required`.
|
|
112
|
+
* - Never claim `staleAfterMs = 0` or `isStale = false` on a stale_readable fact
|
|
113
|
+
* without a live revalidation.
|
|
114
|
+
* - `sourceVersion` records the ETag or content version observed at fetch time.
|
|
115
|
+
* - `sourceStatus` records whether the source was available at fetch time.
|
|
116
|
+
*
|
|
117
|
+
* @typedef {{asOf: string, freshnessClass: string, staleAfterMs?: number,
|
|
118
|
+
* sourceVersion?: string, sourceStatus?: string,
|
|
119
|
+
* isStale?: boolean, revalidatedAt?: string}} FreshnessMarker
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a freshness marker with mandatory timestamp and disclosure.
|
|
124
|
+
*
|
|
125
|
+
* @param {{freshnessClass: string, asOf?: string, staleAfterMs?: number,
|
|
126
|
+
* sourceVersion?: string, sourceStatus?: string,
|
|
127
|
+
* isStale?: boolean, revalidatedAt?: string}} params
|
|
128
|
+
* @returns {FreshnessMarker}
|
|
129
|
+
*/
|
|
130
|
+
export function createFreshnessMarker({
|
|
131
|
+
freshnessClass,
|
|
132
|
+
asOf,
|
|
133
|
+
staleAfterMs,
|
|
134
|
+
sourceVersion,
|
|
135
|
+
sourceStatus,
|
|
136
|
+
isStale,
|
|
137
|
+
revalidatedAt,
|
|
138
|
+
} = {}) {
|
|
139
|
+
if (!isValidFreshnessClass(freshnessClass)) {
|
|
140
|
+
throw new FreshnessClassError(
|
|
141
|
+
`invalid freshness class "${freshnessClass}"; must be one of ${FRESHNESS_CLASSES.join(", ")}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const now = asOf || new Date().toISOString();
|
|
146
|
+
|
|
147
|
+
// live_required and stale_readable MUST carry an asOf timestamp
|
|
148
|
+
if ((freshnessClass === "live_required" || freshnessClass === "stale_readable") && !asOf) {
|
|
149
|
+
throw new FreshnessClassError(
|
|
150
|
+
`freshness class "${freshnessClass}" requires an explicit asOf timestamp`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Never claim zero staleness on stale_readable without revalidation
|
|
155
|
+
if (freshnessClass === "stale_readable" && staleAfterMs !== undefined && staleAfterMs <= 0) {
|
|
156
|
+
throw new FreshnessClassError(
|
|
157
|
+
"stale_readable facts cannot claim zero or negative staleness",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (sourceStatus !== undefined && !SOURCE_STATUS_SET.has(sourceStatus)) {
|
|
162
|
+
throw new FreshnessClassError(
|
|
163
|
+
`sourceStatus must be one of ${SOURCE_STATUSES.join(", ")}; got "${sourceStatus}"`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const marker = {
|
|
168
|
+
asOf: now,
|
|
169
|
+
freshnessClass,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (staleAfterMs !== undefined) marker.staleAfterMs = staleAfterMs;
|
|
173
|
+
if (sourceVersion !== undefined) marker.sourceVersion = sourceVersion;
|
|
174
|
+
if (sourceStatus !== undefined) marker.sourceStatus = sourceStatus;
|
|
175
|
+
if (isStale !== undefined) marker.isStale = isStale;
|
|
176
|
+
if (revalidatedAt !== undefined) marker.revalidatedAt = revalidatedAt;
|
|
177
|
+
|
|
178
|
+
return Object.freeze(marker);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Determine whether a freshness marker is currently stale.
|
|
183
|
+
* Returns undefined when staleness cannot be determined (never lie).
|
|
184
|
+
*/
|
|
185
|
+
export function isFreshnessStale(marker, now = new Date()) {
|
|
186
|
+
if (!marker || marker.freshnessClass === "static") return false;
|
|
187
|
+
if (marker.freshnessClass === "live_required" && !marker.revalidatedAt) return true;
|
|
188
|
+
if (marker.isStale === true) return true;
|
|
189
|
+
if (marker.staleAfterMs !== undefined && marker.asOf) {
|
|
190
|
+
const age = now.getTime() - new Date(marker.asOf).getTime();
|
|
191
|
+
if (age > marker.staleAfterMs) return true;
|
|
192
|
+
}
|
|
193
|
+
return undefined; // cannot determine — disclose, don't invent
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Source versioning (ETags, content versions)
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A source version descriptor captured at fetch time.
|
|
202
|
+
*
|
|
203
|
+
* @typedef {{sourceId: string, version: string, etag?: string,
|
|
204
|
+
* fetchedAt: string, tenantScope: string,
|
|
205
|
+
* sourceStatus: string}} SourceVersion
|
|
206
|
+
*/
|
|
207
|
+
|
|
208
|
+
export const SOURCE_STATUSES = Object.freeze([
|
|
209
|
+
"available",
|
|
210
|
+
"degraded",
|
|
211
|
+
"unavailable",
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const SOURCE_STATUS_SET = new Set(SOURCE_STATUSES);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Create a source version record from a fetch result.
|
|
218
|
+
*/
|
|
219
|
+
export function createSourceVersion({
|
|
220
|
+
sourceId,
|
|
221
|
+
version,
|
|
222
|
+
etag,
|
|
223
|
+
fetchedAt,
|
|
224
|
+
tenantScope,
|
|
225
|
+
sourceStatus,
|
|
226
|
+
} = {}) {
|
|
227
|
+
if (!sourceId || typeof sourceId !== "string" || sourceId.length === 0) {
|
|
228
|
+
throw new SourceVersionError("sourceId is required and must be non-empty");
|
|
229
|
+
}
|
|
230
|
+
if (!version || typeof version !== "string" || version.length === 0) {
|
|
231
|
+
throw new SourceVersionError("version is required and must be non-empty");
|
|
232
|
+
}
|
|
233
|
+
if (!fetchedAt) {
|
|
234
|
+
throw new SourceVersionError("fetchedAt is required");
|
|
235
|
+
}
|
|
236
|
+
if (!SOURCE_STATUS_SET.has(sourceStatus)) {
|
|
237
|
+
throw new SourceVersionError(
|
|
238
|
+
`sourceStatus must be one of ${SOURCE_STATUSES.join(", ")}; got "${sourceStatus}"`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const record = {
|
|
243
|
+
sourceId,
|
|
244
|
+
version,
|
|
245
|
+
fetchedAt: typeof fetchedAt === "string" ? fetchedAt : fetchedAt.toISOString(),
|
|
246
|
+
tenantScope: tenantScope || "default",
|
|
247
|
+
sourceStatus,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (etag !== undefined) record.etag = etag;
|
|
251
|
+
|
|
252
|
+
return Object.freeze(record);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Authoritative priority resolution
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve which of two source versions is more authoritative.
|
|
261
|
+
*
|
|
262
|
+
* Priority (highest to lowest):
|
|
263
|
+
* 1. available > degraded > unavailable
|
|
264
|
+
* 2. fresher fetchedAt
|
|
265
|
+
* 3. higher version (string comparison)
|
|
266
|
+
*
|
|
267
|
+
* Returns the more authoritative version, or null if neither is usable.
|
|
268
|
+
*/
|
|
269
|
+
export function resolveAuthoritativeSource(a, b) {
|
|
270
|
+
if (!a || !b) return a || b || null;
|
|
271
|
+
|
|
272
|
+
const authorityRank = { available: 3, degraded: 2, unavailable: 1 };
|
|
273
|
+
const rankA = authorityRank[a.sourceStatus] || 0;
|
|
274
|
+
const rankB = authorityRank[b.sourceStatus] || 0;
|
|
275
|
+
|
|
276
|
+
if (rankA !== rankB) return rankA > rankB ? a : b;
|
|
277
|
+
|
|
278
|
+
const timeA = new Date(a.fetchedAt).getTime();
|
|
279
|
+
const timeB = new Date(b.fetchedAt).getTime();
|
|
280
|
+
if (timeA !== timeB) return timeA > timeB ? a : b;
|
|
281
|
+
|
|
282
|
+
return a.version > b.version ? a : b;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Revalidation gate (TOCTOU guard)
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Determine whether a freshness marker requires revalidation before the
|
|
291
|
+
* fact it describes can authorize an action.
|
|
292
|
+
*
|
|
293
|
+
* Rules:
|
|
294
|
+
* - live_required ALWAYS requires revalidation (cached is never enough)
|
|
295
|
+
* - stale_readable requires revalidation when isStale or past deadline
|
|
296
|
+
* - unavailable source → revalidation is impossible → escalate
|
|
297
|
+
* - static never requires revalidation
|
|
298
|
+
*
|
|
299
|
+
* @returns {{mustRevalidate: boolean, reason?: string, escalate?: boolean}}
|
|
300
|
+
*/
|
|
301
|
+
export function mustRevalidate(marker, now = new Date()) {
|
|
302
|
+
if (!marker) {
|
|
303
|
+
return { mustRevalidate: true, reason: "no freshness marker — cannot authorize without provenance" };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Fail-safe: unknown freshness class must revalidate
|
|
307
|
+
if (!isValidFreshnessClass(marker.freshnessClass)) {
|
|
308
|
+
return { mustRevalidate: true, reason: `unknown freshness class "${marker.freshnessClass}" — refusing to authorize` };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (marker.freshnessClass === "static") {
|
|
312
|
+
return { mustRevalidate: false };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (marker.freshnessClass === "live_required") {
|
|
316
|
+
return { mustRevalidate: true, reason: "live_required facts must be fetched at action time" };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (marker.freshnessClass === "private_session") {
|
|
320
|
+
return { mustRevalidate: false }; // session-scoped; already bound to caller
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// stale_readable
|
|
324
|
+
if (marker.isStale === true) {
|
|
325
|
+
return { mustRevalidate: true, reason: "fact is marked stale" };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (marker.staleAfterMs !== undefined && marker.asOf) {
|
|
329
|
+
const age = now.getTime() - new Date(marker.asOf).getTime();
|
|
330
|
+
if (age > marker.staleAfterMs) {
|
|
331
|
+
return { mustRevalidate: true, reason: `staleness deadline exceeded (age ${age}ms > ${marker.staleAfterMs}ms)` };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Degraded source: allow but disclose reduced confidence
|
|
336
|
+
if (marker.sourceStatus === "degraded") {
|
|
337
|
+
return {
|
|
338
|
+
mustRevalidate: false,
|
|
339
|
+
degraded: true,
|
|
340
|
+
reason: "source is degraded — proceed with reduced confidence",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Source outage: cannot revalidate → escalate
|
|
345
|
+
if (marker.sourceStatus === "unavailable") {
|
|
346
|
+
return {
|
|
347
|
+
mustRevalidate: true,
|
|
348
|
+
reason: "source is unavailable — cannot revalidate; escalate to operator",
|
|
349
|
+
escalate: true,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// No staleness signal → must disclose but not block
|
|
354
|
+
return { mustRevalidate: false };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Source outage handling
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
/** How bad a source status is; a status outside SOURCE_STATUSES is the worst. */
|
|
362
|
+
const SOURCE_STATUS_RANK = new Map([["available", 0], ["degraded", 1], ["unavailable", 2]]);
|
|
363
|
+
const statusRank = (status) => SOURCE_STATUS_RANK.get(status) ?? 3;
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Check whether a source outage disables a dependent action.
|
|
367
|
+
*
|
|
368
|
+
* When a source is unavailable, dependent actions are disabled.
|
|
369
|
+
* Degraded sources allow actions but with reduced confidence.
|
|
370
|
+
* No invented substitute facts.
|
|
371
|
+
*
|
|
372
|
+
* Every dependency is checked before a degraded one is reported, so one
|
|
373
|
+
* unavailable source disables the action wherever it sits in the list. A
|
|
374
|
+
* status outside SOURCE_STATUSES cannot be verified and disables it too.
|
|
375
|
+
* When several records name one source, the worst status counts (unknown,
|
|
376
|
+
* then unavailable, then degraded, then available), so the result never
|
|
377
|
+
* depends on their order.
|
|
378
|
+
*/
|
|
379
|
+
export function disabledByOutage(sourceVersions, actionDependencies) {
|
|
380
|
+
if (!actionDependencies || actionDependencies.length === 0) return { disabled: false };
|
|
381
|
+
|
|
382
|
+
const sources = new Map();
|
|
383
|
+
for (const sv of sourceVersions || []) {
|
|
384
|
+
if (!sv || !sv.sourceId) continue;
|
|
385
|
+
const recorded = sources.get(sv.sourceId);
|
|
386
|
+
if (!recorded || statusRank(sv.sourceStatus) > statusRank(recorded.sourceStatus)) {
|
|
387
|
+
sources.set(sv.sourceId, sv);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let degraded = null;
|
|
392
|
+
for (const dep of actionDependencies) {
|
|
393
|
+
const sv = sources.get(dep);
|
|
394
|
+
if (!sv) {
|
|
395
|
+
return { disabled: true, reason: `source "${dep}" has no recorded version — cannot verify availability` };
|
|
396
|
+
}
|
|
397
|
+
if (sv.sourceStatus === "unavailable") {
|
|
398
|
+
return { disabled: true, reason: `source "${dep}" is unavailable` };
|
|
399
|
+
}
|
|
400
|
+
if (sv.sourceStatus === "degraded") {
|
|
401
|
+
degraded ??= { disabled: false, degraded: true, reason: `source "${dep}" is degraded — proceed with caution` };
|
|
402
|
+
} else if (sv.sourceStatus !== "available") {
|
|
403
|
+
return {
|
|
404
|
+
disabled: true,
|
|
405
|
+
reason: `source "${dep}" has unknown status "${sv.sourceStatus}" — cannot verify availability`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return degraded ?? { disabled: false };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Commerce context (currency / market / variant / customer eligibility / quantity)
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* A CommerceContext bundles the commerce-relevant dimensions for a single
|
|
419
|
+
* operation. Every operation that touches price, inventory, or eligibility
|
|
420
|
+
* carries its own context — none of these fields is global.
|
|
421
|
+
*
|
|
422
|
+
* Rules:
|
|
423
|
+
* - No fields are global — every commerce operation carries its own bundle.
|
|
424
|
+
* - An absent field means "not applicable to this operation" or "not
|
|
425
|
+
* provided"; it is never silently defaulted to a global value.
|
|
426
|
+
*/
|
|
427
|
+
export function createCommerceContext({
|
|
428
|
+
currency,
|
|
429
|
+
market,
|
|
430
|
+
variant,
|
|
431
|
+
customerEligibility,
|
|
432
|
+
quantity,
|
|
433
|
+
priceContext,
|
|
434
|
+
} = {}) {
|
|
435
|
+
const ctx = {};
|
|
436
|
+
|
|
437
|
+
if (currency !== undefined) {
|
|
438
|
+
if (typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) {
|
|
439
|
+
throw new CommerceContextError(`invalid currency code "${currency}"`);
|
|
440
|
+
}
|
|
441
|
+
ctx.currency = currency;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (market !== undefined) {
|
|
445
|
+
if (typeof market !== "string" || market.length === 0) {
|
|
446
|
+
throw new CommerceContextError("market must be a non-empty string");
|
|
447
|
+
}
|
|
448
|
+
ctx.market = market;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (variant !== undefined) {
|
|
452
|
+
if (typeof variant !== "string" || variant.length === 0) {
|
|
453
|
+
throw new CommerceContextError("variant must be a non-empty string");
|
|
454
|
+
}
|
|
455
|
+
ctx.variant = variant;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (customerEligibility !== undefined) {
|
|
459
|
+
if (typeof customerEligibility !== "string") {
|
|
460
|
+
throw new CommerceContextError("customerEligibility must be a string");
|
|
461
|
+
}
|
|
462
|
+
ctx.customerEligibility = customerEligibility;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (quantity !== undefined) {
|
|
466
|
+
if (typeof quantity !== "number" || !Number.isSafeInteger(quantity) || quantity < 0) {
|
|
467
|
+
throw new CommerceContextError("quantity must be a non-negative safe integer");
|
|
468
|
+
}
|
|
469
|
+
ctx.quantity = quantity;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (priceContext !== undefined) {
|
|
473
|
+
if (typeof priceContext !== "object" || priceContext === null) {
|
|
474
|
+
throw new CommerceContextError("priceContext must be an object");
|
|
475
|
+
}
|
|
476
|
+
ctx.priceContext = priceContext;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return Object.freeze(ctx);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// Error types
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
export class FreshnessClassError extends Error {
|
|
487
|
+
constructor(message) {
|
|
488
|
+
super(message);
|
|
489
|
+
this.name = "FreshnessClassError";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export class SourceVersionError extends Error {
|
|
494
|
+
constructor(message) {
|
|
495
|
+
super(message);
|
|
496
|
+
this.name = "SourceVersionError";
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export class RevalidationRequiredError extends Error {
|
|
501
|
+
constructor(message) {
|
|
502
|
+
super(message);
|
|
503
|
+
this.name = "RevalidationRequiredError";
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export class SourceOutageError extends Error {
|
|
508
|
+
constructor(message) {
|
|
509
|
+
super(message);
|
|
510
|
+
this.name = "SourceOutageError";
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export class CommerceContextError extends Error {
|
|
515
|
+
constructor(message) {
|
|
516
|
+
super(message);
|
|
517
|
+
this.name = "CommerceContextError";
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Live-source adapter interface (contract only — adapter is injected)
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* A live-source adapter MUST fulfil this interface. No concrete implementation
|
|
527
|
+
* is provided here; callers inject an adapter per authoritative source.
|
|
528
|
+
*
|
|
529
|
+
* fetchLive(commerceContext, entityIds, fieldNames) → Promise<Map<string, Map<string, any>>>
|
|
530
|
+
*
|
|
531
|
+
* - commerceContext: a CommerceContext (created by createCommerceContext)
|
|
532
|
+
* - entityIds: string[] of entity identifiers (e.g. ["product:1", "product:2"])
|
|
533
|
+
* - fieldNames: string[] of field names to fetch (e.g. ["price", "inventory"])
|
|
534
|
+
* - Returns: Map keyed by entityId → Map keyed by field name → live value
|
|
535
|
+
* Entities or fields that are unknown or unavailable are simply absent from the Maps.
|
|
536
|
+
*
|
|
537
|
+
* sourceId: string — identifies the live source (e.g. "catalog:us-east-1")
|
|
538
|
+
*
|
|
539
|
+
* sourceStatus(): "available" | "degraded" | "unavailable"
|
|
540
|
+
*
|
|
541
|
+
* Adapters may throw on transport failure; callers MUST handle that.
|
|
542
|
+
*/
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Validate that an object satisfies the minimal live-source adapter shape.
|
|
546
|
+
* Returns an array of missing/invalid members (empty = valid).
|
|
547
|
+
*/
|
|
548
|
+
export function validateAdapterShape(adapter) {
|
|
549
|
+
const issues = [];
|
|
550
|
+
if (!adapter || typeof adapter !== "object") {
|
|
551
|
+
return ["adapter must be an object"];
|
|
552
|
+
}
|
|
553
|
+
if (typeof adapter.fetchLive !== "function") {
|
|
554
|
+
issues.push("adapter.fetchLive must be a function");
|
|
555
|
+
}
|
|
556
|
+
if (typeof adapter.sourceId !== "string" || adapter.sourceId.length === 0) {
|
|
557
|
+
issues.push("adapter.sourceId must be a non-empty string");
|
|
558
|
+
}
|
|
559
|
+
if (typeof adapter.sourceStatus !== "function") {
|
|
560
|
+
issues.push("adapter.sourceStatus must be a function");
|
|
561
|
+
}
|
|
562
|
+
return issues;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
// Volatile values: one rule for what hydrate fetches, merges and clears
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Check whether a value is a canonical money object per moneySchema.
|
|
571
|
+
*/
|
|
572
|
+
function isMoneyValue(value) {
|
|
573
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
574
|
+
// moneySchema requires amountUnit: an object without it is not parsed, which
|
|
575
|
+
// keeps the walk over a large graph cheap.
|
|
576
|
+
if (!("amountUnit" in value)) return false;
|
|
577
|
+
return moneySchema.safeParse(value).success;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** True for a list that holds a money value, directly or in nested lists. */
|
|
581
|
+
function holdsMoneyInList(value) {
|
|
582
|
+
return Array.isArray(value) && value.some((item) => isMoneyValue(item) || holdsMoneyInList(item));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* True when a value found under `key` is one volatile node: the key names a
|
|
587
|
+
* volatile field (isVolatileField), the value is canonical money
|
|
588
|
+
* (moneySchema), or the value is a list holding money. Such a list is one
|
|
589
|
+
* node, fetched, merged and cleared whole: clearing its money items one by one
|
|
590
|
+
* would shift the items after them, and a live value written by path would
|
|
591
|
+
* land at another item's index.
|
|
592
|
+
*/
|
|
593
|
+
function isVolatileNode(key, value) {
|
|
594
|
+
return isVolatileField(key) || isMoneyValue(value) || holdsMoneyInList(value);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Returned by a mapVolatileNodes callback to drop the node it was given. */
|
|
598
|
+
const OMIT = Symbol("omit");
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Rebuild a JSON value with every volatile node replaced by
|
|
602
|
+
* replace(path, node); a node replaced by OMIT is dropped from its object.
|
|
603
|
+
*
|
|
604
|
+
* The rule (isVolatileNode) holds at ANY depth, so it covers a price inside
|
|
605
|
+
* offers or a variant offer, a priceSpecification on an offer or on the
|
|
606
|
+
* product itself, an AggregateOffer's lowPrice and highPrice, the prices of a
|
|
607
|
+
* top-level Offer, AggregateOffer or UnitPriceSpecification entity, and the
|
|
608
|
+
* per-source copies the compiler keeps when two pages describe one product.
|
|
609
|
+
* The root (path null) is never a volatile node itself. A volatile node is
|
|
610
|
+
* handed over whole and not walked into, so no volatile path is a prefix of
|
|
611
|
+
* another.
|
|
612
|
+
*
|
|
613
|
+
* Paths are the keys adapters use for live values: "price", "offers.price",
|
|
614
|
+
* "offers[0].priceSpecification.price". Below the root, a list item is never
|
|
615
|
+
* a volatile node (a list holding money is one node), so lists keep their
|
|
616
|
+
* length and every item its index.
|
|
617
|
+
*/
|
|
618
|
+
function mapVolatileNodes(value, path, key, replace) {
|
|
619
|
+
if (path !== null && isVolatileNode(key, value)) return replace(path, value);
|
|
620
|
+
if (Array.isArray(value)) {
|
|
621
|
+
return value.map((item, i) => {
|
|
622
|
+
const next = mapVolatileNodes(item, `${path ?? ""}[${i}]`, undefined, replace);
|
|
623
|
+
return next === OMIT ? null : next; // only an item of a root list can be dropped
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
if (value !== null && typeof value === "object") {
|
|
627
|
+
const fields = [];
|
|
628
|
+
for (const [childKey, child] of Object.entries(value)) {
|
|
629
|
+
const childPath = path === null ? childKey : `${path}.${childKey}`;
|
|
630
|
+
const next = mapVolatileNodes(child, childPath, childKey, replace);
|
|
631
|
+
if (next !== OMIT) fields.push([childKey, next]);
|
|
632
|
+
}
|
|
633
|
+
return Object.fromEntries(fields);
|
|
634
|
+
}
|
|
635
|
+
return value;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** The volatile paths of an entity: the fields hydrate asks its source for. */
|
|
639
|
+
function volatileFieldsForEntity(entity) {
|
|
640
|
+
const paths = new Set();
|
|
641
|
+
mapVolatileNodes(entity?.attributes, null, undefined, (path, node) => {
|
|
642
|
+
paths.add(path);
|
|
643
|
+
return node;
|
|
644
|
+
});
|
|
645
|
+
return paths;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** True when a JSON value is, or holds at any depth, a volatile value. */
|
|
649
|
+
function holdsVolatileValue(value) {
|
|
650
|
+
let found = isVolatileNode(undefined, value);
|
|
651
|
+
mapVolatileNodes(value, null, undefined, (_path, node) => {
|
|
652
|
+
found = true;
|
|
653
|
+
return node;
|
|
654
|
+
});
|
|
655
|
+
return found;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* An entity with each volatile path holding the value the source returned for
|
|
660
|
+
* that exact path, and every volatile path the source did not return removed.
|
|
661
|
+
* Only the entity's own volatile paths are ever written: an adapter cannot add
|
|
662
|
+
* a field, grow an offers array or overwrite a stable fact. Each path written
|
|
663
|
+
* is added to liveFields.
|
|
664
|
+
*/
|
|
665
|
+
function overlayLiveValues(entity, live, liveFields) {
|
|
666
|
+
if (!entity?.attributes) return entity;
|
|
667
|
+
// Attributes that are themselves a volatile node (a money value, or a list
|
|
668
|
+
// holding money) have no path to ask the source for, so they are never served.
|
|
669
|
+
if (isVolatileNode(undefined, entity.attributes)) return { ...entity, attributes: {} };
|
|
670
|
+
const attributes = mapVolatileNodes(entity.attributes, null, undefined, (path) => {
|
|
671
|
+
const value = live.get(path);
|
|
672
|
+
if (value === undefined) return OMIT;
|
|
673
|
+
liveFields.add(path);
|
|
674
|
+
return value;
|
|
675
|
+
});
|
|
676
|
+
return { ...entity, attributes };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Merge live values (per-entity Map<entityId, Map<path, value>>) into entity
|
|
681
|
+
* attributes. Each entity gets the values for its own volatile paths only; a
|
|
682
|
+
* volatile path with no live value is removed, and on an outage every one is.
|
|
683
|
+
*
|
|
684
|
+
* Returns the hydrated entities and liveFields: the paths that hold a live
|
|
685
|
+
* value in them.
|
|
686
|
+
*/
|
|
687
|
+
function mergeLiveValues(entities, liveValues, sourceStatus) {
|
|
688
|
+
const liveFields = new Set();
|
|
689
|
+
const hydratedEntities = entities.map((entity) => {
|
|
690
|
+
// On outage nothing is live: never serve cached values
|
|
691
|
+
const live = sourceStatus === "unavailable" ? undefined : liveValues.get(entity?.id);
|
|
692
|
+
return overlayLiveValues(entity, live ?? new Map(), liveFields);
|
|
693
|
+
});
|
|
694
|
+
return { hydratedEntities, liveFields };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// Cached volatile values outside entity attributes
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
/** The compiler states attribute <field> of an entity as describes_<field>. */
|
|
702
|
+
const ATTRIBUTE_PREDICATE_PREFIX = `${EW05_PREDICATES.DESCRIBES}_`;
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* True when an assertion carries a cached volatile value: its object is or
|
|
706
|
+
* holds one, or its predicate names a volatile field, by name
|
|
707
|
+
* (describes_price, describes_availability) or because that field of its
|
|
708
|
+
* subject is volatile (describes_lowPrice where lowPrice is money).
|
|
709
|
+
*/
|
|
710
|
+
function isVolatileAssertion(assertion, volatileFieldsBySubject) {
|
|
711
|
+
const predicate = typeof assertion?.predicate === "string" ? assertion.predicate : "";
|
|
712
|
+
const field = predicate.startsWith(ATTRIBUTE_PREDICATE_PREFIX)
|
|
713
|
+
? predicate.slice(ATTRIBUTE_PREDICATE_PREFIX.length)
|
|
714
|
+
: predicate;
|
|
715
|
+
return isVolatileField(field) ||
|
|
716
|
+
volatileFieldsBySubject.get(assertion?.subject)?.has(field) === true ||
|
|
717
|
+
holdsVolatileValue(assertion?.object);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Coverage counts for the assertions hydrate returns. */
|
|
721
|
+
function recountCoverage(coverage, assertions) {
|
|
722
|
+
const countBy = (field) => {
|
|
723
|
+
const counts = {};
|
|
724
|
+
for (const assertion of assertions) {
|
|
725
|
+
const value = assertion?.[field];
|
|
726
|
+
if (value !== undefined) counts[value] = (counts[value] ?? 0) + 1;
|
|
727
|
+
}
|
|
728
|
+
return counts;
|
|
729
|
+
};
|
|
730
|
+
const recounted = { ...coverage, totalAssertions: assertions.length };
|
|
731
|
+
if (coverage.byProvenance) recounted.byProvenance = countBy("provenance");
|
|
732
|
+
if (coverage.bySchemaValidity) recounted.bySchemaValidity = countBy("schemaValidity");
|
|
733
|
+
return recounted;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The graph hydrate returns: the hydrated entities, and no cached volatile
|
|
738
|
+
* value anywhere else in the published graph.
|
|
739
|
+
*
|
|
740
|
+
* Assertions. The compiler copies each attribute of an entity into a
|
|
741
|
+
* describes_<field> assertion, so a published graph also holds cached prices
|
|
742
|
+
* there (describes_price, describes_lowPrice, ...). Such an assertion is
|
|
743
|
+
* removed, not rewritten with the live value. An assertion states what one
|
|
744
|
+
* snapshot said: source.version and source.capturedAt name that snapshot, and
|
|
745
|
+
* the graph contract rejects a fact captured after graph.generatedAt. A live
|
|
746
|
+
* value written into it would either keep the snapshot's provenance, which is
|
|
747
|
+
* false, or carry its own fetch time, which the contract rejects; and a live
|
|
748
|
+
* value that is not canonical money would fail the contract's check on
|
|
749
|
+
* monetary predicates. The live value is on the entity, at its own path.
|
|
750
|
+
* coverage is recounted, so the graph stays valid under its contract.
|
|
751
|
+
*
|
|
752
|
+
* Links. A link is kept only when both its ends are in the returned graph: a
|
|
753
|
+
* returned entity or a kept assertion. Otherwise a removed assertion, or an
|
|
754
|
+
* entity left out by entityIds, would leave it dangling.
|
|
755
|
+
*
|
|
756
|
+
* Unresolved facts. A rawValue is a value the compiler could not type: a
|
|
757
|
+
* price with no currency, an amount it could not read, or an array recorded
|
|
758
|
+
* whole, which can hold prices under any field name. hydrate cannot tell which
|
|
759
|
+
* raw values are prices and never asks the source for them, so it returns
|
|
760
|
+
* each record without its rawValue; field, path and reason still disclose the
|
|
761
|
+
* gap.
|
|
762
|
+
*/
|
|
763
|
+
function withoutCachedVolatiles(graph, entities) {
|
|
764
|
+
const hydrated = { ...graph, entities };
|
|
765
|
+
|
|
766
|
+
const volatileFieldsBySubject = new Map();
|
|
767
|
+
for (const entity of graph.entities ?? []) {
|
|
768
|
+
if (entity) volatileFieldsBySubject.set(entity.id, volatileFieldsForEntity(entity));
|
|
769
|
+
}
|
|
770
|
+
const assertions = Array.isArray(graph.assertions) ? graph.assertions : [];
|
|
771
|
+
const kept = assertions.filter((assertion) => !isVolatileAssertion(assertion, volatileFieldsBySubject));
|
|
772
|
+
if (kept.length < assertions.length) {
|
|
773
|
+
hydrated.assertions = kept;
|
|
774
|
+
if (graph.coverage) hydrated.coverage = recountCoverage(graph.coverage, kept);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (Array.isArray(graph.links)) {
|
|
778
|
+
const nodes = new Set([...entities, ...kept].map((node) => node?.id));
|
|
779
|
+
hydrated.links = graph.links.filter((link) => nodes.has(link?.source) && nodes.has(link?.target));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (Array.isArray(graph.unresolvedFacts)) {
|
|
783
|
+
hydrated.unresolvedFacts = graph.unresolvedFacts.map((fact) => {
|
|
784
|
+
if (!fact || typeof fact !== "object") return fact;
|
|
785
|
+
const { rawValue: _cached, ...record } = fact;
|
|
786
|
+
return record;
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return hydrated;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// Adapter results, checked before they are trusted
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* A live source's status. A missing adapter, a status check that throws and
|
|
798
|
+
* a status outside SOURCE_STATUSES all read as "unavailable": hydrate and
|
|
799
|
+
* revalidateForAction fail closed rather than trust them.
|
|
800
|
+
*/
|
|
801
|
+
function liveSourceStatus(adapter) {
|
|
802
|
+
if (!adapter || typeof adapter.sourceStatus !== "function") return "unavailable";
|
|
803
|
+
try {
|
|
804
|
+
const status = adapter.sourceStatus();
|
|
805
|
+
return SOURCE_STATUS_SET.has(status) ? status : "unavailable";
|
|
806
|
+
} catch {
|
|
807
|
+
return "unavailable";
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** True for a Map whose values are all Maps: Map<entityId, Map<path, value>>. */
|
|
812
|
+
function isMapOfMaps(value) {
|
|
813
|
+
if (!(value instanceof Map)) return false;
|
|
814
|
+
for (const inner of value.values()) {
|
|
815
|
+
if (!(inner instanceof Map)) return false;
|
|
816
|
+
}
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** True for a record shaped like createSourceVersion's output. */
|
|
821
|
+
function isSourceVersionRecord(record) {
|
|
822
|
+
if (record === null || typeof record !== "object") return false;
|
|
823
|
+
const named = [record.sourceId, record.version, record.fetchedAt];
|
|
824
|
+
return named.every((field) => typeof field === "string" && field.length > 0) &&
|
|
825
|
+
SOURCE_STATUS_SET.has(record.sourceStatus);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// hydrate — read graph through graph-store, overlay live values per entity
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Hydrate the caller's view of the published graph.
|
|
834
|
+
*
|
|
835
|
+
* Reads the graph through graph-store and resolves every entity whose
|
|
836
|
+
* freshness class is `live_required` against the live-source adapter.
|
|
837
|
+
* Each entity gets its own live values — never a single shared value.
|
|
838
|
+
*
|
|
839
|
+
* A volatile value (a field named in VOLATILE_FIELD_NAMES in any spelling,
|
|
840
|
+
* canonical money, or a list holding money, at any depth of an entity's
|
|
841
|
+
* attributes) comes only from the live source. A volatile path the source
|
|
842
|
+
* does not return is CLEARED; on an outage, a transport failure, a malformed
|
|
843
|
+
* response or with no adapter every one is (never served from cache), and
|
|
844
|
+
* the caller sees sourceStatus="unavailable". Assertions that carry a
|
|
845
|
+
* volatile value and the raw values of unresolved facts are never returned
|
|
846
|
+
* (see withoutCachedVolatiles).
|
|
847
|
+
*
|
|
848
|
+
* Optional `entityIds` and `fieldNames` (top-level attribute names) filter
|
|
849
|
+
* the returned graph to the requested entities and fields, with or without
|
|
850
|
+
* an adapter; the source is asked only for the volatile paths inside them.
|
|
851
|
+
*
|
|
852
|
+
* Returns:
|
|
853
|
+
* { graph, version, publishedAt, hydratedAt, liveFields: string[],
|
|
854
|
+
* sourceStatus: "available"|"degraded"|"unavailable" }
|
|
855
|
+
*
|
|
856
|
+
* liveFields lists the volatile paths that hold a live value in the
|
|
857
|
+
* returned graph, so it is empty whenever nothing came back from the source.
|
|
858
|
+
*/
|
|
859
|
+
export async function hydrate(
|
|
860
|
+
caller,
|
|
861
|
+
namespace,
|
|
862
|
+
graphStore,
|
|
863
|
+
liveSourceAdapter,
|
|
864
|
+
commerceContext,
|
|
865
|
+
{ entityIds, fieldNames } = {},
|
|
866
|
+
) {
|
|
867
|
+
// Read the currently published graph
|
|
868
|
+
const result = await graphStore.readCurrentGraph(caller, namespace);
|
|
869
|
+
|
|
870
|
+
if (!result || result.graph === null) {
|
|
871
|
+
return {
|
|
872
|
+
graph: null,
|
|
873
|
+
version: result?.version ?? 0,
|
|
874
|
+
reason: result?.reason ?? "no graph published",
|
|
875
|
+
hydratedAt: new Date().toISOString(),
|
|
876
|
+
liveFields: [],
|
|
877
|
+
sourceStatus: "unavailable",
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const { graph, version, publishedAt } = result;
|
|
882
|
+
|
|
883
|
+
// The caller's view: the requested entities and top-level fields
|
|
884
|
+
let entities = graph.entities ?? [];
|
|
885
|
+
if (entityIds && entityIds.length > 0) {
|
|
886
|
+
const idSet = new Set(entityIds);
|
|
887
|
+
entities = entities.filter((entity) => idSet.has(entity?.id));
|
|
888
|
+
}
|
|
889
|
+
if (fieldNames && fieldNames.length > 0) {
|
|
890
|
+
const fieldSet = new Set(fieldNames);
|
|
891
|
+
entities = entities.map((entity) => {
|
|
892
|
+
if (!entity?.attributes) return entity;
|
|
893
|
+
// Attributes that are themselves a volatile node are one value, never
|
|
894
|
+
// served: a slice of them (a money object's amount alone) would no
|
|
895
|
+
// longer look volatile.
|
|
896
|
+
if (isVolatileNode(undefined, entity.attributes)) return { ...entity, attributes: {} };
|
|
897
|
+
const attributes = Object.fromEntries(Object.entries(entity.attributes).filter(([key]) => fieldSet.has(key)));
|
|
898
|
+
return { ...entity, attributes };
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Ask the source for the volatile paths in that view only. Assertions are
|
|
903
|
+
// not hydrated (withoutCachedVolatiles removes the ones that carry a
|
|
904
|
+
// volatile value), so it is asked for entity fields only.
|
|
905
|
+
const ids = entities.map((entity) => entity?.id).filter(Boolean);
|
|
906
|
+
const requested = new Set();
|
|
907
|
+
for (const entity of entities) {
|
|
908
|
+
for (const path of volatileFieldsForEntity(entity)) requested.add(path);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
let sourceStatus = liveSourceStatus(liveSourceAdapter);
|
|
912
|
+
let liveValues = new Map();
|
|
913
|
+
if (sourceStatus !== "unavailable" && requested.size > 0 && ids.length > 0) {
|
|
914
|
+
try {
|
|
915
|
+
const response = await liveSourceAdapter.fetchLive(commerceContext ?? {}, ids, [...requested]);
|
|
916
|
+
// A response outside the adapter contract is a failed fetch
|
|
917
|
+
if (isMapOfMaps(response)) liveValues = response;
|
|
918
|
+
else sourceStatus = "unavailable";
|
|
919
|
+
} catch {
|
|
920
|
+
// Transport failure → treat as unavailable for this hydration
|
|
921
|
+
sourceStatus = "unavailable";
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Merge per-entity live values (or clear volatiles on outage)
|
|
926
|
+
const { hydratedEntities, liveFields } = mergeLiveValues(entities, liveValues, sourceStatus);
|
|
927
|
+
|
|
928
|
+
return {
|
|
929
|
+
graph: withoutCachedVolatiles(graph, hydratedEntities),
|
|
930
|
+
version,
|
|
931
|
+
publishedAt,
|
|
932
|
+
hydratedAt: new Date().toISOString(),
|
|
933
|
+
liveFields: [...liveFields],
|
|
934
|
+
sourceStatus,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
// revalidateForAction — TOCTOU re-check before authorisation
|
|
940
|
+
// ---------------------------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Revalidate live_required fields against the live source before an
|
|
944
|
+
* authorization-significant action.
|
|
945
|
+
*
|
|
946
|
+
* Accepts `expectedVersions`: a Map<entityId, Map<fieldName, version>> that
|
|
947
|
+
* records what the caller saw. Compares live values against THESE versions,
|
|
948
|
+
* not against cached graph values. If the caller's version differs from live,
|
|
949
|
+
* the action MUST be re-attempted with the new values. Every expected field
|
|
950
|
+
* must be a volatile field of its entity in the published graph, checked
|
|
951
|
+
* before the source is called: the source is asked for the graph's own
|
|
952
|
+
* volatile fields only, so a caller cannot widen the live query.
|
|
953
|
+
*
|
|
954
|
+
* `actionDependencies` (optional): SourceVersion records (createSourceVersion)
|
|
955
|
+
* for the other sources the action depends on, as the caller last observed
|
|
956
|
+
* them. One that is unavailable blocks the action (disabledByOutage), and so
|
|
957
|
+
* does anything that is not such a record: a non-empty sourceId, version and
|
|
958
|
+
* fetchedAt, and a status in SOURCE_STATUSES. With two records for one
|
|
959
|
+
* source the worse status counts.
|
|
960
|
+
*
|
|
961
|
+
* Reduced confidence: when the live source or a dependency is degraded the
|
|
962
|
+
* action may go ahead, and the result says so with degraded: true and a
|
|
963
|
+
* reason naming each degraded source (sourceStatus alone reports only the
|
|
964
|
+
* live source).
|
|
965
|
+
*
|
|
966
|
+
* Empty or missing expectedVersions: nothing is compared, so the result is
|
|
967
|
+
* ok:true with an empty changedFields, meaning the action relied on no
|
|
968
|
+
* volatile value. The source is still asked, so an outage, a failed fetch or
|
|
969
|
+
* an unavailable dependency still gives ok:false. A caller that showed a
|
|
970
|
+
* volatile value must pass it here.
|
|
971
|
+
*
|
|
972
|
+
* Returns:
|
|
973
|
+
* { ok: boolean, mustRetry: boolean, changedFields: Map<string, {callerVersion, live}>,
|
|
974
|
+
* sourceStatus: string, revalidatedAt: string, degraded?: true, reason?: string }
|
|
975
|
+
*
|
|
976
|
+
* When ok=false the action is blocked (no graph, the source unavailable or
|
|
977
|
+
* misbehaving, a missing live value, an unavailable dependency, ...); a
|
|
978
|
+
* missing adapter or a malformed argument or response blocks it too, nothing
|
|
979
|
+
* is thrown. When mustRetry=true the action must be re-attempted with the
|
|
980
|
+
* live values.
|
|
981
|
+
*/
|
|
982
|
+
export async function revalidateForAction(
|
|
983
|
+
caller,
|
|
984
|
+
namespace,
|
|
985
|
+
graphStore,
|
|
986
|
+
liveSourceAdapter,
|
|
987
|
+
commerceContext,
|
|
988
|
+
actionDependencies,
|
|
989
|
+
expectedVersions,
|
|
990
|
+
) {
|
|
991
|
+
const revalidatedAt = new Date().toISOString();
|
|
992
|
+
const blocked = (reason, sourceStatus) => ({
|
|
993
|
+
ok: false,
|
|
994
|
+
mustRetry: false,
|
|
995
|
+
reason,
|
|
996
|
+
...(sourceStatus ? { sourceStatus } : {}),
|
|
997
|
+
revalidatedAt,
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// Read current graph
|
|
1001
|
+
const result = await graphStore.readCurrentGraph(caller, namespace);
|
|
1002
|
+
if (!result || result.graph === null) return blocked(result?.reason ?? "no graph published");
|
|
1003
|
+
|
|
1004
|
+
const { graph } = result;
|
|
1005
|
+
const entities = graph.entities ?? [];
|
|
1006
|
+
|
|
1007
|
+
// Collect live_required fields per entity (including offers)
|
|
1008
|
+
const allLiveFieldNames = new Set();
|
|
1009
|
+
const ids = [];
|
|
1010
|
+
for (const entity of entities) {
|
|
1011
|
+
if (!entity) continue;
|
|
1012
|
+
if (entity.id) ids.push(entity.id);
|
|
1013
|
+
for (const fn of volatileFieldsForEntity(entity)) {
|
|
1014
|
+
allLiveFieldNames.add(fn);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Check source outage; a missing or misbehaving adapter is one
|
|
1019
|
+
const sourceStatus = liveSourceStatus(liveSourceAdapter);
|
|
1020
|
+
if (sourceStatus === "unavailable") return blocked("live source unavailable — cannot revalidate", sourceStatus);
|
|
1021
|
+
|
|
1022
|
+
// Check the other sources the action depends on
|
|
1023
|
+
let degradedDependency = null;
|
|
1024
|
+
if (actionDependencies !== undefined && actionDependencies !== null) {
|
|
1025
|
+
if (!Array.isArray(actionDependencies) || !actionDependencies.every(isSourceVersionRecord)) {
|
|
1026
|
+
return blocked("actionDependencies must be SourceVersion records (createSourceVersion)", sourceStatus);
|
|
1027
|
+
}
|
|
1028
|
+
const outage = disabledByOutage(actionDependencies, actionDependencies.map((record) => record.sourceId));
|
|
1029
|
+
if (outage.disabled) return blocked(outage.reason, sourceStatus);
|
|
1030
|
+
if (outage.degraded) degradedDependency = outage.reason;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Walk expectedVersions before the source is called: an entity no longer in
|
|
1034
|
+
// the graph, or a field outside its volatile set, is refused here.
|
|
1035
|
+
if (expectedVersions !== undefined && expectedVersions !== null && !isMapOfMaps(expectedVersions)) {
|
|
1036
|
+
return blocked("expectedVersions must be a Map<entityId, Map<field, version>>", sourceStatus);
|
|
1037
|
+
}
|
|
1038
|
+
const ev = expectedVersions ?? new Map();
|
|
1039
|
+
const entityMap = new Map(entities.filter((e) => e?.id).map((e) => [e.id, e]));
|
|
1040
|
+
for (const [entityId, expectedFields] of ev) {
|
|
1041
|
+
if (!entityMap.has(entityId)) return blocked(`entity ${entityId} is no longer in the graph`, sourceStatus);
|
|
1042
|
+
const entityVolatiles = volatileFieldsForEntity(entityMap.get(entityId));
|
|
1043
|
+
for (const field of expectedFields.keys()) {
|
|
1044
|
+
if (!entityVolatiles.has(field)) {
|
|
1045
|
+
return blocked(`entity ${entityId}: field ${field} is not a volatile field of this entity`, sourceStatus);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Fetch the graph's own volatile fields
|
|
1051
|
+
let liveValues;
|
|
1052
|
+
try {
|
|
1053
|
+
liveValues = await liveSourceAdapter.fetchLive(
|
|
1054
|
+
commerceContext ?? {},
|
|
1055
|
+
ids,
|
|
1056
|
+
[...allLiveFieldNames],
|
|
1057
|
+
);
|
|
1058
|
+
} catch {
|
|
1059
|
+
return blocked("live source fetch failed — cannot revalidate", "unavailable");
|
|
1060
|
+
}
|
|
1061
|
+
if (!isMapOfMaps(liveValues)) {
|
|
1062
|
+
return blocked("live source returned a malformed response — cannot revalidate", "unavailable");
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Fail closed: every expected field needs a live value
|
|
1066
|
+
const changedFields = new Map();
|
|
1067
|
+
for (const [entityId, perEntityExpected] of ev) {
|
|
1068
|
+
const perEntityLive = liveValues.get(entityId);
|
|
1069
|
+
|
|
1070
|
+
for (const [field, expectedVersion] of perEntityExpected) {
|
|
1071
|
+
if (!perEntityLive || perEntityLive.size === 0) {
|
|
1072
|
+
return blocked(`entity ${entityId}: no live values returned for expected field ${field}`, sourceStatus);
|
|
1073
|
+
}
|
|
1074
|
+
if (!perEntityLive.has(field)) {
|
|
1075
|
+
return blocked(`entity ${entityId}: expected field ${field} has no live value`, sourceStatus);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const liveVal = perEntityLive.get(field);
|
|
1079
|
+
|
|
1080
|
+
// Compare against caller's expected version
|
|
1081
|
+
const liveVersion = typeof liveVal === "object" && liveVal !== null
|
|
1082
|
+
? JSON.stringify(liveVal)
|
|
1083
|
+
: String(liveVal);
|
|
1084
|
+
const expectedStr = typeof expectedVersion === "object" && expectedVersion !== null
|
|
1085
|
+
? JSON.stringify(expectedVersion)
|
|
1086
|
+
: String(expectedVersion);
|
|
1087
|
+
|
|
1088
|
+
if (liveVersion !== expectedStr) {
|
|
1089
|
+
changedFields.set(`${entityId}:${field}`, {
|
|
1090
|
+
callerVersion: expectedVersion,
|
|
1091
|
+
live: liveVal,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Disclose reduced confidence, from the live source and from dependencies
|
|
1098
|
+
const degraded = [
|
|
1099
|
+
sourceStatus === "degraded" ? "live source is degraded — proceed with reduced confidence" : null,
|
|
1100
|
+
degradedDependency,
|
|
1101
|
+
].filter(Boolean);
|
|
1102
|
+
|
|
1103
|
+
return {
|
|
1104
|
+
ok: true,
|
|
1105
|
+
mustRetry: changedFields.size > 0,
|
|
1106
|
+
changedFields,
|
|
1107
|
+
sourceStatus,
|
|
1108
|
+
revalidatedAt,
|
|
1109
|
+
...(degraded.length > 0 ? { degraded: true, reason: degraded.join("; ") } : {}),
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
// LiveStateError
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
export class LiveStateError extends Error {
|
|
1118
|
+
constructor(message) {
|
|
1119
|
+
super(message);
|
|
1120
|
+
this.name = "LiveStateError";
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ---------------------------------------------------------------------------
|
|
1125
|
+
// Known volatile commerce field names (always live_required)
|
|
1126
|
+
// ---------------------------------------------------------------------------
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Field names that are always served from the live source, never from cache.
|
|
1130
|
+
* These correspond to the volatile commerce fields from the ticket: price,
|
|
1131
|
+
* availability, inventory (schema.org inventoryLevel) and delivery estimate
|
|
1132
|
+
* (schema.org deliveryTime on shipping details, deliveryLeadTime on an
|
|
1133
|
+
* offer).
|
|
1134
|
+
*
|
|
1135
|
+
* Note: schema.org Products typically carry price and availability inside
|
|
1136
|
+
* `offers` (an object, array of variants, or AggregateOffer), and prices also
|
|
1137
|
+
* sit in a priceSpecification or on top-level Offer, AggregateOffer and
|
|
1138
|
+
* UnitPriceSpecification entities. hydrate and revalidateForAction therefore
|
|
1139
|
+
* treat a field with one of these names, and any canonical money value, as
|
|
1140
|
+
* volatile at any depth of an entity's attributes (mapVolatileNodes).
|
|
1141
|
+
*
|
|
1142
|
+
* A name is volatile wherever it appears, on any entity: a store-wide shipping
|
|
1143
|
+
* policy under deliveryTime (an Organization's, say) is cleared like an
|
|
1144
|
+
* offer's estimate unless the adapter serves it. Adapters must serve these
|
|
1145
|
+
* fields to keep them; limiting the delivery names to offer paths would let a
|
|
1146
|
+
* cached estimate through on a top-level Offer entity or under makesOffer.
|
|
1147
|
+
*/
|
|
1148
|
+
export const VOLATILE_FIELD_NAMES = Object.freeze([
|
|
1149
|
+
"price",
|
|
1150
|
+
"availability",
|
|
1151
|
+
"inventory",
|
|
1152
|
+
"inventoryLevel",
|
|
1153
|
+
"delivery_estimate",
|
|
1154
|
+
"deliveryEstimate",
|
|
1155
|
+
"deliveryTime",
|
|
1156
|
+
"deliveryLeadTime",
|
|
1157
|
+
]);
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* The form a field name is compared in: the local name after any namespace
|
|
1161
|
+
* prefix (schema:availability, https://schema.org/availability), lowercased,
|
|
1162
|
+
* with every character other than a letter or digit removed. The compiler
|
|
1163
|
+
* keeps markup keys as written, so Availability, AVAILABILITY and
|
|
1164
|
+
* delivery-estimate name the same fields as availability and
|
|
1165
|
+
* deliveryEstimate.
|
|
1166
|
+
*/
|
|
1167
|
+
function normalizeFieldName(fieldName) {
|
|
1168
|
+
if (typeof fieldName !== "string") return "";
|
|
1169
|
+
return fieldName.split(/[:/#]/).pop().toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const VOLATILE_FIELD_SET = new Set(VOLATILE_FIELD_NAMES.map(normalizeFieldName));
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Check whether a field name is volatile (must be fetched live), in any
|
|
1176
|
+
* spelling normalizeFieldName folds together.
|
|
1177
|
+
*/
|
|
1178
|
+
export function isVolatileField(fieldName) {
|
|
1179
|
+
return VOLATILE_FIELD_SET.has(normalizeFieldName(fieldName));
|
|
1180
|
+
}
|