@storystick/preview 0.0.0-stage → 0.2.0-rc.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/LICENSE +112 -0
- package/PUBLIC-API.md +55 -0
- package/README.md +42 -2
- package/dist/.source-hash +1 -0
- package/dist/authoring/runtime-contract.d.ts +89 -0
- package/dist/authoring/runtime-contract.js +28 -0
- package/dist/channel-taxonomy.d.ts +17 -0
- package/dist/channel-taxonomy.js +46 -0
- package/dist/context-command.d.ts +18 -0
- package/dist/context-command.js +38 -0
- package/dist/dev/suspicious-codeword-detector.d.ts +15 -0
- package/dist/dev/suspicious-codeword-detector.js +220 -0
- package/dist/dev/suspicious-codeword-detector.noop.d.ts +3 -0
- package/dist/dev/suspicious-codeword-detector.noop.js +4 -0
- package/dist/framework/browser-context.d.ts +2 -0
- package/dist/framework/browser-context.js +2 -0
- package/dist/framework/generation-refresh.d.ts +23 -0
- package/dist/framework/generation-refresh.js +82 -0
- package/dist/framework/generation-signal.d.ts +8 -0
- package/dist/framework/generation-signal.js +17 -0
- package/dist/framework/projection-generation-watch.d.ts +5 -0
- package/dist/framework/projection-generation-watch.js +53 -0
- package/dist/framework/render-receipt.d.ts +8 -0
- package/dist/framework/render-receipt.js +10 -0
- package/dist/framework/runtime-adapter-store.d.ts +30 -0
- package/dist/framework/runtime-adapter-store.js +81 -0
- package/dist/grant-coordinator.d.ts +53 -0
- package/dist/grant-coordinator.js +329 -0
- package/dist/grant-lifecycle.d.ts +52 -0
- package/dist/grant-lifecycle.js +74 -0
- package/dist/identity/__test-adapter.d.ts +34 -0
- package/dist/identity/__test-adapter.js +59 -0
- package/dist/identity/browser-context-gate.d.ts +4 -0
- package/dist/identity/browser-context-gate.js +22 -0
- package/dist/identity/browser-context.d.ts +13 -0
- package/dist/identity/browser-context.js +22 -0
- package/dist/identity/carriers/postmessage.d.ts +84 -0
- package/dist/identity/carriers/postmessage.js +177 -0
- package/dist/identity/carriers/storage-state.d.ts +41 -0
- package/dist/identity/carriers/storage-state.js +99 -0
- package/dist/identity/carriers/storage.d.ts +39 -0
- package/dist/identity/carriers/storage.js +182 -0
- package/dist/identity/carriers/url.d.ts +19 -0
- package/dist/identity/carriers/url.js +38 -0
- package/dist/identity/identity-state-machine.d.ts +70 -0
- package/dist/identity/identity-state-machine.js +244 -0
- package/dist/identity/identity-types.d.ts +192 -0
- package/dist/identity/identity-types.js +22 -0
- package/dist/identity/index.d.ts +18 -0
- package/dist/identity/index.js +16 -0
- package/dist/identity/preview-cookie.d.ts +9 -0
- package/dist/identity/preview-cookie.js +12 -0
- package/dist/identity/preview-draft.d.ts +6 -0
- package/dist/identity/preview-draft.js +25 -0
- package/dist/identity/preview-identity.d.ts +28 -0
- package/dist/identity/preview-identity.js +84 -0
- package/dist/identity/preview-scope.d.ts +28 -0
- package/dist/identity/preview-scope.js +47 -0
- package/dist/identity/resolver.d.ts +110 -0
- package/dist/identity/resolver.js +334 -0
- package/dist/identity/session-query.d.ts +4 -0
- package/dist/identity/session-query.js +19 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.js +788 -0
- package/dist/interaction-mode.d.ts +58 -0
- package/dist/interaction-mode.js +93 -0
- package/dist/nav-handler.d.ts +95 -0
- package/dist/nav-handler.js +84 -0
- package/dist/overlay-label.d.ts +79 -0
- package/dist/overlay-label.js +73 -0
- package/dist/provenance/anchor-map.d.ts +95 -0
- package/dist/provenance/anchor-map.js +177 -0
- package/dist/provenance/carrier-plan.d.ts +51 -0
- package/dist/provenance/carrier-plan.js +90 -0
- package/dist/provenance/coded-text.d.ts +20 -0
- package/dist/provenance/coded-text.js +302 -0
- package/dist/provenance/direct-edit-locator.d.ts +29 -0
- package/dist/provenance/direct-edit-locator.js +218 -0
- package/dist/provenance/editable-leaf.d.ts +134 -0
- package/dist/provenance/editable-leaf.js +483 -0
- package/dist/provenance/editor-target.d.ts +29 -0
- package/dist/provenance/editor-target.js +46 -0
- package/dist/provenance/field-leaf-walker.d.ts +21 -0
- package/dist/provenance/field-leaf-walker.js +98 -0
- package/dist/provenance/frame-dependencies.d.ts +212 -0
- package/dist/provenance/frame-dependencies.js +550 -0
- package/dist/provenance/index.d.ts +26 -0
- package/dist/provenance/index.js +26 -0
- package/dist/provenance/local-events.d.ts +1 -0
- package/dist/provenance/local-events.js +1 -0
- package/dist/provenance/markers.d.ts +21 -0
- package/dist/provenance/markers.js +70 -0
- package/dist/provenance/multi-frame-anchor-map.d.ts +54 -0
- package/dist/provenance/multi-frame-anchor-map.js +109 -0
- package/dist/provenance/normalize.d.ts +2 -0
- package/dist/provenance/normalize.js +19 -0
- package/dist/provenance/overlay-focus-echo.d.ts +17 -0
- package/dist/provenance/overlay-focus-echo.js +16 -0
- package/dist/provenance/overlay-projection.d.ts +65 -0
- package/dist/provenance/overlay-projection.js +201 -0
- package/dist/provenance/owner-map.d.ts +55 -0
- package/dist/provenance/owner-map.js +239 -0
- package/dist/provenance/read-carrier.d.ts +22 -0
- package/dist/provenance/read-carrier.js +92 -0
- package/dist/provenance/recorder.d.ts +38 -0
- package/dist/provenance/recorder.js +82 -0
- package/dist/provenance/render-context.d.ts +106 -0
- package/dist/provenance/render-context.js +338 -0
- package/dist/provenance/reveal-contract.d.ts +44 -0
- package/dist/provenance/reveal-contract.js +1 -0
- package/dist/provenance/richtext-slice-embed-address.d.ts +20 -0
- package/dist/provenance/richtext-slice-embed-address.js +35 -0
- package/dist/provenance/runtime-script.d.ts +1 -0
- package/dist/provenance/runtime-script.js +2217 -0
- package/dist/provenance/sidecar-store.d.ts +63 -0
- package/dist/provenance/sidecar-store.js +151 -0
- package/dist/provenance/sidecar.d.ts +34 -0
- package/dist/provenance/sidecar.js +227 -0
- package/dist/provenance/slice-model.d.ts +28 -0
- package/dist/provenance/slice-model.js +86 -0
- package/dist/provenance/source-identity.d.ts +3 -0
- package/dist/provenance/source-identity.js +7 -0
- package/dist/provenance/source-map-materializer.d.ts +76 -0
- package/dist/provenance/source-map-materializer.js +286 -0
- package/dist/provenance/source-registry.d.ts +103 -0
- package/dist/provenance/source-registry.js +562 -0
- package/dist/provenance/transformed-output-gate.d.ts +161 -0
- package/dist/provenance/transformed-output-gate.js +171 -0
- package/dist/provenance/types.d.ts +102 -0
- package/dist/provenance/types.js +1 -0
- package/dist/refresh-policy.d.ts +11 -0
- package/dist/refresh-policy.js +14 -0
- package/dist/reveal-target.d.ts +8 -0
- package/dist/reveal-target.js +47 -0
- package/dist/route-paths.d.ts +11 -0
- package/dist/route-paths.js +14 -0
- package/dist/router-subscriber.d.ts +92 -0
- package/dist/router-subscriber.js +44 -0
- package/dist/runtime/anchor-types.d.ts +5 -0
- package/dist/runtime/anchor-types.js +1 -0
- package/dist/runtime/authority-store.d.ts +31 -0
- package/dist/runtime/authority-store.js +56 -0
- package/dist/runtime/boot.d.ts +52 -0
- package/dist/runtime/boot.js +379 -0
- package/dist/runtime/content-refresh.d.ts +30 -0
- package/dist/runtime/content-refresh.js +68 -0
- package/dist/runtime/emit-runtime-error.d.ts +51 -0
- package/dist/runtime/emit-runtime-error.js +64 -0
- package/dist/runtime/field-proxy.d.ts +112 -0
- package/dist/runtime/field-proxy.js +471 -0
- package/dist/runtime/form-contract-reporter.d.ts +99 -0
- package/dist/runtime/form-contract-reporter.js +391 -0
- package/dist/runtime/invalidation-adapter.d.ts +17 -0
- package/dist/runtime/invalidation-adapter.js +42 -0
- package/dist/runtime/live-query-overlay.d.ts +39 -0
- package/dist/runtime/live-query-overlay.js +345 -0
- package/dist/runtime/momentary-navigate.d.ts +16 -0
- package/dist/runtime/momentary-navigate.js +106 -0
- package/dist/runtime/observation-scheduler.d.ts +15 -0
- package/dist/runtime/observation-scheduler.js +42 -0
- package/dist/runtime/perf-metrics.d.ts +28 -0
- package/dist/runtime/perf-metrics.js +90 -0
- package/dist/runtime/preview-interaction-policy.d.ts +1 -0
- package/dist/runtime/preview-interaction-policy.js +103 -0
- package/dist/runtime/preview-overlay-painter.d.ts +27 -0
- package/dist/runtime/preview-overlay-painter.js +1732 -0
- package/dist/runtime/preview-overlay-sidecar.d.ts +26 -0
- package/dist/runtime/preview-overlay-sidecar.js +45 -0
- package/dist/runtime/preview-perspective-alignment.d.ts +15 -0
- package/dist/runtime/preview-perspective-alignment.js +40 -0
- package/dist/runtime/preview-perspective-intent-state.d.ts +23 -0
- package/dist/runtime/preview-perspective-intent-state.js +5 -0
- package/dist/runtime/preview-transition-state.d.ts +142 -0
- package/dist/runtime/preview-transition-state.js +257 -0
- package/dist/runtime/render-mode-state.d.ts +51 -0
- package/dist/runtime/render-mode-state.js +76 -0
- package/dist/runtime/request-id.d.ts +4 -0
- package/dist/runtime/request-id.js +8 -0
- package/dist/runtime/resolve-schema-field.d.ts +99 -0
- package/dist/runtime/resolve-schema-field.js +356 -0
- package/dist/runtime/runtime-error-dispatch.d.ts +23 -0
- package/dist/runtime/runtime-error-dispatch.js +75 -0
- package/dist/runtime/schema-store.d.ts +121 -0
- package/dist/runtime/schema-store.js +667 -0
- package/dist/runtime/snapshot-assembly.d.ts +104 -0
- package/dist/runtime/snapshot-assembly.js +553 -0
- package/dist/runtime/snapshot-reporter.d.ts +55 -0
- package/dist/runtime/snapshot-reporter.js +375 -0
- package/dist/runtime/standalone-overlay-controller.d.ts +33 -0
- package/dist/runtime/standalone-overlay-controller.js +281 -0
- package/dist/runtime/store.d.ts +307 -0
- package/dist/runtime/store.js +1026 -0
- package/dist/runtime/windowed-minimap-projection.d.ts +28 -0
- package/dist/runtime/windowed-minimap-projection.js +189 -0
- package/dist/runtime/workspace-shortcuts.d.ts +30 -0
- package/dist/runtime/workspace-shortcuts.js +188 -0
- package/dist/sdk-error-copy.d.ts +48 -0
- package/dist/sdk-error-copy.js +182 -0
- package/dist/select-cursor.d.ts +2 -0
- package/dist/select-cursor.js +23 -0
- package/dist/server/admission-cookie.d.ts +38 -0
- package/dist/server/admission-cookie.js +122 -0
- package/dist/server/control-client.d.ts +29 -0
- package/dist/server/control-client.js +22 -0
- package/dist/server/control-errors.d.ts +22 -0
- package/dist/server/control-errors.js +165 -0
- package/dist/server/draft-route.d.ts +12 -0
- package/dist/server/draft-route.js +45 -0
- package/dist/server/extract-token.d.ts +30 -0
- package/dist/server/extract-token.js +98 -0
- package/dist/server/grant.d.ts +128 -0
- package/dist/server/grant.js +395 -0
- package/dist/server/ordinary-read-provenance.d.ts +11 -0
- package/dist/server/ordinary-read-provenance.js +124 -0
- package/dist/server/preview-read-metadata.d.ts +10 -0
- package/dist/server/preview-read-metadata.js +27 -0
- package/dist/server/provenance-frame-builder.d.ts +53 -0
- package/dist/server/provenance-frame-builder.js +102 -0
- package/dist/server/provenance-runtime.d.ts +70 -0
- package/dist/server/provenance-runtime.js +46 -0
- package/dist/server/read-context-command.d.ts +11 -0
- package/dist/server/read-context-command.js +53 -0
- package/dist/server/read-identity.d.ts +3 -0
- package/dist/server/read-identity.js +63 -0
- package/dist/server/read-lifecycle.d.ts +17 -0
- package/dist/server/read-lifecycle.js +52 -0
- package/dist/server/render-frame-provenance.d.ts +133 -0
- package/dist/server/render-frame-provenance.js +676 -0
- package/dist/server/render-id.d.ts +15 -0
- package/dist/server/render-id.js +22 -0
- package/dist/server/route-document-provenance.d.ts +77 -0
- package/dist/server/route-document-provenance.js +1327 -0
- package/dist/server/route-session-cookie.d.ts +33 -0
- package/dist/server/route-session-cookie.js +54 -0
- package/dist/session-projections.d.ts +33 -0
- package/dist/session-projections.js +43 -0
- package/dist/session-subscriber.d.ts +124 -0
- package/dist/session-subscriber.js +79 -0
- package/dist/sha256-fallback.d.ts +30 -0
- package/dist/sha256-fallback.js +126 -0
- package/dist/types.d.ts +446 -0
- package/dist/types.js +85 -0
- package/dist/validators.d.ts +61 -0
- package/dist/validators.js +110 -0
- package/package.json +206 -3
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-131 Amendment 002 — iframe-side schema store.
|
|
3
|
+
*
|
|
4
|
+
* Caches the project schema fetched via the existing
|
|
5
|
+
* `/api/q/schemas/current` HTTP route. Two storage layers :
|
|
6
|
+
*
|
|
7
|
+
* 1. In-memory `inMemory` cell (sync read for the resolver hot path).
|
|
8
|
+
* 2. IndexedDB cache, keyed by
|
|
9
|
+
* `(projectId, codeVersion, wireFormatVersion)`, TTL 5 min.
|
|
10
|
+
*
|
|
11
|
+
* Hot path (`peekSchema`) is synchronous — returns the in-memory record
|
|
12
|
+
* or `null`. The async `bootSchema` is idempotent : concurrent callers
|
|
13
|
+
* share a single in-flight promise ; tag mismatches (different
|
|
14
|
+
* projectId or codeVersion) invalidate the fast path and refetch ;
|
|
15
|
+
* TTL expiry triggers refetch ; network failures preserve the last
|
|
16
|
+
* good record (degraded mode).
|
|
17
|
+
*
|
|
18
|
+
* Race contracts :
|
|
19
|
+
* - Provider mounts AFTER iframe runtime starts → bootSchema returns
|
|
20
|
+
* `null` until `previewRuntime.httpClient` is set. Resolver retries
|
|
21
|
+
* next rAF cycle. Cost : ~1-2 rAF (~32 ms).
|
|
22
|
+
* - codeVersion drift mid-session → resolver detects mismatch, calls
|
|
23
|
+
* `resetSchemaStore()` then `bootSchema()` ; null SnapshotItems for
|
|
24
|
+
* the cycle, fresh fetch next.
|
|
25
|
+
* - Concurrent callers → `inflight` promise serialises ; IDB + HTTP
|
|
26
|
+
* caches absorb the rare double-fetch.
|
|
27
|
+
*
|
|
28
|
+
* TTL+codeVersion tradeoff (amendment §6.3) : a `storystick push`
|
|
29
|
+
* without code rebuild leaves the codeVersion unchanged. The iframe
|
|
30
|
+
* may serve a stale schema for up to 5 min in that edge case. The
|
|
31
|
+
* normal dev workflow (push → generate types → rebuild → bundle hash
|
|
32
|
+
* changes → codeVersion bumps) invalidates immediately.
|
|
33
|
+
*/
|
|
34
|
+
import { fetchCurrentSchema, isSchemaFormatUnsupportedError, } from '@storystick/client/adapter';
|
|
35
|
+
import { StorystickError } from '@storystick/client';
|
|
36
|
+
import { STORY_SCHEMA_FORMAT_VERSION } from '@storystick/types';
|
|
37
|
+
import { previewRuntime } from './store.js';
|
|
38
|
+
import { emitRuntimeError, emitRuntimeRecovered, } from './emit-runtime-error.js';
|
|
39
|
+
const IDB_NAME = 'storystick-preview-schema';
|
|
40
|
+
const IDB_STORE = 'schemas';
|
|
41
|
+
const IDB_VERSION = 2;
|
|
42
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
43
|
+
/**
|
|
44
|
+
* ADR-134 §9.T7 — retry orchestrator backoff array.
|
|
45
|
+
*
|
|
46
|
+
* Interpretation : every attempt is preceded by a delay from this array
|
|
47
|
+
* (attempt 1 by index 0, attempt 2 by index 1, …). attempt 1 runs at
|
|
48
|
+
* t=1s, attempt 5 at t=31s — matching the spec's "Attempt 1 fails
|
|
49
|
+
* (~1s elapsed)" / "Attempt 5 fails (~31s elapsed)" numbers exactly.
|
|
50
|
+
*
|
|
51
|
+
* The 1s grace before attempt 1 is intentional : it lets the IDB
|
|
52
|
+
* cache + boot-time `bootSchema()` resolve fast paths without a UI
|
|
53
|
+
* blip. If schema is already in cache when attempt 1 fires, bootSchema
|
|
54
|
+
* returns the cached record and the orchestrator resets without
|
|
55
|
+
* emitting anything.
|
|
56
|
+
*/
|
|
57
|
+
const RETRY_BACKOFF_INTERVALS_MS = [1_000, 2_000, 4_000, 8_000, 16_000];
|
|
58
|
+
const RETRY_MAX_ATTEMPTS = RETRY_BACKOFF_INTERVALS_MS.length;
|
|
59
|
+
let inMemory = null;
|
|
60
|
+
let inflight = null;
|
|
61
|
+
let lastFailure = null;
|
|
62
|
+
const activeSchemaDiagnostics = new Set();
|
|
63
|
+
/**
|
|
64
|
+
* Synchronous accessor — the resolver §D6.1 calls this every rAF cycle.
|
|
65
|
+
* Returns `null` until `bootSchema()` has resolved at least once with
|
|
66
|
+
* a non-null gateway response.
|
|
67
|
+
*
|
|
68
|
+
* Tag-aware (codex R3 P1) : returns the in-memory record only when its
|
|
69
|
+
* tags still match the current `(projectId, codeVersion)`. Without
|
|
70
|
+
* this gate, an identity Replace or codeVersion bump would leak the
|
|
71
|
+
* prior tenant's / prior bundle's schema to the resolver hot path
|
|
72
|
+
* until `bootSchema()` has had a chance to overwrite or null-out the
|
|
73
|
+
* slot. Returning `null` under stale tags is the same surface the
|
|
74
|
+
* resolver already handles (degraded mode, retry next rAF).
|
|
75
|
+
*/
|
|
76
|
+
export function peekSchema() {
|
|
77
|
+
if (inMemory === null)
|
|
78
|
+
return null;
|
|
79
|
+
const currentProjectId = previewRuntime.getState().identity?.projectId ?? null;
|
|
80
|
+
const currentCodeVersion = readCodeVersionFromWindow();
|
|
81
|
+
if (inMemory.projectId !== currentProjectId ||
|
|
82
|
+
inMemory.codeVersion !== currentCodeVersion) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return inMemory;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Boot or refresh the iframe-side schema cache. Returns the in-memory
|
|
89
|
+
* record (possibly stale, if a refetch failed) OR `null` if no record
|
|
90
|
+
* has ever been established AND the current attempt cannot proceed
|
|
91
|
+
* (missing httpClient / identity / codeVersion / network failure).
|
|
92
|
+
*
|
|
93
|
+
* Idempotent : concurrent callers await the same `inflight` promise.
|
|
94
|
+
*
|
|
95
|
+
* Tag-stability invariant (codex R1 fix) : we re-read the current
|
|
96
|
+
* projectId + codeVersion AFTER each await and discard the result
|
|
97
|
+
* if the tags drifted. Without this, an identity Replace mid-flight
|
|
98
|
+
* could install a wrong-tenant schema OR keep a stale prior record
|
|
99
|
+
* referenced under the wrong tags.
|
|
100
|
+
*/
|
|
101
|
+
export async function bootSchema() {
|
|
102
|
+
const initialState = previewRuntime.getState();
|
|
103
|
+
const projectIdAtEntry = initialState.identity?.projectId ?? null;
|
|
104
|
+
const codeVersionAtEntry = readCodeVersionFromWindow();
|
|
105
|
+
const httpClientAtEntry = initialState.httpClient;
|
|
106
|
+
// Fast path : in-memory record still matches the current tags AND
|
|
107
|
+
// is within TTL. Tags-check is critical — without it a stale
|
|
108
|
+
// schema persists across projectId / codeVersion changes (codex R4).
|
|
109
|
+
if (inMemory !== null &&
|
|
110
|
+
inMemory.projectId === projectIdAtEntry &&
|
|
111
|
+
inMemory.codeVersion === codeVersionAtEntry &&
|
|
112
|
+
!_isStale(inMemory)) {
|
|
113
|
+
_recoverSchemaDiagnostics();
|
|
114
|
+
return inMemory;
|
|
115
|
+
}
|
|
116
|
+
// Codex R3 P1 — preconditions checked BEFORE entering the `inflight`
|
|
117
|
+
// slot. Otherwise an early-return IIFE that runs without awaiting
|
|
118
|
+
// would (a) sync-execute finally → clear `inflight`, then (b) get
|
|
119
|
+
// the resolved promise re-assigned into `inflight` after the IIFE
|
|
120
|
+
// returns, leaving `inflight` permanently pinned to a null-resolved
|
|
121
|
+
// promise. Subsequent re-fires (e.g. from boot.ts's httpClient
|
|
122
|
+
// arrival subscription) would short-circuit to the latched null.
|
|
123
|
+
if (httpClientAtEntry === null) {
|
|
124
|
+
return _onlyIfTagsMatch(projectIdAtEntry, codeVersionAtEntry);
|
|
125
|
+
}
|
|
126
|
+
if (projectIdAtEntry === null)
|
|
127
|
+
return null;
|
|
128
|
+
if (codeVersionAtEntry === null)
|
|
129
|
+
return null;
|
|
130
|
+
if (lastFailure?.retryable === false &&
|
|
131
|
+
lastFailure.projectId === projectIdAtEntry &&
|
|
132
|
+
lastFailure.codeVersion === codeVersionAtEntry &&
|
|
133
|
+
lastFailure.httpClient === httpClientAtEntry) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (inflight !== null)
|
|
137
|
+
return inflight;
|
|
138
|
+
const bypassCache = lastFailure?.kind === 'incompatible' &&
|
|
139
|
+
lastFailure.projectId === projectIdAtEntry &&
|
|
140
|
+
lastFailure.codeVersion === codeVersionAtEntry;
|
|
141
|
+
inflight = (async () => {
|
|
142
|
+
try {
|
|
143
|
+
const cached = bypassCache
|
|
144
|
+
? null
|
|
145
|
+
: await _loadFromIdb(projectIdAtEntry, codeVersionAtEntry);
|
|
146
|
+
if (cached !== null && !_isStale(cached)) {
|
|
147
|
+
// Re-read tags before install — async windows let identity
|
|
148
|
+
// Replace and codeVersion drift slip in.
|
|
149
|
+
if (!_tagsStable(projectIdAtEntry, codeVersionAtEntry)) {
|
|
150
|
+
return _onlyIfTagsMatch(projectIdAtEntry, codeVersionAtEntry);
|
|
151
|
+
}
|
|
152
|
+
inMemory = cached;
|
|
153
|
+
lastFailure = null;
|
|
154
|
+
_recoverSchemaDiagnostics();
|
|
155
|
+
return cached;
|
|
156
|
+
}
|
|
157
|
+
const row = await fetchCurrentSchema(httpClientAtEntry);
|
|
158
|
+
if (row === null) {
|
|
159
|
+
// Backend returned null — no schema pushed yet for tenant.
|
|
160
|
+
// Stay in degraded mode ; do NOT overwrite a prior good record.
|
|
161
|
+
lastFailure = {
|
|
162
|
+
kind: 'transient',
|
|
163
|
+
code: 'storystick/SCHEMA_NOT_AVAILABLE',
|
|
164
|
+
message: 'The schema endpoint returned no schema for this project.',
|
|
165
|
+
retryable: true,
|
|
166
|
+
projectId: projectIdAtEntry,
|
|
167
|
+
codeVersion: codeVersionAtEntry,
|
|
168
|
+
httpClient: httpClientAtEntry,
|
|
169
|
+
};
|
|
170
|
+
return _onlyIfTagsMatch(projectIdAtEntry, codeVersionAtEntry);
|
|
171
|
+
}
|
|
172
|
+
// Re-read tags before install — same race window as the IDB
|
|
173
|
+
// branch above.
|
|
174
|
+
if (!_tagsStable(projectIdAtEntry, codeVersionAtEntry)) {
|
|
175
|
+
return _onlyIfTagsMatch(projectIdAtEntry, codeVersionAtEntry);
|
|
176
|
+
}
|
|
177
|
+
const record = {
|
|
178
|
+
projectId: projectIdAtEntry,
|
|
179
|
+
codeVersion: codeVersionAtEntry,
|
|
180
|
+
schema: row,
|
|
181
|
+
fetchedAt: Date.now(),
|
|
182
|
+
};
|
|
183
|
+
// Persist + install. Persist failure is logged-silent (degraded
|
|
184
|
+
// IDB) ; install still happens — UNLESS tags drifted during the
|
|
185
|
+
// IDB save await (codex R2 P1 — close the hole). The IDB row is
|
|
186
|
+
// already written under the entry-tags composite key, which is
|
|
187
|
+
// harmless ; we just don't install it into the in-memory slot
|
|
188
|
+
// under stale tags.
|
|
189
|
+
await _saveToIdb(record);
|
|
190
|
+
if (!_tagsStable(projectIdAtEntry, codeVersionAtEntry)) {
|
|
191
|
+
return _onlyIfTagsMatch(projectIdAtEntry, codeVersionAtEntry);
|
|
192
|
+
}
|
|
193
|
+
inMemory = record;
|
|
194
|
+
lastFailure = null;
|
|
195
|
+
_recoverSchemaDiagnostics();
|
|
196
|
+
return record;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
lastFailure = _classifyFailure(error, {
|
|
200
|
+
projectId: projectIdAtEntry,
|
|
201
|
+
codeVersion: codeVersionAtEntry,
|
|
202
|
+
httpClient: httpClientAtEntry,
|
|
203
|
+
});
|
|
204
|
+
if (lastFailure.kind === 'incompatible') {
|
|
205
|
+
inMemory = null;
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
// Network / auth / IDB error → fall back to stale cache only if
|
|
209
|
+
// its tags still match the entry tags. A failed refetch must not
|
|
210
|
+
// resurrect a record under tags that no longer apply.
|
|
211
|
+
return _onlyIfTagsMatch(projectIdAtEntry, codeVersionAtEntry);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
inflight = null;
|
|
215
|
+
}
|
|
216
|
+
})();
|
|
217
|
+
return inflight;
|
|
218
|
+
}
|
|
219
|
+
function _tagsStable(projectId, codeVersion) {
|
|
220
|
+
const currentProjectId = previewRuntime.getState().identity?.projectId ?? null;
|
|
221
|
+
const currentCodeVersion = readCodeVersionFromWindow();
|
|
222
|
+
return projectId === currentProjectId && codeVersion === currentCodeVersion;
|
|
223
|
+
}
|
|
224
|
+
function _onlyIfTagsMatch(projectId, codeVersion) {
|
|
225
|
+
// Return the current in-memory record only if it matches the entry
|
|
226
|
+
// tags AND the current tags. Drops the record if a drift happened
|
|
227
|
+
// mid-flight — the caller will retry under the new tags.
|
|
228
|
+
if (inMemory === null)
|
|
229
|
+
return null;
|
|
230
|
+
if (inMemory.projectId !== projectId || inMemory.codeVersion !== codeVersion) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (!_tagsStable(projectId, codeVersion))
|
|
234
|
+
return null;
|
|
235
|
+
return inMemory;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Drop the in-memory record. Used by the resolver when it detects a
|
|
239
|
+
* codeVersion drift — the next `bootSchema()` refetches against the
|
|
240
|
+
* new tag. Does NOT clear IDB ; the IDB entry under the new tag is
|
|
241
|
+
* separate and the old tag's row is harmless until it ages out.
|
|
242
|
+
*/
|
|
243
|
+
export function invalidateSchema() {
|
|
244
|
+
inMemory = null;
|
|
245
|
+
lastFailure = null;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Test hook — clears in-memory, in-flight, AND IDB. Never invoked in
|
|
249
|
+
* production code.
|
|
250
|
+
*/
|
|
251
|
+
export async function _resetSchemaStoreForTestsOnly() {
|
|
252
|
+
inMemory = null;
|
|
253
|
+
inflight = null;
|
|
254
|
+
lastFailure = null;
|
|
255
|
+
activeSchemaDiagnostics.clear();
|
|
256
|
+
_resetRetryCycle();
|
|
257
|
+
await _clearIdb();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Test hook — expose the current in-flight promise so tests can assert
|
|
261
|
+
* concurrent callers share it. Returns null when no fetch is pending.
|
|
262
|
+
*/
|
|
263
|
+
export function _peekInflightForTestsOnly() {
|
|
264
|
+
return inflight;
|
|
265
|
+
}
|
|
266
|
+
export function _peekSchemaFailureForTestsOnly() {
|
|
267
|
+
return lastFailure;
|
|
268
|
+
}
|
|
269
|
+
function _classifyFailure(error, tags) {
|
|
270
|
+
if (isSchemaFormatUnsupportedError(error)) {
|
|
271
|
+
return {
|
|
272
|
+
kind: 'incompatible',
|
|
273
|
+
code: error.code,
|
|
274
|
+
message: error.message,
|
|
275
|
+
retryable: false,
|
|
276
|
+
...tags,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const code = error instanceof StorystickError && typeof error.code === 'string'
|
|
280
|
+
? error.code
|
|
281
|
+
: error instanceof Error
|
|
282
|
+
? error.name
|
|
283
|
+
: 'storystick/UNKNOWN_SCHEMA_DELIVERY_FAILURE';
|
|
284
|
+
const message = error instanceof Error
|
|
285
|
+
? error.message
|
|
286
|
+
: 'Schema delivery failed with an unknown error.';
|
|
287
|
+
return {
|
|
288
|
+
kind: 'transient',
|
|
289
|
+
code,
|
|
290
|
+
message,
|
|
291
|
+
retryable: error instanceof StorystickError ? error.retryable !== false : true,
|
|
292
|
+
...tags,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function _failureMessage(failure) {
|
|
296
|
+
if (failure === null)
|
|
297
|
+
return 'Schema delivery returned no usable result.';
|
|
298
|
+
return `${failure.code}: ${failure.message}`;
|
|
299
|
+
}
|
|
300
|
+
function _emitSchemaDiagnosticSafe(input) {
|
|
301
|
+
try {
|
|
302
|
+
emitRuntimeError(input);
|
|
303
|
+
activeSchemaDiagnostics.add(input.code);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
/* Diagnostics must not interrupt schema delivery. */
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function _recoverSchemaDiagnostics() {
|
|
310
|
+
for (const code of activeSchemaDiagnostics) {
|
|
311
|
+
try {
|
|
312
|
+
emitRuntimeRecovered(code);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
/* Diagnostics must not interrupt schema delivery. */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
activeSchemaDiagnostics.clear();
|
|
319
|
+
}
|
|
320
|
+
// ─────────────────────────────────────────────
|
|
321
|
+
// ADR-134 §9.T7 — retry orchestrator
|
|
322
|
+
// ─────────────────────────────────────────────
|
|
323
|
+
//
|
|
324
|
+
// A bounded retry loop on top of `bootSchema`. Stages two visible
|
|
325
|
+
// signals via `emitRuntimeError` :
|
|
326
|
+
//
|
|
327
|
+
// - attempt 1 fail → `runtime.schema_sync_delayed` (severity:warning)
|
|
328
|
+
// - attempts 2-4 silent
|
|
329
|
+
// - attempt 5 fail → `runtime.schema_delivery_failed` (severity:error)
|
|
330
|
+
// then stops retrying for the current cycle.
|
|
331
|
+
//
|
|
332
|
+
// "Cycle" = one (httpClient, projectId) tuple. When either changes
|
|
333
|
+
// (token refresh, project switch) the counter resets to zero and a
|
|
334
|
+
// fresh attempt cycle begins. boot.ts wires the orchestrator on once
|
|
335
|
+
// per runtime boot ; teardown clears all timers.
|
|
336
|
+
//
|
|
337
|
+
// All state below is module-scoped + singleton ; this module is
|
|
338
|
+
// already a singleton against `previewRuntime` so adding a parallel
|
|
339
|
+
// singleton for the retry counter is consistent.
|
|
340
|
+
let retryTimer = null;
|
|
341
|
+
let retryAttempt = 0;
|
|
342
|
+
let retryWarningEmitted = false;
|
|
343
|
+
let retryErrorEmitted = false;
|
|
344
|
+
let retryOrchestratorActive = false;
|
|
345
|
+
let retryUnsubscribe = null;
|
|
346
|
+
/**
|
|
347
|
+
* Cycle generation counter. Incremented every time the cycle resets
|
|
348
|
+
* (identity change, teardown). Each `_runAttempt` captures the
|
|
349
|
+
* generation at entry and checks it AFTER the bootSchema await ; a
|
|
350
|
+
* stale attempt that survived a reset bails before emitting
|
|
351
|
+
* envelopes or scheduling the next attempt (codex review P1 fix).
|
|
352
|
+
*
|
|
353
|
+
* Without this, a slow bootSchema fetch under a soon-to-be-stale
|
|
354
|
+
* (httpClient, projectId) tuple could land its `null` result after
|
|
355
|
+
* a token-refresh-driven cycle reset, then incorrectly emit
|
|
356
|
+
* `runtime.schema_delivery_failed` against the NEW identity, and
|
|
357
|
+
* even schedule a new attempt that the new cycle's own scheduler
|
|
358
|
+
* doesn't know about (leaking timer + double-scheduling).
|
|
359
|
+
*/
|
|
360
|
+
let retryCycleGeneration = 0;
|
|
361
|
+
function _emitRetryWarningSafe() {
|
|
362
|
+
_emitSchemaDiagnosticSafe({
|
|
363
|
+
code: 'runtime.schema_sync_delayed',
|
|
364
|
+
message: `Loading schema is taking longer than usual. ${_failureMessage(lastFailure)}`,
|
|
365
|
+
severity: 'warning',
|
|
366
|
+
retryable: true,
|
|
367
|
+
persistence: 'transient',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function _emitRetryErrorSafe() {
|
|
371
|
+
_emitSchemaDiagnosticSafe({
|
|
372
|
+
code: 'runtime.schema_delivery_failed',
|
|
373
|
+
message: `Schema delivery failed after 5 attempts. ${_failureMessage(lastFailure)}`,
|
|
374
|
+
severity: 'error',
|
|
375
|
+
retryable: lastFailure?.retryable ?? true,
|
|
376
|
+
persistence: 'until-recovered',
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
function _emitSchemaUpgradeRequiredSafe() {
|
|
380
|
+
_emitSchemaDiagnosticSafe({
|
|
381
|
+
code: 'runtime.schema_upgrade_required',
|
|
382
|
+
message: 'Schema upgrade required. Content remains available without field-level overlays. ' +
|
|
383
|
+
_failureMessage(lastFailure),
|
|
384
|
+
severity: 'warning',
|
|
385
|
+
retryable: false,
|
|
386
|
+
persistence: 'until-recovered',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function _resetRetryCycle() {
|
|
390
|
+
if (retryTimer !== null) {
|
|
391
|
+
clearTimeout(retryTimer);
|
|
392
|
+
retryTimer = null;
|
|
393
|
+
}
|
|
394
|
+
retryAttempt = 0;
|
|
395
|
+
retryWarningEmitted = false;
|
|
396
|
+
retryErrorEmitted = false;
|
|
397
|
+
// Bump the generation so any in-flight `_runAttempt` that survived
|
|
398
|
+
// the await will see the mismatch and bail (codex review P1 fix).
|
|
399
|
+
retryCycleGeneration += 1;
|
|
400
|
+
}
|
|
401
|
+
function _scheduleAttempt(attempt, generation) {
|
|
402
|
+
if (attempt > RETRY_MAX_ATTEMPTS)
|
|
403
|
+
return;
|
|
404
|
+
const delayMs = RETRY_BACKOFF_INTERVALS_MS[attempt - 1];
|
|
405
|
+
if (delayMs === undefined)
|
|
406
|
+
return;
|
|
407
|
+
retryTimer = setTimeout(() => {
|
|
408
|
+
retryTimer = null;
|
|
409
|
+
// Cross-cycle late-fire guard : the scheduling cycle may have been
|
|
410
|
+
// reset by an identity change OR by teardown after the timer was
|
|
411
|
+
// queued but before it fired. Bail before consuming bootSchema in
|
|
412
|
+
// the wrong cycle's context.
|
|
413
|
+
if (generation !== retryCycleGeneration)
|
|
414
|
+
return;
|
|
415
|
+
void _runAttempt(attempt, generation);
|
|
416
|
+
}, delayMs);
|
|
417
|
+
}
|
|
418
|
+
async function _runAttempt(attempt, generation) {
|
|
419
|
+
retryAttempt = attempt;
|
|
420
|
+
// Re-check preconditions. They might have lapsed (httpClient torn
|
|
421
|
+
// down, identity cleared) between scheduling and firing — in that
|
|
422
|
+
// case we stay silent and the runtime subscriber below will reschedule
|
|
423
|
+
// once preconditions are met again.
|
|
424
|
+
const state = previewRuntime.getState();
|
|
425
|
+
if (state.httpClient === null)
|
|
426
|
+
return;
|
|
427
|
+
if (state.identity?.projectId == null)
|
|
428
|
+
return;
|
|
429
|
+
const result = await bootSchema();
|
|
430
|
+
// Post-await cycle check (codex review P1 fix) : if the cycle was
|
|
431
|
+
// reset DURING the await (token refresh, project switch, teardown)
|
|
432
|
+
// this attempt is now stale. Bail without emitting envelopes or
|
|
433
|
+
// scheduling the next attempt — the new cycle owns its own
|
|
434
|
+
// scheduling and any signals it needs to emit.
|
|
435
|
+
if (generation !== retryCycleGeneration)
|
|
436
|
+
return;
|
|
437
|
+
if (result !== null) {
|
|
438
|
+
// Success — schema is loaded. Reset the cycle for any future
|
|
439
|
+
// identity changes that might trigger a fresh attempt cycle.
|
|
440
|
+
_resetRetryCycle();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (lastFailure?.kind === 'incompatible') {
|
|
444
|
+
if (!retryWarningEmitted) {
|
|
445
|
+
retryWarningEmitted = true;
|
|
446
|
+
_emitSchemaUpgradeRequiredSafe();
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (lastFailure !== null && !lastFailure.retryable) {
|
|
451
|
+
if (!retryErrorEmitted) {
|
|
452
|
+
retryErrorEmitted = true;
|
|
453
|
+
_emitRetryErrorSafe();
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Failure. Stage the two signals per §9.T7.
|
|
458
|
+
if (attempt === 1 && !retryWarningEmitted) {
|
|
459
|
+
retryWarningEmitted = true;
|
|
460
|
+
_emitRetryWarningSafe();
|
|
461
|
+
}
|
|
462
|
+
if (attempt >= RETRY_MAX_ATTEMPTS) {
|
|
463
|
+
if (!retryErrorEmitted) {
|
|
464
|
+
retryErrorEmitted = true;
|
|
465
|
+
_emitRetryErrorSafe();
|
|
466
|
+
}
|
|
467
|
+
// Stop retrying — counter stays at MAX_ATTEMPTS so the next
|
|
468
|
+
// identity change resets cleanly.
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
_scheduleAttempt(attempt + 1, generation);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Start the schema-retry orchestrator. Idempotent — calling a second
|
|
475
|
+
* time before stop returns the same teardown. Wired by `boot.ts` once
|
|
476
|
+
* per runtime boot.
|
|
477
|
+
*
|
|
478
|
+
* Behaviour per §9.T7 :
|
|
479
|
+
* - Subscribes to `previewRuntime` ; resets the cycle whenever
|
|
480
|
+
* `httpClient` or `identity.projectId` changes ; kicks off attempt
|
|
481
|
+
* 1 (after the 1s grace delay) when both preconditions are met.
|
|
482
|
+
* - If preconditions are ALREADY met at start, schedules attempt 1
|
|
483
|
+
* immediately via the same delay path (no fast-track).
|
|
484
|
+
* - On success at any attempt, resets the counter ; on attempt 5
|
|
485
|
+
* failure, emits the error envelope and stops retrying for the
|
|
486
|
+
* current cycle (waiting for the next identity change to rearm).
|
|
487
|
+
*/
|
|
488
|
+
export function startSchemaRetryLoop() {
|
|
489
|
+
if (retryOrchestratorActive)
|
|
490
|
+
return stopSchemaRetryLoop;
|
|
491
|
+
retryOrchestratorActive = true;
|
|
492
|
+
const initialState = previewRuntime.getState();
|
|
493
|
+
let prevHttpClient = initialState.httpClient;
|
|
494
|
+
let prevProjectId = initialState.identity?.projectId ?? null;
|
|
495
|
+
retryUnsubscribe = previewRuntime.subscribe(() => {
|
|
496
|
+
const s = previewRuntime.getState();
|
|
497
|
+
const nextHttpClient = s.httpClient;
|
|
498
|
+
const nextProjectId = s.identity?.projectId ?? null;
|
|
499
|
+
const httpClientChanged = nextHttpClient !== prevHttpClient;
|
|
500
|
+
const projectIdChanged = nextProjectId !== prevProjectId;
|
|
501
|
+
if (!httpClientChanged && !projectIdChanged)
|
|
502
|
+
return;
|
|
503
|
+
if (projectIdChanged) {
|
|
504
|
+
// Drop the in-memory record proactively — peekSchema is now
|
|
505
|
+
// tag-aware so this is belt-and-suspenders, but explicit
|
|
506
|
+
// invalidation also resets the IDB tag-stability fast path.
|
|
507
|
+
invalidateSchema();
|
|
508
|
+
}
|
|
509
|
+
_resetRetryCycle();
|
|
510
|
+
prevHttpClient = nextHttpClient;
|
|
511
|
+
prevProjectId = nextProjectId;
|
|
512
|
+
// Rearm if preconditions are now met. Stamp the schedule with the
|
|
513
|
+
// CURRENT generation (post-reset, post-bump) so any in-flight
|
|
514
|
+
// attempt from the prior cycle bails on the post-await check.
|
|
515
|
+
if (nextHttpClient !== null && nextProjectId !== null) {
|
|
516
|
+
_scheduleAttempt(1, retryCycleGeneration);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
// Initial kick — schedule attempt 1 if preconditions are already met.
|
|
520
|
+
if (prevHttpClient !== null && prevProjectId !== null) {
|
|
521
|
+
_scheduleAttempt(1, retryCycleGeneration);
|
|
522
|
+
}
|
|
523
|
+
return stopSchemaRetryLoop;
|
|
524
|
+
}
|
|
525
|
+
export function stopSchemaRetryLoop() {
|
|
526
|
+
if (!retryOrchestratorActive)
|
|
527
|
+
return;
|
|
528
|
+
retryOrchestratorActive = false;
|
|
529
|
+
retryUnsubscribe?.();
|
|
530
|
+
retryUnsubscribe = null;
|
|
531
|
+
_resetRetryCycle();
|
|
532
|
+
}
|
|
533
|
+
/** Test hook — read the current attempt count. */
|
|
534
|
+
export function _peekRetryAttemptForTestsOnly() {
|
|
535
|
+
return retryAttempt;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Test hook — directly install an in-memory schema record. Used by
|
|
539
|
+
* orchestrator tests that need `bootSchema()` to return a record via
|
|
540
|
+
* the fast path without going through the IDB+transport stack (which
|
|
541
|
+
* doesn't compose with `vi.useFakeTimers`).
|
|
542
|
+
*/
|
|
543
|
+
export function _setInMemoryForTestsOnly(record) {
|
|
544
|
+
inMemory = record;
|
|
545
|
+
}
|
|
546
|
+
// ─────────────────────────────────────────────
|
|
547
|
+
// Internals
|
|
548
|
+
// ─────────────────────────────────────────────
|
|
549
|
+
function readCodeVersionFromWindow() {
|
|
550
|
+
if (typeof window === 'undefined')
|
|
551
|
+
return null;
|
|
552
|
+
const raw = window
|
|
553
|
+
.__STORYSTICK_CODE_VERSION__;
|
|
554
|
+
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
|
555
|
+
}
|
|
556
|
+
function _isStale(record) {
|
|
557
|
+
return Date.now() - record.fetchedAt > TTL_MS;
|
|
558
|
+
}
|
|
559
|
+
function _keyFor(projectId, codeVersion) {
|
|
560
|
+
return `${projectId}/${codeVersion}/schema-v${STORY_SCHEMA_FORMAT_VERSION}`;
|
|
561
|
+
}
|
|
562
|
+
function _isCachedSchemaRecord(value, projectId, codeVersion) {
|
|
563
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
const record = value;
|
|
567
|
+
const schema = record.schema;
|
|
568
|
+
return record.projectId === projectId &&
|
|
569
|
+
record.codeVersion === codeVersion &&
|
|
570
|
+
typeof record.fetchedAt === 'number' &&
|
|
571
|
+
Number.isFinite(record.fetchedAt) &&
|
|
572
|
+
schema !== null &&
|
|
573
|
+
typeof schema === 'object' &&
|
|
574
|
+
!Array.isArray(schema) &&
|
|
575
|
+
typeof schema.projectId === 'string' &&
|
|
576
|
+
schema.projectId.length > 0 &&
|
|
577
|
+
schema.formatVersion === STORY_SCHEMA_FORMAT_VERSION &&
|
|
578
|
+
typeof schema.version === 'string' &&
|
|
579
|
+
schema.version.length > 0 &&
|
|
580
|
+
typeof schema.storyCount === 'number' &&
|
|
581
|
+
Number.isInteger(schema.storyCount) &&
|
|
582
|
+
typeof schema.pushedAt === 'number' &&
|
|
583
|
+
Number.isFinite(schema.pushedAt);
|
|
584
|
+
}
|
|
585
|
+
// ── IndexedDB — minimal wrapper. Separate DB from Layer1Cache so each
|
|
586
|
+
// has its own lifecycle (amendment §8.8). ──────────────────────────
|
|
587
|
+
function _openDb() {
|
|
588
|
+
if (typeof indexedDB === 'undefined') {
|
|
589
|
+
return Promise.resolve(null);
|
|
590
|
+
}
|
|
591
|
+
return new Promise((resolve) => {
|
|
592
|
+
let req;
|
|
593
|
+
try {
|
|
594
|
+
req = indexedDB.open(IDB_NAME, IDB_VERSION);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
resolve(null);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
req.onupgradeneeded = () => {
|
|
601
|
+
const db = req.result;
|
|
602
|
+
if (db.objectStoreNames.contains(IDB_STORE)) {
|
|
603
|
+
db.deleteObjectStore(IDB_STORE);
|
|
604
|
+
}
|
|
605
|
+
db.createObjectStore(IDB_STORE);
|
|
606
|
+
};
|
|
607
|
+
req.onsuccess = () => resolve(req.result);
|
|
608
|
+
req.onerror = () => resolve(null);
|
|
609
|
+
req.onblocked = () => resolve(null);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
async function _loadFromIdb(projectId, codeVersion) {
|
|
613
|
+
const db = await _openDb();
|
|
614
|
+
if (db === null)
|
|
615
|
+
return null;
|
|
616
|
+
try {
|
|
617
|
+
return await new Promise((resolve) => {
|
|
618
|
+
const tx = db.transaction(IDB_STORE, 'readonly');
|
|
619
|
+
const store = tx.objectStore(IDB_STORE);
|
|
620
|
+
const req = store.get(_keyFor(projectId, codeVersion));
|
|
621
|
+
req.onsuccess = () => {
|
|
622
|
+
const value = req.result;
|
|
623
|
+
resolve(_isCachedSchemaRecord(value, projectId, codeVersion) ? value : null);
|
|
624
|
+
};
|
|
625
|
+
req.onerror = () => resolve(null);
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
finally {
|
|
629
|
+
db.close();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async function _saveToIdb(record) {
|
|
633
|
+
const db = await _openDb();
|
|
634
|
+
if (db === null)
|
|
635
|
+
return;
|
|
636
|
+
try {
|
|
637
|
+
await new Promise((resolve) => {
|
|
638
|
+
const tx = db.transaction(IDB_STORE, 'readwrite');
|
|
639
|
+
const store = tx.objectStore(IDB_STORE);
|
|
640
|
+
store.put(record, _keyFor(record.projectId, record.codeVersion));
|
|
641
|
+
tx.oncomplete = () => resolve();
|
|
642
|
+
tx.onerror = () => resolve();
|
|
643
|
+
tx.onabort = () => resolve();
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
finally {
|
|
647
|
+
db.close();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async function _clearIdb() {
|
|
651
|
+
const db = await _openDb();
|
|
652
|
+
if (db === null)
|
|
653
|
+
return;
|
|
654
|
+
try {
|
|
655
|
+
await new Promise((resolve) => {
|
|
656
|
+
const tx = db.transaction(IDB_STORE, 'readwrite');
|
|
657
|
+
const store = tx.objectStore(IDB_STORE);
|
|
658
|
+
store.clear();
|
|
659
|
+
tx.oncomplete = () => resolve();
|
|
660
|
+
tx.onerror = () => resolve();
|
|
661
|
+
tx.onabort = () => resolve();
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
db.close();
|
|
666
|
+
}
|
|
667
|
+
}
|