@storystick/preview 0.0.0-stage → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/LICENSE +112 -0
  2. package/PUBLIC-API.md +56 -0
  3. package/README.md +42 -2
  4. package/dist/.source-hash +1 -0
  5. package/dist/authoring/runtime-contract.d.ts +92 -0
  6. package/dist/authoring/runtime-contract.js +28 -0
  7. package/dist/channel-taxonomy.d.ts +17 -0
  8. package/dist/channel-taxonomy.js +46 -0
  9. package/dist/context-command.d.ts +18 -0
  10. package/dist/context-command.js +38 -0
  11. package/dist/dev/suspicious-codeword-detector.d.ts +15 -0
  12. package/dist/dev/suspicious-codeword-detector.js +220 -0
  13. package/dist/dev/suspicious-codeword-detector.noop.d.ts +3 -0
  14. package/dist/dev/suspicious-codeword-detector.noop.js +4 -0
  15. package/dist/framework/browser-context.d.ts +2 -0
  16. package/dist/framework/browser-context.js +2 -0
  17. package/dist/framework/generation-refresh.d.ts +23 -0
  18. package/dist/framework/generation-refresh.js +82 -0
  19. package/dist/framework/generation-signal.d.ts +8 -0
  20. package/dist/framework/generation-signal.js +17 -0
  21. package/dist/framework/projection-generation-watch.d.ts +5 -0
  22. package/dist/framework/projection-generation-watch.js +53 -0
  23. package/dist/framework/render-receipt.d.ts +8 -0
  24. package/dist/framework/render-receipt.js +10 -0
  25. package/dist/framework/runtime-adapter-store.d.ts +30 -0
  26. package/dist/framework/runtime-adapter-store.js +81 -0
  27. package/dist/grant-coordinator.d.ts +53 -0
  28. package/dist/grant-coordinator.js +329 -0
  29. package/dist/grant-lifecycle.d.ts +52 -0
  30. package/dist/grant-lifecycle.js +74 -0
  31. package/dist/identity/__test-adapter.d.ts +34 -0
  32. package/dist/identity/__test-adapter.js +59 -0
  33. package/dist/identity/browser-context-gate.d.ts +4 -0
  34. package/dist/identity/browser-context-gate.js +22 -0
  35. package/dist/identity/browser-context.d.ts +13 -0
  36. package/dist/identity/browser-context.js +22 -0
  37. package/dist/identity/carriers/postmessage.d.ts +84 -0
  38. package/dist/identity/carriers/postmessage.js +177 -0
  39. package/dist/identity/carriers/storage-state.d.ts +41 -0
  40. package/dist/identity/carriers/storage-state.js +99 -0
  41. package/dist/identity/carriers/storage.d.ts +39 -0
  42. package/dist/identity/carriers/storage.js +182 -0
  43. package/dist/identity/carriers/url.d.ts +19 -0
  44. package/dist/identity/carriers/url.js +38 -0
  45. package/dist/identity/identity-state-machine.d.ts +70 -0
  46. package/dist/identity/identity-state-machine.js +244 -0
  47. package/dist/identity/identity-types.d.ts +192 -0
  48. package/dist/identity/identity-types.js +22 -0
  49. package/dist/identity/index.d.ts +18 -0
  50. package/dist/identity/index.js +16 -0
  51. package/dist/identity/preview-cookie.d.ts +9 -0
  52. package/dist/identity/preview-cookie.js +12 -0
  53. package/dist/identity/preview-draft.d.ts +6 -0
  54. package/dist/identity/preview-draft.js +25 -0
  55. package/dist/identity/preview-identity.d.ts +28 -0
  56. package/dist/identity/preview-identity.js +84 -0
  57. package/dist/identity/preview-scope.d.ts +28 -0
  58. package/dist/identity/preview-scope.js +47 -0
  59. package/dist/identity/resolver.d.ts +110 -0
  60. package/dist/identity/resolver.js +334 -0
  61. package/dist/identity/session-query.d.ts +4 -0
  62. package/dist/identity/session-query.js +19 -0
  63. package/dist/index.d.ts +90 -0
  64. package/dist/index.js +788 -0
  65. package/dist/interaction-mode.d.ts +58 -0
  66. package/dist/interaction-mode.js +93 -0
  67. package/dist/nav-handler.d.ts +95 -0
  68. package/dist/nav-handler.js +84 -0
  69. package/dist/overlay-label.d.ts +79 -0
  70. package/dist/overlay-label.js +73 -0
  71. package/dist/page-presentation.d.ts +24 -0
  72. package/dist/page-presentation.js +32 -0
  73. package/dist/provenance/anchor-map.d.ts +95 -0
  74. package/dist/provenance/anchor-map.js +177 -0
  75. package/dist/provenance/carrier-plan.d.ts +51 -0
  76. package/dist/provenance/carrier-plan.js +90 -0
  77. package/dist/provenance/coded-text.d.ts +20 -0
  78. package/dist/provenance/coded-text.js +302 -0
  79. package/dist/provenance/direct-edit-locator.d.ts +29 -0
  80. package/dist/provenance/direct-edit-locator.js +218 -0
  81. package/dist/provenance/editable-leaf.d.ts +134 -0
  82. package/dist/provenance/editable-leaf.js +483 -0
  83. package/dist/provenance/editor-target.d.ts +29 -0
  84. package/dist/provenance/editor-target.js +46 -0
  85. package/dist/provenance/field-leaf-walker.d.ts +21 -0
  86. package/dist/provenance/field-leaf-walker.js +98 -0
  87. package/dist/provenance/frame-dependencies.d.ts +212 -0
  88. package/dist/provenance/frame-dependencies.js +550 -0
  89. package/dist/provenance/index.d.ts +26 -0
  90. package/dist/provenance/index.js +26 -0
  91. package/dist/provenance/local-events.d.ts +1 -0
  92. package/dist/provenance/local-events.js +1 -0
  93. package/dist/provenance/markers.d.ts +21 -0
  94. package/dist/provenance/markers.js +70 -0
  95. package/dist/provenance/multi-frame-anchor-map.d.ts +54 -0
  96. package/dist/provenance/multi-frame-anchor-map.js +109 -0
  97. package/dist/provenance/normalize.d.ts +2 -0
  98. package/dist/provenance/normalize.js +19 -0
  99. package/dist/provenance/overlay-focus-echo.d.ts +17 -0
  100. package/dist/provenance/overlay-focus-echo.js +16 -0
  101. package/dist/provenance/overlay-projection.d.ts +65 -0
  102. package/dist/provenance/overlay-projection.js +201 -0
  103. package/dist/provenance/owner-map.d.ts +55 -0
  104. package/dist/provenance/owner-map.js +239 -0
  105. package/dist/provenance/read-carrier.d.ts +22 -0
  106. package/dist/provenance/read-carrier.js +92 -0
  107. package/dist/provenance/recorder.d.ts +38 -0
  108. package/dist/provenance/recorder.js +82 -0
  109. package/dist/provenance/render-context.d.ts +106 -0
  110. package/dist/provenance/render-context.js +338 -0
  111. package/dist/provenance/reveal-contract.d.ts +44 -0
  112. package/dist/provenance/reveal-contract.js +1 -0
  113. package/dist/provenance/richtext-slice-embed-address.d.ts +20 -0
  114. package/dist/provenance/richtext-slice-embed-address.js +35 -0
  115. package/dist/provenance/runtime-script.d.ts +1 -0
  116. package/dist/provenance/runtime-script.js +2217 -0
  117. package/dist/provenance/sidecar-store.d.ts +63 -0
  118. package/dist/provenance/sidecar-store.js +151 -0
  119. package/dist/provenance/sidecar.d.ts +34 -0
  120. package/dist/provenance/sidecar.js +227 -0
  121. package/dist/provenance/slice-model.d.ts +28 -0
  122. package/dist/provenance/slice-model.js +86 -0
  123. package/dist/provenance/source-identity.d.ts +3 -0
  124. package/dist/provenance/source-identity.js +7 -0
  125. package/dist/provenance/source-map-materializer.d.ts +76 -0
  126. package/dist/provenance/source-map-materializer.js +286 -0
  127. package/dist/provenance/source-registry.d.ts +103 -0
  128. package/dist/provenance/source-registry.js +562 -0
  129. package/dist/provenance/transformed-output-gate.d.ts +161 -0
  130. package/dist/provenance/transformed-output-gate.js +171 -0
  131. package/dist/provenance/types.d.ts +102 -0
  132. package/dist/provenance/types.js +1 -0
  133. package/dist/refresh-policy.d.ts +11 -0
  134. package/dist/refresh-policy.js +14 -0
  135. package/dist/reveal-target.d.ts +8 -0
  136. package/dist/reveal-target.js +47 -0
  137. package/dist/route-paths.d.ts +11 -0
  138. package/dist/route-paths.js +14 -0
  139. package/dist/router-subscriber.d.ts +92 -0
  140. package/dist/router-subscriber.js +44 -0
  141. package/dist/runtime/anchor-types.d.ts +5 -0
  142. package/dist/runtime/anchor-types.js +1 -0
  143. package/dist/runtime/authority-store.d.ts +31 -0
  144. package/dist/runtime/authority-store.js +56 -0
  145. package/dist/runtime/boot.d.ts +52 -0
  146. package/dist/runtime/boot.js +379 -0
  147. package/dist/runtime/content-refresh.d.ts +30 -0
  148. package/dist/runtime/content-refresh.js +68 -0
  149. package/dist/runtime/emit-runtime-error.d.ts +51 -0
  150. package/dist/runtime/emit-runtime-error.js +64 -0
  151. package/dist/runtime/field-proxy.d.ts +112 -0
  152. package/dist/runtime/field-proxy.js +471 -0
  153. package/dist/runtime/form-contract-reporter.d.ts +99 -0
  154. package/dist/runtime/form-contract-reporter.js +391 -0
  155. package/dist/runtime/invalidation-adapter.d.ts +17 -0
  156. package/dist/runtime/invalidation-adapter.js +42 -0
  157. package/dist/runtime/live-query-overlay.d.ts +39 -0
  158. package/dist/runtime/live-query-overlay.js +345 -0
  159. package/dist/runtime/momentary-navigate.d.ts +16 -0
  160. package/dist/runtime/momentary-navigate.js +106 -0
  161. package/dist/runtime/observation-scheduler.d.ts +15 -0
  162. package/dist/runtime/observation-scheduler.js +42 -0
  163. package/dist/runtime/perf-metrics.d.ts +28 -0
  164. package/dist/runtime/perf-metrics.js +90 -0
  165. package/dist/runtime/preview-interaction-policy.d.ts +1 -0
  166. package/dist/runtime/preview-interaction-policy.js +103 -0
  167. package/dist/runtime/preview-overlay-painter.d.ts +27 -0
  168. package/dist/runtime/preview-overlay-painter.js +1732 -0
  169. package/dist/runtime/preview-overlay-sidecar.d.ts +26 -0
  170. package/dist/runtime/preview-overlay-sidecar.js +45 -0
  171. package/dist/runtime/preview-perspective-alignment.d.ts +15 -0
  172. package/dist/runtime/preview-perspective-alignment.js +40 -0
  173. package/dist/runtime/preview-perspective-intent-state.d.ts +23 -0
  174. package/dist/runtime/preview-perspective-intent-state.js +5 -0
  175. package/dist/runtime/preview-transition-state.d.ts +142 -0
  176. package/dist/runtime/preview-transition-state.js +257 -0
  177. package/dist/runtime/render-mode-state.d.ts +51 -0
  178. package/dist/runtime/render-mode-state.js +76 -0
  179. package/dist/runtime/request-id.d.ts +4 -0
  180. package/dist/runtime/request-id.js +8 -0
  181. package/dist/runtime/resolve-schema-field.d.ts +99 -0
  182. package/dist/runtime/resolve-schema-field.js +356 -0
  183. package/dist/runtime/runtime-error-dispatch.d.ts +23 -0
  184. package/dist/runtime/runtime-error-dispatch.js +75 -0
  185. package/dist/runtime/schema-store.d.ts +121 -0
  186. package/dist/runtime/schema-store.js +667 -0
  187. package/dist/runtime/snapshot-assembly.d.ts +104 -0
  188. package/dist/runtime/snapshot-assembly.js +553 -0
  189. package/dist/runtime/snapshot-reporter.d.ts +55 -0
  190. package/dist/runtime/snapshot-reporter.js +375 -0
  191. package/dist/runtime/standalone-overlay-controller.d.ts +33 -0
  192. package/dist/runtime/standalone-overlay-controller.js +281 -0
  193. package/dist/runtime/store.d.ts +307 -0
  194. package/dist/runtime/store.js +1026 -0
  195. package/dist/runtime/windowed-minimap-projection.d.ts +28 -0
  196. package/dist/runtime/windowed-minimap-projection.js +189 -0
  197. package/dist/runtime/workspace-shortcuts.d.ts +30 -0
  198. package/dist/runtime/workspace-shortcuts.js +188 -0
  199. package/dist/sdk-error-copy.d.ts +48 -0
  200. package/dist/sdk-error-copy.js +182 -0
  201. package/dist/select-cursor.d.ts +2 -0
  202. package/dist/select-cursor.js +23 -0
  203. package/dist/server/admission-cookie.d.ts +38 -0
  204. package/dist/server/admission-cookie.js +122 -0
  205. package/dist/server/control-client.d.ts +29 -0
  206. package/dist/server/control-client.js +22 -0
  207. package/dist/server/control-errors.d.ts +22 -0
  208. package/dist/server/control-errors.js +182 -0
  209. package/dist/server/draft-route.d.ts +12 -0
  210. package/dist/server/draft-route.js +45 -0
  211. package/dist/server/extract-token.d.ts +30 -0
  212. package/dist/server/extract-token.js +98 -0
  213. package/dist/server/grant.d.ts +128 -0
  214. package/dist/server/grant.js +395 -0
  215. package/dist/server/ordinary-read-provenance.d.ts +11 -0
  216. package/dist/server/ordinary-read-provenance.js +124 -0
  217. package/dist/server/preview-read-metadata.d.ts +10 -0
  218. package/dist/server/preview-read-metadata.js +27 -0
  219. package/dist/server/provenance-frame-builder.d.ts +53 -0
  220. package/dist/server/provenance-frame-builder.js +102 -0
  221. package/dist/server/provenance-runtime.d.ts +70 -0
  222. package/dist/server/provenance-runtime.js +46 -0
  223. package/dist/server/read-context-command.d.ts +11 -0
  224. package/dist/server/read-context-command.js +53 -0
  225. package/dist/server/read-identity.d.ts +3 -0
  226. package/dist/server/read-identity.js +63 -0
  227. package/dist/server/read-lifecycle.d.ts +17 -0
  228. package/dist/server/read-lifecycle.js +52 -0
  229. package/dist/server/render-frame-provenance.d.ts +133 -0
  230. package/dist/server/render-frame-provenance.js +676 -0
  231. package/dist/server/render-id.d.ts +15 -0
  232. package/dist/server/render-id.js +22 -0
  233. package/dist/server/route-document-provenance.d.ts +77 -0
  234. package/dist/server/route-document-provenance.js +1327 -0
  235. package/dist/server/route-session-cookie.d.ts +33 -0
  236. package/dist/server/route-session-cookie.js +54 -0
  237. package/dist/session-projections.d.ts +33 -0
  238. package/dist/session-projections.js +43 -0
  239. package/dist/session-subscriber.d.ts +124 -0
  240. package/dist/session-subscriber.js +79 -0
  241. package/dist/sha256-fallback.d.ts +30 -0
  242. package/dist/sha256-fallback.js +126 -0
  243. package/dist/types.d.ts +446 -0
  244. package/dist/types.js +85 -0
  245. package/dist/validators.d.ts +61 -0
  246. package/dist/validators.js +110 -0
  247. package/package.json +210 -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
+ }