@doync/client 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/adapter.cjs +1 -0
  4. package/dist/adapter.d.cts +86 -0
  5. package/dist/adapter.d.cts.map +1 -0
  6. package/dist/adapter.d.ts +86 -0
  7. package/dist/adapter.d.ts.map +1 -0
  8. package/dist/adapter.js +2 -0
  9. package/dist/adapter.js.map +1 -0
  10. package/dist/client-C6jAdhbe.cjs +15 -0
  11. package/dist/client-CNyLMCw0.d.ts +812 -0
  12. package/dist/client-CNyLMCw0.d.ts.map +1 -0
  13. package/dist/client-ClV8ce6X.js +16 -0
  14. package/dist/client-ClV8ce6X.js.map +1 -0
  15. package/dist/client-DHXO0dbf.d.cts +812 -0
  16. package/dist/client-DHXO0dbf.d.cts.map +1 -0
  17. package/dist/index.cjs +0 -0
  18. package/dist/index.d.cts +2 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +0 -0
  21. package/dist/internal.cjs +1 -0
  22. package/dist/internal.d.cts +69 -0
  23. package/dist/internal.d.cts.map +1 -0
  24. package/dist/internal.d.ts +69 -0
  25. package/dist/internal.d.ts.map +1 -0
  26. package/dist/internal.js +2 -0
  27. package/dist/internal.js.map +1 -0
  28. package/package.json +79 -0
  29. package/src/adapter.ts +25 -0
  30. package/src/client-mutation-registry.ts +31 -0
  31. package/src/client.ts +100 -0
  32. package/src/engine.ts +3322 -0
  33. package/src/identity.ts +41 -0
  34. package/src/index.ts +36 -0
  35. package/src/internal.ts +37 -0
  36. package/src/migrate.ts +156 -0
  37. package/src/mutations.ts +96 -0
  38. package/src/port.ts +74 -0
  39. package/src/raw-read.ts +135 -0
  40. package/src/replica/db/0000_replica_engine_v0.sql +20 -0
  41. package/src/replica/db/0001_release_stamps.sql +6 -0
  42. package/src/replica/db/meta/0000_snapshot.json +129 -0
  43. package/src/replica/db/meta/0001_snapshot.json +167 -0
  44. package/src/replica/db/meta/_journal.json +20 -0
  45. package/src/replica/db/schema.ts +67 -0
  46. package/src/replica/index.ts +185 -0
  47. package/src/replica/meta.ts +26 -0
  48. package/src/replica/stamps.ts +102 -0
  49. package/src/replica/track.ts +190 -0
  50. package/src/socket-reconnect.ts +242 -0
  51. package/src/socket.ts +44 -0
  52. package/src/sql-raw.d.ts +4 -0
package/src/engine.ts ADDED
@@ -0,0 +1,3322 @@
1
+ import type {
2
+ BoundQuery,
3
+ DoyncSchema,
4
+ MutationDefinition,
5
+ RowDecoder,
6
+ SqlValue,
7
+ } from '@doync/core'
8
+ import type {
9
+ DesiredQuery,
10
+ InstanceInfo,
11
+ OnceEndMessage,
12
+ Patch,
13
+ PksPatch,
14
+ SchemaDirectiveMessage,
15
+ SchemaSkewMessage,
16
+ ServerMessage,
17
+ } from '@doync/core/internal'
18
+
19
+ import { isBoundQuery } from '@doync/core/internal'
20
+ import {
21
+ assertWireRepresentable,
22
+ canonicalizeArgsBinary,
23
+ declaredSchemaVersion,
24
+ decodeArgs,
25
+ decodeImageValue,
26
+ encodeArgs,
27
+ isInternalTable,
28
+ } from '@doync/core/internal'
29
+
30
+ import type { ClientMutationRegistry, ClientMutationTx } from './mutations'
31
+ import type { LocalDb } from './port'
32
+ import type { SeamStatus, SyncSocket } from './socket'
33
+
34
+ import { asClientMutation, validateMutationArgs } from './mutations'
35
+ import { RawRead, type LocalStatement } from './raw-read'
36
+ import {
37
+ applyBundledMigrations,
38
+ assertNoReservedTables,
39
+ clearReleaseStamps,
40
+ createEngineTables,
41
+ decodePatchImage,
42
+ deleteReleaseStamp,
43
+ dropConsumerTables,
44
+ listReleaseStamps,
45
+ parsePk,
46
+ pkWhereClause,
47
+ quoteIdent,
48
+ readMeta,
49
+ readReleaseStamp,
50
+ replayMigrations,
51
+ requireTable,
52
+ resolveReleaseTtlMs,
53
+ upsertReleaseStamp,
54
+ writeMeta,
55
+ type ReleaseStamp,
56
+ } from './replica'
57
+
58
+ /**
59
+ * What `mutate()` returns: `client` settles when the optimistic body applies
60
+ * locally (rejects if the body throws or args fail validation); `server`
61
+ * settles when the Origin confirms or rejects the mutation. Render off
62
+ * `client`; await `server` when you need authoritative confirmation.
63
+ */
64
+ export interface MutationResult {
65
+ readonly client: Promise<void>
66
+ readonly server: Promise<void>
67
+ }
68
+
69
+ // ADR-0022; closeio/doync#135/#139 (durable per-identity logout retention).
70
+ /**
71
+ * What happens to this identity's local store on logout: `keep` leaves it so
72
+ * unsynced writes await the next login (default); `forget` erases it
73
+ * (shared-computer / privacy). Set at construction or via
74
+ * {@link DoyncClient.setLogoutBehavior}.
75
+ */
76
+ export type LogoutBehavior = 'keep' | 'forget'
77
+
78
+ /**
79
+ * The `__doync_meta` key the durable {@link LogoutBehavior} lives under
80
+ * (closeio/doync#135) — shared by the direct engine's
81
+ * {@link ClientEngine.setLogoutBehavior} / `createClient` write and the web DB
82
+ * worker's per-identity write + boot read, so the ends can never drift.
83
+ */
84
+ export const __LOGOUT_BEHAVIOR_META_KEY = 'logout_behavior'
85
+
86
+ /**
87
+ * The `__doync_meta` key the Client's durable connected-time counter lives
88
+ * under (ADR-0014 client-half addendum / closeio/doync#223). Blob-typed
89
+ * store-as-bound: no engine-track migration. Package-internal — release stamps
90
+ * (#224) and membership GC read it through the engine, not this key.
91
+ */
92
+ export const __CONNECTED_CLOCK_META_KEY = 'connected_clock'
93
+
94
+ // ADR-0020/0022 (schema-skew + recovery surface).
95
+ /**
96
+ * Schema / recovery states the UI can show as a banner:
97
+ *
98
+ * - `reload` — client bundle cannot understand the server's shape; reload the app
99
+ * after deploying a matching client. Terminal until reload.
100
+ * - `server-behind` — client is ahead of a mid-deploy Mirror; the client backs
101
+ * off and re-handshakes automatically.
102
+ * - `resync` — local replica/memberships wiped and rebuilding; pending writes and
103
+ * clientId survive. Transient; clears when sync resumes.
104
+ * - `forget` — local store erased (including identity); boots as a fresh client.
105
+ * Transient; clears when sync resumes.
106
+ */
107
+ export type SchemaEventKind = 'reload' | 'server-behind' | 'resync' | 'forget'
108
+
109
+ /**
110
+ * One schema-status transition: `kind` plus a human-readable `message` for
111
+ * diagnostics (not a control signal). `null` on the client means nominal.
112
+ */
113
+ export interface SchemaEvent {
114
+ readonly kind: SchemaEventKind
115
+ /** Human-readable reason (diagnostics; never a control signal). */
116
+ readonly message: string
117
+ }
118
+
119
+ /** Options for one `mutate()` call. */
120
+ export interface MutationOptions {
121
+ /**
122
+ * Idempotency key: a second `mutate()` with a key already in flight (or
123
+ * already settled) returns the same `{client, server}` pair and enqueues
124
+ * nothing, so a retried submit never double-writes.
125
+ */
126
+ readonly key?: string
127
+ }
128
+
129
+ // ADR-0021 addendum; closeio/doync#104 (honest View status lifecycle).
130
+ /**
131
+ * Whether a view's rows have been server-confirmed:
132
+ *
133
+ * - `unknown` — local answer only (fresh subscribe, skip, or reconnect). Rows may
134
+ * already be present; this speaks to confirmation, not emptiness.
135
+ * - `complete` — server has confirmed this subscription's rows up to the current
136
+ * sync point. Empty results can still be `complete`.
137
+ * - `error` — the Mirror could not honor the subscribe; detail on
138
+ * {@link ViewStatus.error}.
139
+ */
140
+ export type QueryStatus = 'unknown' | 'complete' | 'error'
141
+
142
+ /**
143
+ * A view's status snapshot. The object reference is stable until the visible
144
+ * status changes, so it is safe for `useSyncExternalStore`.
145
+ */
146
+ export interface ViewStatus {
147
+ readonly status: QueryStatus
148
+ /** Subscribe-failure detail; present only when `status` is `error`. */
149
+ readonly error?: Error
150
+ }
151
+
152
+ // closeio/doync#102/#105/#136 (connection status surface).
153
+ /**
154
+ * Client ↔ Mirror connection state (backing for `useConnectionStatus`):
155
+ *
156
+ * - `connecting` — a (re)connect attempt is in flight
157
+ * - `connected` — socket open, sync live
158
+ * - `disconnected` — dropped and backing off
159
+ * - `error` — transport error
160
+ * - `needs-auth` — Mirror rejected auth; refresh credentials before sync resumes
161
+ */
162
+ export type ConnectionStatus =
163
+ | 'connecting'
164
+ | 'connected'
165
+ | 'disconnected'
166
+ | 'error'
167
+ | 'needs-auth'
168
+
169
+ // ADR-0019/0021/0023; closeio/doync#104/#120/#131/#137 (desire layer, Warm pool, status snapshots).
170
+ /**
171
+ * A live, shared handle on a query's rows — what `subscribe()` and `local()`
172
+ * return, and what `useQuery` renders from.
173
+ *
174
+ * `current()` returns the latest rows; the array reference only changes when
175
+ * the rows do, so it is safe for `useSyncExternalStore`. `onChange` fires when
176
+ * the rows or the visible status move; re-read both `current()` and `status()`
177
+ * in the listener. A view keeps serving its last rows through disconnects and
178
+ * errors — check `status()` to tell fresh from stale.
179
+ *
180
+ * Lifecycle: creating a view is free and owns nothing. Call `retain()` when
181
+ * your component mounts, `release()` when it unmounts (both idempotent). Views
182
+ * for the same query share one subscription automatically.
183
+ */
184
+ export interface View<
185
+ Row extends Record<string, unknown> = Record<string, SqlValue>,
186
+ > {
187
+ current(): readonly Row[]
188
+ onChange(listener: () => void): () => void
189
+ /**
190
+ * Take ownership of this handle (call from a mount effect, never during
191
+ * render). Idempotent per handle.
192
+ */
193
+ retain(): void
194
+ /**
195
+ * Drop ownership (call from unmount cleanup). The last release unsubscribes
196
+ * upstream; this handle keeps serving its last snapshot. Idempotent.
197
+ */
198
+ release(): void
199
+ /** Latest status snapshot. Moves ride the same `onChange` as row changes. */
200
+ status(): ViewStatus
201
+ /**
202
+ * `true` when the query is one-row (`` sql.one`…` `` / Drizzle `findFirst`),
203
+ * so `useQuery` unwraps to `Row | undefined`. `false` for multi-row;
204
+ * `undefined` when one-ness is not yet known.
205
+ */
206
+ readonly one?: boolean
207
+ }
208
+
209
+ // ADR-0012/0021/0023 (Once = cache-and-network, no ongoing subscription).
210
+ /**
211
+ * A one-shot cache-and-network read — what `once()` returns and `useQueryOnce`
212
+ * renders from. `current()` is the local cache immediately; `server` resolves
213
+ * with the Mirror's answer (and updates the snapshot). Not reactive to later
214
+ * local writes. Call `dispose()` when done; a quick remount within a short
215
+ * grace keeps the same promise and in-flight request (StrictMode-safe).
216
+ */
217
+ export interface OnceView<
218
+ Row extends Record<string, unknown> = Record<string, SqlValue>,
219
+ > {
220
+ current(): readonly Row[]
221
+ onChange(listener: () => void): () => void
222
+ dispose(): void
223
+ /** Resolves with the server's answer (network half of cache-and-network). */
224
+ readonly server: Promise<readonly Row[]>
225
+ }
226
+
227
+ /**
228
+ * The client call surface: `subscribe` / `once` / `local` / `mutate`, plus
229
+ * connection and schema status. Platform adapters (`@doync/web`,
230
+ * `@doync/mobile`) implement this; `@doync/react` hooks adapt over it.
231
+ *
232
+ * Query-taking methods accept a {@link BoundQuery} (from a registered query
233
+ * call) or {@link FalsyQuery} ("no query"). `options.skip` is also supported.
234
+ */
235
+ export interface DoyncClient {
236
+ /**
237
+ * Live subscription for a bound query (`queries.foo(args)`). Falsy or
238
+ * `options.skip` yields an inert view (empty rows, status `unknown`).
239
+ */
240
+ subscribe<Row extends Record<string, unknown> = Record<string, SqlValue>>(
241
+ query: BoundQuery<Row, boolean> | FalsyQuery,
242
+ options?: SubscribeOptions,
243
+ ): View<Row>
244
+ /**
245
+ * One-shot cache-and-network read for a bound query. Falsy never starts a
246
+ * network half.
247
+ */
248
+ once<Row extends Record<string, unknown> = Record<string, SqlValue>>(
249
+ query: BoundQuery<Row, boolean> | FalsyQuery,
250
+ ): OnceView<Row>
251
+ /**
252
+ * Live local-only read of raw SQL against the replica (no server
253
+ * subscription). Same retain/release lifecycle as {@link subscribe}.
254
+ */
255
+ local<Row extends Record<string, unknown> = Record<string, SqlValue>>(
256
+ sql: string,
257
+ ...params: SqlValue[]
258
+ ): View<Row>
259
+ /**
260
+ * Apply a registered mutation optimistically and push it to the Origin. Pass
261
+ * the {@link MutationDefinition} from your mutations tree; args are
262
+ * type-checked from the definition. Returns {@link MutationResult}.
263
+ */
264
+ mutate<Args = unknown>(
265
+ mutation: MutationDefinition<Args>,
266
+ args: Args,
267
+ options?: MutationOptions,
268
+ ): MutationResult
269
+ /** Current schema/recovery state, or `null` when nominal. */
270
+ readonly schemaStatus: SchemaEvent | null
271
+ /**
272
+ * Subscribe to schema-status transitions (including a clear back to nominal).
273
+ * Returns the unsubscribe function.
274
+ */
275
+ onSchemaChange(listener: () => void): () => void
276
+ /**
277
+ * Wipe the replica and resubscribe while keeping clientId and pending writes.
278
+ * Safe "my local data looks wrong" refresh. Optional on the interface —
279
+ * platform clients implement it.
280
+ */
281
+ resync?(): void
282
+ /**
283
+ * Erase this identity's local data (replica, pendings, identity). Privacy /
284
+ * logout-forget path. Optional on the interface — platform clients implement
285
+ * it.
286
+ */
287
+ forget?(): void
288
+ /**
289
+ * Durably set {@link LogoutBehavior} at runtime (e.g. a "remember me"
290
+ * checkbox). Survives restart. Optional — platform clients implement it.
291
+ */
292
+ setLogoutBehavior?(behavior: LogoutBehavior): void
293
+ /**
294
+ * Authenticated identity, or `null` when anonymous. Updated by auth refresh;
295
+ * never derived from a bearer token by the library.
296
+ */
297
+ readonly userId: string | null
298
+ /**
299
+ * Warm the replica for a query without creating a local view — rows flow in
300
+ * for other queries that read the same tables. Falsy yields a no-op handle.
301
+ * Call `cleanup()` to release (often never, for a session-long preload).
302
+ */
303
+ preload(
304
+ query: BoundQuery | FalsyQuery,
305
+ options?: PreloadOptions,
306
+ ): PreloadHandle
307
+ /** Current {@link ConnectionStatus} to the Mirror. */
308
+ readonly connectionStatus: ConnectionStatus
309
+ /** Subscribe to connection-status transitions. Returns the unsubscribe. */
310
+ onConnectionChange(listener: () => void): () => void
311
+ }
312
+
313
+ /**
314
+ * Falsy "no query" on subscribe / once / preload: `false | null | undefined`.
315
+ * Lets `cond && query(args)` and optional-prop patterns type-check.
316
+ */
317
+ export type FalsyQuery = false | null | undefined
318
+
319
+ /** Per-subscribe options. */
320
+ export interface SubscribeOptions {
321
+ /**
322
+ * How long after unmount the server keeps this subscription warm, in ms of
323
+ * connected time. Absent ⇒ server default (clamped to its ceiling).
324
+ */
325
+ readonly ttl?: number
326
+ /**
327
+ * Skip the subscribe: empty rows, status `unknown`, no network. Use for
328
+ * conditional queries under React's unconditional-hooks rule.
329
+ */
330
+ readonly skip?: boolean
331
+ }
332
+
333
+ /** Options for {@link DoyncClient.preload}. */
334
+ export interface PreloadOptions {
335
+ /**
336
+ * Connected-time grace after cleanup before the server drops the warm
337
+ * subscription, in ms. Absent ⇒ server default.
338
+ */
339
+ readonly ttl?: number
340
+ }
341
+
342
+ /** Handle returned by {@link DoyncClient.preload}. */
343
+ export interface PreloadHandle {
344
+ /**
345
+ * Release the preload. Idempotent; typically unused for a session-long
346
+ * preload.
347
+ */
348
+ cleanup(): void
349
+ }
350
+
351
+ export interface ClientEngineConfig {
352
+ /**
353
+ * The synchronous local-DB port (ADR-0019): node:sqlite in tests, wa-sqlite
354
+ * on the web.
355
+ */
356
+ readonly db: LocalDb
357
+ /**
358
+ * The consumer's synced schema — the shape source replayed for the replica
359
+ * (ADR-0020).
360
+ */
361
+ readonly schema: DoyncSchema
362
+ /**
363
+ * Named mutation bodies resolved on `push` — deterministic, DB-only
364
+ * (ADR-0017/0021).
365
+ */
366
+ readonly mutations: ClientMutationRegistry
367
+ /** The Mirror socket seam (ADR-0016). */
368
+ readonly socket: SyncSocket
369
+ /**
370
+ * The asserted projected auth context (ADR-0018 addendum / closeio/doync#167)
371
+ * queries and mutation bodies read. Consumer-owned; the library ships no
372
+ * decode. Anonymous `{}`.
373
+ */
374
+ readonly ctx?: Record<string, unknown>
375
+ /**
376
+ * Transport credential PRESENTED as `connect{jwt}` (ADR-0016/0018) — the
377
+ * Mirror resolves token-first. Auth must ride the HANDSHAKE, not only the
378
+ * WebSocket upgrade's Cookie header: the socket lives in the SharedWorker and
379
+ * can OUTLIVE a login. The engine NEVER derives identity from this token;
380
+ * {@link userId} and {@link ctx} are the asserted siblings.
381
+ */
382
+ readonly token?: string
383
+ /**
384
+ * The ASSERTED authenticated identity (closeio/doync#167) — drives
385
+ * {@link ClientEngine.userId}. `null` / omitted = anonymous. Never derived
386
+ * from {@link token}.
387
+ */
388
+ readonly userId?: string | null
389
+ /**
390
+ * The durable clientId (ADR-0019's respawn-double-apply trap): reused from
391
+ * `__doync_meta` when present, else this value, else generated. Provide it to
392
+ * pin identity across engine restarts over the same DB.
393
+ */
394
+ readonly clientId?: string
395
+ /**
396
+ * ClientId generator when none is stored/provided (default:
397
+ * `crypto.randomUUID`). Test-only pin — production paths always provide
398
+ * {@link clientId} or accept a random UUID.
399
+ */
400
+ readonly generateId?: () => string
401
+ /**
402
+ * Consume schema-state transitions (ADR-0020): a stale-client or above-bundle
403
+ * skew (`reload`), a client-ahead skew (`server-behind`), or a
404
+ * post-failed-migration wipe (`resync`). The UI layer reloads the app on
405
+ * `reload` and can surface a retry banner on `server-behind`; the engine
406
+ * drives the recovery itself (re-handshake / wipe). The current state is also
407
+ * readable synchronously via {@link ClientEngine.schemaStatus}.
408
+ */
409
+ readonly onSchemaEvent?: (event: SchemaEvent) => void
410
+ /**
411
+ * Wall-clock source for the connected-time counter (ADR-0014 client half /
412
+ * closeio/doync#223). Defaults to `Date.now`. Injectable so Seam A tests pin
413
+ * pong deltas without real timers; package-internal — never a public
414
+ * surface.
415
+ */
416
+ readonly now?: () => number
417
+ }
418
+
419
+ /**
420
+ * Durable pending mutation mirrored from `__doync_pending`. `args` = live
421
+ * canonicalized JS for replay (#230). `wireArgs` = reserved-key form in the
422
+ * queue and on push (ADR-0031 / #231). Reload decodes wire -> live.
423
+ */
424
+ interface PendingMutation {
425
+ readonly mutationId: number
426
+ readonly name: string
427
+ readonly args: unknown
428
+ readonly wireArgs: unknown
429
+ readonly key: string | undefined
430
+ }
431
+
432
+ /** Resolvers for one mutation's `{client, server}` pair. */
433
+ interface PendingPromise {
434
+ readonly pair: MutationResult
435
+ resolveClient: () => void
436
+ rejectClient: (error: unknown) => void
437
+ resolveServer: () => void
438
+ rejectServer: (error: unknown) => void
439
+ clientSettled: boolean
440
+ serverSettled: boolean
441
+ }
442
+
443
+ const OPTIMISTIC_SAVEPOINT = 'doync_optimistic'
444
+ const BODY_SAVEPOINT = 'doync_body'
445
+
446
+ /**
447
+ * Client engine (ADR-0019): synchronous local-DB port + socket seam +
448
+ * optimistic Layer 1.
449
+ *
450
+ * Durable base (consumer tables, `__doync_membership`, `__doync_meta`) stays
451
+ * committed. One held SAVEPOINT ({@link OPTIMISTIC_SAVEPOINT}) layers pending
452
+ * bodies in order; same-connection reads see that uncommitted state. Durable
453
+ * `__doync_pending` is written only while the overlay is closed, so an overlay
454
+ * crash loses the guess and boot replays from the queue. Every durable write
455
+ * closes the overlay (`ROLLBACK TO` → `RELEASE`), commits outside it, then
456
+ * reopens and replays survivors — overlay always equals current pendings on
457
+ * current base.
458
+ *
459
+ * Rebase, never merge: a poke rolls the guess off, applies authoritative
460
+ * patches (puts before dels; row delete only when last membership drops), drops
461
+ * rejected-then-acked pendings, persists cookie only on pokeEnd, then replays
462
+ * survivors. A rejected write vanishes by not replaying.
463
+ */
464
+ /**
465
+ * Internal confirmation phase for a desired Subscription (closeio/doync#104),
466
+ * finer than visible {@link QueryStatus}:
467
+ *
468
+ * - `pending` — subscribe/connect sent, no ack yet (visible `unknown`)
469
+ * - `acked` — `subscribeAck` seen; waiting for a `pokeEnd` that names this
470
+ * instance in `confirms` (#114) (visible `unknown`)
471
+ * - `complete` — that hydration/reactivation pokeEnd confirmed it
472
+ * - `error` — Mirror framed a subscribe failure for this instance
473
+ *
474
+ * `pending`↔`acked` share visible `unknown`, so that step neither swaps status
475
+ * references nor notifies.
476
+ */
477
+ type SubPhase = 'pending' | 'acked' | 'complete' | 'error'
478
+
479
+ /**
480
+ * Mount declaration shared by `subscribe`, `preload`, and the desire layer:
481
+ * wire name+args, declared ttl, and the resolved local statement.
482
+ */
483
+ interface SubscriptionSpec {
484
+ readonly name: string
485
+ readonly args: unknown
486
+ readonly ttl: number | undefined
487
+ readonly statement: LocalStatement
488
+ }
489
+
490
+ /**
491
+ * One desired Subscription (refcounted), keyed by statement identity. Shared by
492
+ * live subscribes and `preload` (preload = desire with no View). Wire `heldAt`
493
+ * is NOT stored — re-derived from live membership + release stamps on every
494
+ * (re)send (ADR-0029) so reconnect attests what the replica holds NOW.
495
+ */
496
+ interface DesiredEntry {
497
+ readonly name: string
498
+ readonly args: unknown
499
+ /**
500
+ * Widest ttl any retain declared (#102 ratchet — see `#retainDesire`; release
501
+ * never lowers it).
502
+ */
503
+ ttl: number | undefined
504
+ /**
505
+ * Retained holders only (ADR-0023: commit owns): retained Views + preloads.
506
+ * Handle creation does not count. 0→1 sends `subscribe`; last release sends
507
+ * `unsubscribe` immediately (#120/Q2 — Warm pool is paint budget, not wire
508
+ * lifetime).
509
+ */
510
+ count: number
511
+ /**
512
+ * Shared reactive read for this instance (ADR-0023 share-raw). `null` for
513
+ * preload-only (no local execution — #104).
514
+ */
515
+ read: RawRead | null
516
+ /** Confirmation phase driving View {@link QueryStatus} (#104). */
517
+ phase: SubPhase
518
+ /** Subscribe-failure detail when `phase` is `error` (#104). */
519
+ error: Error | undefined
520
+ }
521
+
522
+ /**
523
+ * Local-read slot (sql+params key): shared RawRead + retain count. Same retain
524
+ * / warm lifecycle as {@link DesiredEntry}, no wire half.
525
+ */
526
+ interface LocalReadEntry {
527
+ count: number
528
+ read: RawRead | null
529
+ }
530
+
531
+ /**
532
+ * View handle from `subscribe()`/`local()` (CONTEXT.md; ADR-0023). Creation is
533
+ * pure compute; `retain()` is first ownership (0→1 sends wire `subscribe`).
534
+ * Last `release()` unsubscribes immediately (#120/Q2) and parks the read in the
535
+ * Warm pool for one tick.
536
+ *
537
+ * Stale-but-stable (Q1): after Warm eviction the handle keeps last rows/status
538
+ * until a re-retain's read notifies fresh ones. `onChange` listeners re-wire
539
+ * onto the recreated read so the handle never observes the gap.
540
+ */
541
+ class ViewHandle implements View<Record<string, unknown>> {
542
+ /** Whether THIS handle currently retains (idempotent; #120). */
543
+ #held = false
544
+ #unwire: (() => void) | null = null
545
+ readonly #listeners = new Set<() => void>()
546
+ #lastRows: readonly Record<string, unknown>[] = EMPTY_ROWS
547
+ #lastStatus: ViewStatus = UNKNOWN_STATUS
548
+ #seeded = false
549
+ /**
550
+ * Decode memo (Q4): read identity + generation that produced `#lastRows`
551
+ * (recreated reads restart generations).
552
+ */
553
+ #decodedFrom: { read: RawRead; generation: number } | null = null
554
+
555
+ constructor(
556
+ /**
557
+ * Retain/release/peek for the shared read; `compute` is pure discardable
558
+ * seed.
559
+ */
560
+ private readonly lifecycle: {
561
+ retain(): RawRead
562
+ release(): void
563
+ peek(): RawRead | null
564
+ /**
565
+ * Pure creation-time compute (ADR-0023/Q3): run statement + decode, no
566
+ * shared state. Caller snapshot-gates mid-replay (torn overlay → empty
567
+ * seed; commit-time read supplies truth).
568
+ */
569
+ compute(): {
570
+ rows: readonly Record<string, unknown>[]
571
+ status: ViewStatus
572
+ }
573
+ },
574
+ /**
575
+ * Per-handle decode (Q4): never keyed by statement — two flavors with
576
+ * identical SQL share one RawRead and decode here, memoized on generation.
577
+ */
578
+ private readonly decode: RowDecoder | undefined,
579
+ readonly one: boolean | undefined,
580
+ ) {}
581
+
582
+ /** Decode shared raw rows with this handle's flavor, memoized on generation. */
583
+ #project(read: RawRead): readonly Record<string, unknown>[] {
584
+ if (
585
+ this.#decodedFrom?.read !== read ||
586
+ this.#decodedFrom.generation !== read.generation
587
+ ) {
588
+ const raw = read.rawRows()
589
+ this.#lastRows = this.decode
590
+ ? this.decode(raw as readonly Record<string, SqlValue>[])
591
+ : raw
592
+ this.#decodedFrom = { read, generation: read.generation }
593
+ }
594
+ return this.#lastRows
595
+ }
596
+
597
+ /**
598
+ * Lazy first-paint seed (Q1/Q3): prefer the live shared read (another retain
599
+ * or Warm pool), else pure `compute` once. Discarded renders that never read
600
+ * stay zero-cost; retain-then-read shares the live read's execution.
601
+ */
602
+ #seed(): void {
603
+ if (this.#seeded) return
604
+ this.#seeded = true
605
+ const read = this.lifecycle.peek()
606
+ if (read !== null) {
607
+ this.#project(read)
608
+ this.#lastStatus = read.status()
609
+ } else {
610
+ const seed = this.lifecycle.compute()
611
+ this.#lastRows = seed.rows
612
+ this.#lastStatus = seed.status
613
+ }
614
+ }
615
+
616
+ current(): readonly Record<string, unknown>[] {
617
+ const read = this.lifecycle.peek()
618
+ if (read !== null && read.generation > 0) {
619
+ this.#seeded = true
620
+ return this.#project(read)
621
+ }
622
+ this.#seed()
623
+ return this.#lastRows
624
+ }
625
+
626
+ status(): ViewStatus {
627
+ const read = this.lifecycle.peek()
628
+ if (read !== null) {
629
+ this.#seeded = true
630
+ this.#lastStatus = read.status()
631
+ return this.#lastStatus
632
+ }
633
+ this.#seed()
634
+ return this.#lastStatus
635
+ }
636
+
637
+ onChange(listener: () => void): () => void {
638
+ this.#listeners.add(listener)
639
+ return () => this.#listeners.delete(listener)
640
+ }
641
+
642
+ retain(): void {
643
+ if (this.#held) return
644
+ this.#held = true
645
+ this.#seeded = true
646
+ const read = this.lifecycle.retain()
647
+ // One forwarder per handle onto the (possibly recreated) shared read.
648
+ this.#unwire = read.onChange(() => {
649
+ this.#project(read)
650
+ this.#lastStatus = read.status()
651
+ for (const l of this.#listeners) l()
652
+ })
653
+ // Re-project when the live read is newer than our snapshot. Generation 0
654
+ // (gated initial compute) holds nothing fresher than any seed — must not
655
+ // overwrite; its first real recompute notifies via the wire above (Q1:
656
+ // never blank, only move forward).
657
+ if (
658
+ (read.generation > 0 &&
659
+ (this.#decodedFrom?.read !== read ||
660
+ this.#decodedFrom.generation !== read.generation)) ||
661
+ read.status() !== this.#lastStatus
662
+ ) {
663
+ if (read.generation > 0) this.#project(read)
664
+ this.#lastStatus = read.status()
665
+ for (const l of this.#listeners) l()
666
+ }
667
+ }
668
+
669
+ release(): void {
670
+ if (!this.#held) return
671
+ this.#held = false
672
+ this.#unwire?.()
673
+ this.#unwire = null
674
+ this.lifecycle.release()
675
+ }
676
+ }
677
+
678
+ export class ClientEngine implements DoyncClient {
679
+ readonly #db: LocalDb
680
+ readonly #schema: DoyncSchema
681
+ readonly #mutations: ClientMutationRegistry
682
+ readonly #socket: SyncSocket
683
+ /**
684
+ * Projected auth ctx (ADR-0018) for queries/bodies. Updated by
685
+ * {@link updateAuth} on same-user refresh (#102).
686
+ */
687
+ #ctx: Record<string, unknown>
688
+ /**
689
+ * Bearer for every handshake (ADR-0016/0018). Updated by {@link updateAuth} so
690
+ * refresh rides in-band update AND the next reconnect (#102).
691
+ */
692
+ #token: string | undefined
693
+ /**
694
+ * Asserted identity (#104 / #167) from construction / {@link updateAuth}.
695
+ * Never derived from {@link #token}.
696
+ */
697
+ #userId: string | null = null
698
+
699
+ #clientId!: string
700
+ /**
701
+ * ClientId mint (config `generateId` or {@link defaultGenerateID}). Kept so
702
+ * `forget()` and corrupt-meta heal can mint mid-session, not only at boot.
703
+ */
704
+ readonly #generateId: () => string
705
+ /** Durable next-mutation-id high-water mark (ADR-0019). */
706
+ #nextMutationId = 1
707
+ /** Origin-commit cookie; advances only on pokeEnd. */
708
+ #cookie: number | null = null
709
+ #connected = false
710
+ /**
711
+ * Connected-time ms (ADR-0014 client / #223): pong deltas while connected;
712
+ * frozen offline. Currency for release stamps and membership GC. Memory
713
+ * first; piggybacks durable rebases (naive meta write would join the overlay
714
+ * and roll back — #139). Lost ticks ⇒ clock runs SLOW (safe).
715
+ */
716
+ #connectedClock = 0
717
+ /**
718
+ * Wall time of previous pong on THIS connection (`null` until first pong).
719
+ * Re-anchored on open/close so disconnect gaps add nothing.
720
+ */
721
+ #lastPongAt: number | null = null
722
+ /** Wall clock for pong deltas (injectable in tests). */
723
+ readonly #now: () => number
724
+ /**
725
+ * Sticky auth failure (#136, ADR-0018): set on framed `unauthorized` (engine
726
+ * inference — seam never reports it). Sticky across backoff until a
727
+ * substantive frame proves a refreshed handshake (same as shared-hub.ts).
728
+ * Outranks seam report and live `open` in {@link connectionStatus}.
729
+ */
730
+ #needsAuth = false
731
+ /**
732
+ * Last optional seam transient (#136), or `null` if never reported / cleared
733
+ * by live open. Surfaces in {@link connectionStatus} only when neither open
734
+ * nor needs-auth wins.
735
+ */
736
+ #seamStatus: SeamStatus | null = null
737
+ #overlayOpen = false
738
+ /**
739
+ * Connection-status subscribers (#105): notified when visible
740
+ * {@link connectionStatus} flips.
741
+ */
742
+ readonly #connectionListeners = new Set<() => void>()
743
+
744
+ /** Bundled schema version — handshake skew currency (ADR-0020). */
745
+ #bundleVersion = 0
746
+ /**
747
+ * Replica data schema version in `__doync_meta`. Fresh → `#bundleVersion`;
748
+ * persists until mid-session `schema` directives advance it.
749
+ */
750
+ #appliedVersion = 0
751
+ /** Current schema-handling state, or `null` when nominal (ADR-0020). */
752
+ #schemaEvent: SchemaEvent | null = null
753
+ readonly #onSchemaEvent: ((event: SchemaEvent) => void) | undefined
754
+ /**
755
+ * Schema-state subscribers (#89): every transition including silent clear.
756
+ * Config `onSchemaEvent` fires only on NEW state (clears emit nothing), so a
757
+ * banner on that alone sticks after recovery — hooks use this +
758
+ * `schemaStatus`.
759
+ */
760
+ readonly #schemaListeners = new Set<() => void>()
761
+ /**
762
+ * After `reload` skew: stop handshakes and frame apply until app reload
763
+ * (ADR-0020).
764
+ */
765
+ #halted = false
766
+
767
+ /** Durable pending queue, ascending mutation id. */
768
+ #pending: PendingMutation[] = []
769
+ /** `{client, server}` resolvers by mutation id. */
770
+ readonly #promises = new Map<number, PendingPromise>()
771
+ /** Idempotency key → result pair (ADR-0019 retry-once). */
772
+ readonly #keyed = new Map<string, MutationResult>()
773
+ /**
774
+ * Keys reloaded from durable queue at boot (ADR-0019): pairs are orphaned (no
775
+ * caller awaits; settlement swallowed). A DB_FAILOVER retry on a respawned
776
+ * engine must treat client phase as already applied.
777
+ */
778
+ readonly #recoveredKeys = new Set<string>()
779
+ /** Mutids dropped by `pokeReject` — never resolved by a later lmid. */
780
+ readonly #rejected = new Set<number>()
781
+ /** Mutids whose body threw in the latest optimistic replay. */
782
+ readonly #optimisticFailed = new Map<number, unknown>()
783
+
784
+ /** SubscribeAck metadata (Read-set + Level→table). */
785
+ readonly #instanceInfo = new Map<string, InstanceInfo>()
786
+ /**
787
+ * Desired set by statement identity. `heldAt` re-derived on send from
788
+ * membership + stamps (ADR-0029), not stored.
789
+ */
790
+ readonly #desired = new Map<string, DesiredEntry>()
791
+ /**
792
+ * In-flight Once (ADR-0012) by client id: view + wire `{name, args}` for
793
+ * reconnect re-issue (Once is not durable).
794
+ */
795
+ readonly #onceRequests = new Map<
796
+ string,
797
+ { view: OnceViewImpl; name: string; args: unknown }
798
+ >()
799
+ /**
800
+ * Once answer accumulation (ADR-0012): start → parts → end. Feeds OnceView,
801
+ * never replica/cookie.
802
+ */
803
+ readonly #onceBuffers = new Map<string, Record<string, unknown>[]>()
804
+ #nextOnceSeq = 1
805
+ /**
806
+ * Local shared raw reads (ADR-0019: re-run on any local commit, unhinted).
807
+ * Separate from {@link DesiredEntry} so each reactive read has one owner
808
+ * (ADR-0023 one-count).
809
+ */
810
+ readonly #localReads = new Map<string, LocalReadEntry>()
811
+ /**
812
+ * Warm pool (CONTEXT.md; ADR-0023): last-release reads kept one tick outside
813
+ * `#desired` (desired = handshake truth — #120). Re-retain reclaims rows and
814
+ * re-sends `subscribe`; wire `unsubscribe` already fired (Q2).
815
+ */
816
+ readonly #warmReads = new Map<
817
+ string,
818
+ { read: RawRead; timer: ReturnType<typeof setTimeout> }
819
+ >()
820
+ /**
821
+ * Consumer tables written by the current overlay. Overlay rollback removes
822
+ * those optimistic rows — rebase must re-project them even without a patch or
823
+ * survivor rewrite (else phantoms of rejected/acked-away guesses).
824
+ */
825
+ #overlayTables = new Set<string>()
826
+
827
+ /** Patches between pokeStart and pokeEnd; `null` when idle. */
828
+ #pokeBuffer: Patch[] | null = null
829
+ /** Framed protocol errors — never silent (ADR-0016). */
830
+ readonly #errors: string[] = []
831
+
832
+ /**
833
+ * Exclusive chain (ADR-0019 addendum). True while an overlay-touching op runs
834
+ * and STAYS true across an awaited body so concurrent JS sees busy. Sync ops
835
+ * set/clear without yield — observers only see true during async replay.
836
+ * Serialization latch ({@link #chainQueue}) and snapshot gate (defer new view
837
+ * initial reads). All-sync bodies never queue or gate.
838
+ */
839
+ #chainBusy = false
840
+ /** Ops deferred behind in-flight async replay (FIFO). */
841
+ readonly #chainQueue: ChainStep[] = []
842
+ /**
843
+ * Views whose initial recompute was gated mid-async-replay (torn overlay).
844
+ * Flushed when the chain idles.
845
+ */
846
+ readonly #gatedViews = new Set<RawRead>()
847
+
848
+ constructor(config: ClientEngineConfig) {
849
+ this.#db = config.db
850
+ this.#schema = config.schema
851
+ this.#mutations = config.mutations
852
+ this.#socket = config.socket
853
+ this.#ctx = config.ctx ?? {}
854
+ this.#token = config.token
855
+ // Asserted identity (#167) — never from the token.
856
+ this.#userId = config.userId ?? null
857
+ this.#onSchemaEvent = config.onSchemaEvent
858
+ this.#generateId = config.generateId ?? defaultGenerateID
859
+ this.#now = config.now ?? Date.now
860
+ this.#boot(config)
861
+ this.#socket.setHandlers({
862
+ message: (message) => this.#onMessage(message),
863
+ open: () => this.#onOpen(),
864
+ close: () => {
865
+ // Freeze connected clock across the gap (ADR-0014 / #223): clear pong
866
+ // anchor so the first post-open pong contributes nothing.
867
+ this.#lastPongAt = null
868
+ this.#setConnected(false)
869
+ },
870
+ // Optional seam status (#136): reconnecting adapters feed connecting/error.
871
+ status: (status) => this.#onSeamStatus(status),
872
+ })
873
+ }
874
+
875
+ /**
876
+ * In-memory connected-time ms (ADR-0014 / #223). Package-internal for release
877
+ * stamps (#224) and membership GC — not on {@link DoyncClient}.
878
+ */
879
+ get connectedClock(): number {
880
+ return this.#connectedClock
881
+ }
882
+
883
+ /**
884
+ * Durable release stamp for one instance, or `null` (ADR-0014 / #224).
885
+ * Package-internal for `heldAt` (#225) and GC (#226).
886
+ */
887
+ releaseStamp(instance: string): ReleaseStamp | null {
888
+ return readReleaseStamp(this.#db, instance)
889
+ }
890
+
891
+ /**
892
+ * Every parked release stamp (#226 hygiene GC). Package-internal; order
893
+ * unspecified.
894
+ */
895
+ releaseStamps(): ReleaseStamp[] {
896
+ return listReleaseStamps(this.#db)
897
+ }
898
+
899
+ /** Durable clientId (stable across restarts on the same DB). */
900
+ get clientId(): string {
901
+ return this.#clientId
902
+ }
903
+
904
+ /**
905
+ * Asserted `userId` from construction / {@link updateAuth}, or `null` (#104 /
906
+ * #167). Never derived from a bearer (ADR-0018) — cookie sessions assert
907
+ * `userId` with no token.
908
+ */
909
+ get userId(): string | null {
910
+ return this.#userId
911
+ }
912
+
913
+ /** Scalar cookie held now (`null` on first sight). */
914
+ get cookie(): number | null {
915
+ return this.#cookie
916
+ }
917
+
918
+ /** Framed protocol errors (`error` frames, stray poke parts). */
919
+ get errors(): readonly string[] {
920
+ return this.#errors
921
+ }
922
+
923
+ /**
924
+ * Idempotency keys recovered at boot (ADR-0019). A DB_FAILOVER retry on a
925
+ * respawned engine gets the recovered pair whose orphaned `client` never
926
+ * settles for it — treat client phase as already applied.
927
+ */
928
+ get recoveredKeys(): ReadonlySet<string> {
929
+ return this.#recoveredKeys
930
+ }
931
+
932
+ /** Bundled schema version (ADR-0020) — declared on every handshake for skew. */
933
+ get schemaVersion(): number {
934
+ return this.#bundleVersion
935
+ }
936
+
937
+ /**
938
+ * Schema-handling state, or `null` when nominal (ADR-0020). Sync counterpart
939
+ * to {@link ClientEngineConfig.onSchemaEvent} for reload/retry UI.
940
+ */
941
+ get schemaStatus(): SchemaEvent | null {
942
+ return this.#schemaEvent
943
+ }
944
+
945
+ // --- boot / durable state -------------------------------------------------
946
+
947
+ #boot(config: ClientEngineConfig): void {
948
+ const db = this.#db
949
+ // Clients apply state, never enforce (ADR-0009/0020).
950
+ db.exec('PRAGMA foreign_keys = OFF')
951
+ assertNoReservedTables(this.#schema)
952
+ // Engine track via `__doync_engine` ledger (ADR-0024). Ledger-above-bundle
953
+ // rebuilds engine tables and flags consumer wipe-and-resync below.
954
+ const { rolledBack } = createEngineTables(db)
955
+ if (rolledBack) {
956
+ // Consumer half of wipe-and-resync: drop tables + memberships, re-replay
957
+ // bundle from empty, null cookie. Pending + clientId + ledger survive.
958
+ dropConsumerTables(db, this.#schema)
959
+ db.exec('DELETE FROM __doync_membership')
960
+ writeMeta(db, 'schema_version', '0')
961
+ applyBundledMigrations(
962
+ db,
963
+ this.#schema,
964
+ 0,
965
+ declaredSchemaVersion(this.#schema),
966
+ )
967
+ this.#cookie = null
968
+ db.exec('DELETE FROM __doync_meta WHERE k = ?', 'cookie')
969
+ } else {
970
+ replayMigrations(db, this.#schema)
971
+ }
972
+ this.#bundleVersion = declaredSchemaVersion(this.#schema)
973
+
974
+ // Corrupt-meta heal (ADR-0022): non-integer durable counters cannot boot
975
+ // safely (NaN would ride wire as cookie/lmid or reissue mutids — ADR-0019
976
+ // next-id trap; corrupt mut counter makes pending untrustworthy). Loud
977
+ // forget-shaped heal to a fresh first-sight base, then mint clientId /
978
+ // empty queue below. Emit the reset only after that state exists.
979
+ const corruptKey = corruptMetaKey(db)
980
+ if (corruptKey !== null) {
981
+ // Silent would look like data loss — name the corruption and consequence.
982
+ console.error(
983
+ `doync: durable meta "${corruptKey}" is corrupt — healing by ` +
984
+ `FORGETTING the local store (fresh identity, full re-hydration). A ` +
985
+ `corrupt mutation counter makes the pending queue untrustworthy, so ` +
986
+ `unsynced writes are discarded rather than risk double-apply.`,
987
+ )
988
+ this.#forgetStore(db)
989
+ }
990
+
991
+ this.#appliedVersion = this.#durableInt(db, 'schema_version') ?? 0
992
+
993
+ // Reuse durable clientId when present (respawn-double-apply trap). Heal
994
+ // cleared it ⇒ mint fresh first-sight id.
995
+ const stored = readMeta(db, 'client_id')
996
+ this.#clientId = stored ?? config.clientId ?? this.#generateId()
997
+ if (stored === null) writeMeta(db, 'client_id', this.#clientId)
998
+
999
+ // Independent high-water mark (not MAX(pending)+1).
1000
+ this.#nextMutationId = this.#durableInt(db, 'next_mutation_id') ?? 1
1001
+ this.#cookie = this.#durableInt(db, 'cookie')
1002
+ // Connected clock (ADR-0014 / #223): resume last persisted reading. Lost
1003
+ // unpersisted ticks ⇒ slow (safe). Lenient outside forget heal — corrupt
1004
+ // clock must not cost identity/pending (never rides wire); zero matches
1005
+ // crash-loss direction.
1006
+ const storedClock = Number(readMeta(db, __CONNECTED_CLOCK_META_KEY))
1007
+ if (Number.isInteger(storedClock) && storedClock >= 0) {
1008
+ this.#connectedClock = storedClock
1009
+ } else {
1010
+ if (readMeta(db, __CONNECTED_CLOCK_META_KEY) !== null) {
1011
+ console.warn(
1012
+ 'doync: durable connected-clock reading is corrupt — resetting to 0 (clock runs slow; GC defers, server full-hydrates)',
1013
+ )
1014
+ }
1015
+ this.#connectedClock = 0
1016
+ }
1017
+ this.#lastPongAt = null
1018
+
1019
+ // Emit corrupt-meta forget only once clientId/cookie are initialized.
1020
+ if (corruptKey !== null) {
1021
+ this.#emitSchemaEvent(
1022
+ 'forget',
1023
+ `corrupt durable meta "${corruptKey}" — forgot the local store and ` +
1024
+ `reset to a fresh identity`,
1025
+ )
1026
+ }
1027
+
1028
+ // Reload durable pending; decode reserved-key JSON (ADR-0031 / #231) so
1029
+ // replay sees live ArrayBuffers matching the original optimistic bind.
1030
+ this.#pending = db
1031
+ .exec<{
1032
+ mutation_id: SqlValue
1033
+ name: SqlValue
1034
+ args: SqlValue
1035
+ idem_key: SqlValue
1036
+ }>(
1037
+ `SELECT mutation_id, name, args, idem_key FROM __doync_pending ORDER BY mutation_id ASC`,
1038
+ )
1039
+ .map((row) => {
1040
+ const wireArgs = JSON.parse(String(row.args)) as unknown
1041
+ return {
1042
+ mutationId: Number(row.mutation_id),
1043
+ name: String(row.name),
1044
+ wireArgs,
1045
+ args: decodeArgs(wireArgs, 'mutation args'),
1046
+ key: row.idem_key === null ? undefined : String(row.idem_key),
1047
+ }
1048
+ })
1049
+ for (const p of this.#pending) {
1050
+ // Orphan promises: swallow settlement (no caller awaits reloaded pendings).
1051
+ const promise = this.#ensurePromise(p.mutationId, true)
1052
+ if (p.key !== undefined) {
1053
+ this.#keyed.set(p.key, promise.pair)
1054
+ this.#recoveredKeys.add(p.key)
1055
+ }
1056
+ }
1057
+ // Membership GC at boot (ADR-0014 / #226): aged stamped releases only.
1058
+ // Defer stampless until in-session (`#desired` live) — at boot every
1059
+ // instance looks undesired, so stampless would wipe live memberships that
1060
+ // only survived process death / DB-worker failover. Before overlay opens
1061
+ // (plain autocommit). Clock piggybacks only when the sweep has work.
1062
+ this.#runMembershipGc({ includeStampless: false })
1063
+
1064
+ // Discard bootstrap writes before opening the overlay.
1065
+ db.drainWrittenTables()
1066
+ // Initial overlay is usually sync. Async durable pending cannot be awaited
1067
+ // in the constructor — hold chain busy (gate reads) until it lands.
1068
+ const opened = this.#openOverlay()
1069
+ if (isThenable(opened)) {
1070
+ this.#chainBusy = true
1071
+ opened.then(
1072
+ () => this.#finishChainStep(),
1073
+ () => this.#finishChainStep(),
1074
+ )
1075
+ }
1076
+ }
1077
+
1078
+ /**
1079
+ * Durable integer meta (ADR-0015/0019). `null` when unset (caller default;
1080
+ * cookie may stay null). Present-but-corrupt throws — never silent NaN on the
1081
+ * wire (ADR-0019 next-id trap).
1082
+ *
1083
+ * At boot the throw is unreachable: {@link corruptMetaKey} + ADR-0022 heal
1084
+ * clear the store first. Kept as fail-loud tripwire for values the heal
1085
+ * missed (invariant break).
1086
+ */
1087
+ #durableInt(db: LocalDb, key: string): number | null {
1088
+ const raw = readMeta(db, key)
1089
+ if (raw === null) return null
1090
+ const n = Number(raw)
1091
+ if (!Number.isInteger(n)) {
1092
+ throw new Error(
1093
+ `doync: durable meta "${key}" is corrupt (${JSON.stringify(raw)} → ` +
1094
+ `${n}) — a non-integer must never ride the wire (ADR-0019 next-id ` +
1095
+ `trap); the boot-time corrupt-meta heal should have caught this`,
1096
+ )
1097
+ }
1098
+ return n
1099
+ }
1100
+
1101
+ // --- the exclusive chain (ADR-0019 addendum) ------------------------------
1102
+
1103
+ /**
1104
+ * Run one overlay-touching op on the exclusive chain. Idle ⇒ run now (sync
1105
+ * path stays fully sync). Busy ⇒ queue until prior finishes so mutate /
1106
+ * rebase / poke never interleave and an awaited body only defers its paint.
1107
+ */
1108
+ #exclusive(work: ChainStep): void {
1109
+ if (this.#chainBusy) {
1110
+ this.#chainQueue.push(work)
1111
+ return
1112
+ }
1113
+ this.#chainBusy = true
1114
+ this.#runExclusive(work)
1115
+ }
1116
+
1117
+ #runExclusive(work: ChainStep): void {
1118
+ let running: Awaitable<void>
1119
+ try {
1120
+ running = work()
1121
+ } catch (error) {
1122
+ // Sync throw: free chain and rethrow (same surface as pre-chain code).
1123
+ this.#chainBusy = false
1124
+ this.#drainOrFlush()
1125
+ throw error
1126
+ }
1127
+ if (isThenable(running)) {
1128
+ running.then(
1129
+ () => this.#finishChainStep(),
1130
+ (error: unknown) => {
1131
+ // Async rejection cannot reach the sync caller — frame it (ADR-0016)
1132
+ // and free the chain so the engine is not wedged.
1133
+ this.#errors.push(
1134
+ `doync: exclusive-chain step rejected — ${errorMessage(error)}`,
1135
+ )
1136
+ this.#finishChainStep()
1137
+ },
1138
+ )
1139
+ } else {
1140
+ this.#finishChainStep()
1141
+ }
1142
+ }
1143
+
1144
+ #finishChainStep(): void {
1145
+ this.#chainBusy = false
1146
+ this.#drainOrFlush()
1147
+ }
1148
+
1149
+ /** Next queued op, or flush gated views when idle. */
1150
+ #drainOrFlush(): void {
1151
+ const next = this.#chainQueue.shift()
1152
+ if (next !== undefined) {
1153
+ this.#chainBusy = true
1154
+ this.#runExclusive(next)
1155
+ return
1156
+ }
1157
+ this.#flushGatedViews()
1158
+ }
1159
+
1160
+ /**
1161
+ * Recompute views gated during replay on the settled overlay; notify movers.
1162
+ * No-op on the common all-sync path.
1163
+ */
1164
+ #flushGatedViews(): void {
1165
+ if (this.#gatedViews.size === 0) return
1166
+ const gated = [...this.#gatedViews]
1167
+ this.#gatedViews.clear()
1168
+ for (const view of gated) if (view.recompute()) view.notify()
1169
+ }
1170
+
1171
+ /**
1172
+ * New view initial recompute: now, or deferred while overlay is torn
1173
+ * (snapshot gating).
1174
+ */
1175
+ #initialCompute(view: RawRead): void {
1176
+ if (this.#chainBusy) this.#gatedViews.add(view)
1177
+ else view.recompute()
1178
+ }
1179
+
1180
+ // --- the optimistic overlay ----------------------------------------------
1181
+
1182
+ /**
1183
+ * Open held savepoint and replay pendings onto base; record `#overlayTables`.
1184
+ * Clears/drains the write-table accumulator so priors never leak. Sync when
1185
+ * every body is sync; returns a Promise once a body awaits (caller awaits
1186
+ * settle — exclusive chain, ADR-0019).
1187
+ */
1188
+ #openOverlay(): Awaitable<void> {
1189
+ this.#db.drainWrittenTables()
1190
+ this.#db.exec(`SAVEPOINT ${OPTIMISTIC_SAVEPOINT}`)
1191
+ this.#overlayOpen = true
1192
+ this.#optimisticFailed.clear()
1193
+ return thenMaybe(this.#replayFrom(0), () => {
1194
+ this.#overlayTables = consumerTables(this.#db.drainWrittenTables())
1195
+ })
1196
+ }
1197
+
1198
+ /**
1199
+ * Replay pendings from `index` in order. Stays sync across sync bodies; an
1200
+ * await then tail-recurses so ordering is strict without microtask tax.
1201
+ */
1202
+ #replayFrom(index: number): Awaitable<void> {
1203
+ for (let i = index; i < this.#pending.length; i++) {
1204
+ const running = this.#replayBody(this.#pending[i] as PendingMutation)
1205
+ if (isThenable(running))
1206
+ return running.then(() => this.#replayFrom(i + 1))
1207
+ }
1208
+ }
1209
+
1210
+ /** Roll overlay to base and release (autocommit). */
1211
+ #closeOverlay(): void {
1212
+ if (!this.#overlayOpen) return
1213
+ this.#db.exec(`ROLLBACK TO ${OPTIMISTIC_SAVEPOINT}`)
1214
+ this.#db.exec(`RELEASE ${OPTIMISTIC_SAVEPOINT}`)
1215
+ this.#overlayOpen = false
1216
+ }
1217
+
1218
+ /**
1219
+ * Rebase envelope for every durable change (mutate, poke, reject): close
1220
+ * overlay, run `durable` in a committed base transaction (outside savepoint),
1221
+ * reopen with survivor replay. Returns union of tables removed by rollback,
1222
+ * written by durable, and written by new overlay — so pure disappearances
1223
+ * still re-project.
1224
+ *
1225
+ * `durable` is sync; only survivor replay may await. Exclusive chain holds
1226
+ * other ops until overlay is rebuilt.
1227
+ */
1228
+ #rebase(durable: () => void): Awaitable<Set<string>> {
1229
+ const removed = this.#overlayTables
1230
+ this.#closeOverlay()
1231
+ this.#db.drainWrittenTables()
1232
+ this.#db.exec('BEGIN')
1233
+ try {
1234
+ durable()
1235
+ this.#db.exec('COMMIT')
1236
+ } catch (error) {
1237
+ // Leave DB clean for caller recovery (wipe-and-resync): roll durable body,
1238
+ // restore overlay, then rethrow.
1239
+ this.#db.exec('ROLLBACK')
1240
+ return thenMaybe(this.#openOverlay(), () => {
1241
+ throw error
1242
+ })
1243
+ }
1244
+ const applied = consumerTables(this.#db.drainWrittenTables())
1245
+ return thenMaybe(
1246
+ this.#openOverlay(),
1247
+ () => new Set([...removed, ...applied, ...this.#overlayTables]),
1248
+ )
1249
+ }
1250
+
1251
+ /**
1252
+ * Replay one pending body in a nested savepoint so a throw rolls back only
1253
+ * itself and survivors still apply. Failure lands in `#optimisticFailed` for
1254
+ * `client` reject; caller decides drop (initial apply / body-throw contract)
1255
+ * vs keep (later rebase — Origin decides). Async bodies release the savepoint
1256
+ * only after settle.
1257
+ */
1258
+ #replayBody(p: PendingMutation): Awaitable<void> {
1259
+ const db = this.#db
1260
+ db.exec(`SAVEPOINT ${BODY_SAVEPOINT}`)
1261
+ let running: Awaitable<void>
1262
+ try {
1263
+ const entry = this.#mutations[p.name]
1264
+ if (entry === undefined) throw new Error(`unknown mutation "${p.name}"`)
1265
+ running = asClientMutation(entry).handler(p.args, this.#ctx, this.#tx())
1266
+ } catch (error) {
1267
+ this.#failBody(p.mutationId, error)
1268
+ return
1269
+ }
1270
+ if (isThenable(running)) {
1271
+ return running.then(
1272
+ () => this.#releaseBody(p.mutationId),
1273
+ (error: unknown) => this.#failBody(p.mutationId, error),
1274
+ )
1275
+ }
1276
+ this.#releaseBody(p.mutationId)
1277
+ }
1278
+
1279
+ /** Clean body: release savepoint, clear prior failure. */
1280
+ #releaseBody(id: number): void {
1281
+ this.#db.exec(`RELEASE ${BODY_SAVEPOINT}`)
1282
+ this.#optimisticFailed.delete(id)
1283
+ }
1284
+
1285
+ /** Thrown body: roll savepoint back and record the error. */
1286
+ #failBody(id: number, error: unknown): void {
1287
+ this.#db.exec(`ROLLBACK TO ${BODY_SAVEPOINT}`)
1288
+ this.#db.exec(`RELEASE ${BODY_SAVEPOINT}`)
1289
+ this.#optimisticFailed.set(id, error)
1290
+ }
1291
+
1292
+ /** Mutation-body write surface (exec returns rows, ADR-0021). */
1293
+ #tx(): ClientMutationTx {
1294
+ return { exec: (query, ...params) => this.#db.exec(query, ...params) }
1295
+ }
1296
+
1297
+ /**
1298
+ * `push` frame (ADR-0016): `{name, args}` + durable id, never the optimistic
1299
+ * result. `wireArgs` is reserved-key encoded at enqueue/reload (ADR-0031 /
1300
+ * #231) so clean-apply and reconnect outbox share one blob.
1301
+ */
1302
+ #sendPush(p: PendingMutation): void {
1303
+ this.#socket.send({
1304
+ type: 'push',
1305
+ clientId: this.#clientId,
1306
+ mutationId: p.mutationId,
1307
+ name: p.name,
1308
+ args: p.wireArgs,
1309
+ })
1310
+ }
1311
+
1312
+ // --- mutate ---------------------------------------------------------------
1313
+
1314
+ mutate<Args = unknown>(
1315
+ mutation: MutationDefinition<Args>,
1316
+ args: Args,
1317
+ options?: MutationOptions,
1318
+ ): MutationResult {
1319
+ const key = options?.key
1320
+ if (key !== undefined) {
1321
+ const existing = this.#keyed.get(key)
1322
+ if (existing !== undefined) return existing
1323
+ }
1324
+
1325
+ // Definition is a typed name stamp only (ADR-0021 / #161): resolve body +
1326
+ // validator from THIS engine's registry. Passed object body is never run.
1327
+ const name = mutation.name
1328
+ if (name === undefined || name === '') {
1329
+ return settledRejection(
1330
+ new Error(
1331
+ 'doync: mutate requires a registered mutation — wrap it in defineMutations(...) so it carries a dotted name',
1332
+ ),
1333
+ )
1334
+ }
1335
+
1336
+ const entry = this.#mutations[name]
1337
+ if (entry === undefined) {
1338
+ return settledRejection(new Error(`doync: unknown mutation "${name}"`))
1339
+ }
1340
+ const registered = asClientMutation(entry)
1341
+ let liveArgs: unknown
1342
+ let wireArgs: unknown
1343
+ try {
1344
+ // Device-side unrepresentable-leaf guard before enqueue (ADR-0031 /
1345
+ // #231/#232). Binary allowed (`encodeArgs` tags it); bigint/NaN/±Inf/
1346
+ // mangled-id-zone ints rejected. Runs for EVERY mutation ahead of the
1347
+ // schema gate — bare handlers skip `validateMutationArgs`. Throw is
1348
+ // sync fail-fast: nothing enqueued, `client` rejects.
1349
+ assertWireRepresentable(args, 'mutation args', { allowBinary: true })
1350
+ // TypedArray/DataView → ArrayBuffer before schema + body (same as Origin
1351
+ // / query resolve). Encode the post-schema value so wire and durable
1352
+ // queue match what the optimistic body saw (validators assumed
1353
+ // pass-through / idempotent).
1354
+ const canonical = canonicalizeArgsBinary(args)
1355
+ liveArgs =
1356
+ registered.args !== undefined
1357
+ ? validateMutationArgs(registered.args, canonical)
1358
+ : canonical
1359
+ wireArgs = encodeArgs(liveArgs, 'mutation args')
1360
+ } catch (error) {
1361
+ return settledRejection(error)
1362
+ }
1363
+
1364
+ // Pair returned sync; mutid allocated ON THE CHAIN in `#applyMutate` so
1365
+ // mutate-during-replay only defers its paint, fail-fast throws consume no
1366
+ // id, and the Origin cursor never sees a gap.
1367
+ const promise = this.#makePromise(false)
1368
+ if (key !== undefined) this.#keyed.set(key, promise.pair)
1369
+ this.#exclusive(() =>
1370
+ this.#applyMutate({ name, args: liveArgs, wireArgs, key, promise }),
1371
+ )
1372
+ return promise.pair
1373
+ }
1374
+
1375
+ /**
1376
+ * Optimistic apply on the exclusive chain (ADR-0019): allocate id, durable
1377
+ * queue row + counter outside the savepoint, rebase all pendings into a fresh
1378
+ * overlay, settle `client`. Counter rolls back via `#dropPending` on
1379
+ * fail-fast so only pushed mutids are consumed.
1380
+ */
1381
+ #applyMutate(request: {
1382
+ name: string
1383
+ args: unknown
1384
+ wireArgs: unknown
1385
+ key: string | undefined
1386
+ promise: PendingPromise
1387
+ }): Awaitable<void> {
1388
+ const db = this.#db
1389
+ const id = this.#nextMutationId
1390
+ this.#promises.set(id, request.promise)
1391
+ // Memory: live args for replay. Durable: encoded wire (ADR-0031 / #231).
1392
+ const record: PendingMutation = {
1393
+ mutationId: id,
1394
+ name: request.name,
1395
+ args: request.args,
1396
+ wireArgs: request.wireArgs,
1397
+ key: request.key,
1398
+ }
1399
+ const touched = this.#rebase(() => {
1400
+ db.exec(
1401
+ `INSERT INTO __doync_pending (mutation_id, name, args, idem_key) VALUES (?, ?, ?, ?)`,
1402
+ id,
1403
+ record.name,
1404
+ JSON.stringify(record.wireArgs ?? null),
1405
+ record.key ?? null,
1406
+ )
1407
+ this.#nextMutationId = id + 1
1408
+ writeMeta(db, 'next_mutation_id', String(this.#nextMutationId))
1409
+ this.#pending.push(record)
1410
+ })
1411
+ return thenMaybe(touched, (t) => this.#settleInitialApply(record, t))
1412
+ }
1413
+
1414
+ /**
1415
+ * Settle first optimistic apply (ADR-0019/0021). Clean ⇒ resolve `client`,
1416
+ * re-project, PUSH (`{name, args}` only). Thrown body ⇒ client-body-throw
1417
+ * (#103): drop pending (never pushed; body savepoint already rolled back),
1418
+ * reject both promises (`server` can never ack a never-pushed mut).
1419
+ */
1420
+ #settleInitialApply(
1421
+ record: PendingMutation,
1422
+ touched: Set<string>,
1423
+ ): Awaitable<void> {
1424
+ const id = record.mutationId
1425
+ if (!this.#optimisticFailed.has(id)) {
1426
+ this.#settleClient(id, null)
1427
+ this.#notifyChange(touched)
1428
+ if (this.#connected) this.#sendPush(record)
1429
+ return
1430
+ }
1431
+ const failure = this.#optimisticFailed.get(id)
1432
+ const pair = this.#promises.get(id)?.pair
1433
+ // Release idempotency key so a same-key retry re-evaluates (condition may
1434
+ // have cleared) instead of returning the cached rejection.
1435
+ if (record.key !== undefined) this.#keyed.delete(record.key)
1436
+ // Never-pushed ⇒ roll high-water so Origin cursor sees no gap.
1437
+ return thenMaybe(this.#dropPending(id, true), (dropped) => {
1438
+ this.#settleClient(id, failure)
1439
+ this.#settleServer(id, failure)
1440
+ // Swallow unhandled rejection if consumer awaits only `client`.
1441
+ pair?.client.catch(() => {})
1442
+ pair?.server.catch(() => {})
1443
+ this.#notifyChange(new Set([...touched, ...dropped]))
1444
+ })
1445
+ }
1446
+
1447
+ /**
1448
+ * Drop one pending and rebase survivors (ADR-0019). Shared by body-throw
1449
+ * (`rollbackCounter`) and server reject. `rollbackCounter` only on fail-fast
1450
+ * (id never pushed); server reject consumes the id (Origin advanced).
1451
+ */
1452
+ #dropPending(id: number, rollbackCounter = false): Awaitable<Set<string>> {
1453
+ const db = this.#db
1454
+ return this.#rebase(() => {
1455
+ db.exec(`DELETE FROM __doync_pending WHERE mutation_id = ?`, id)
1456
+ this.#pending = this.#pending.filter((p) => p.mutationId !== id)
1457
+ if (rollbackCounter) {
1458
+ this.#nextMutationId = id
1459
+ writeMeta(db, 'next_mutation_id', String(id))
1460
+ }
1461
+ })
1462
+ }
1463
+
1464
+ // --- auth (ADR-0018, closeio/doync#102) -----------------------------------
1465
+
1466
+ /**
1467
+ * Adopt a refreshed SAME-USER identity triple (#167). When connected with a
1468
+ * bearer, send in-band `updateAuth` (ADR-0018 / #85) so the Mirror extends
1469
+ * auth without reconnect when userId matches and `issuedAt` is newer.
1470
+ * Identity CHANGE (different `userId`, incl. anon↔user) is topology-level
1471
+ * replica swap — SharedWorker routes only same-user refreshes here.
1472
+ *
1473
+ * Token is stored for the next reconnect (#92: surviving SharedWorker must
1474
+ * not reuse a stale token). `ctx`/`userId` asserted, never derived. Halted
1475
+ * engines never emit.
1476
+ */
1477
+ updateAuth(
1478
+ token: string | null | undefined,
1479
+ ctx?: Record<string, unknown>,
1480
+ userId?: string | null,
1481
+ ): void {
1482
+ this.#token = token ?? undefined
1483
+ if (userId !== undefined) this.#userId = userId
1484
+ if (ctx !== undefined) this.#ctx = ctx
1485
+ if (
1486
+ this.#connected &&
1487
+ !this.#halted &&
1488
+ typeof token === 'string' &&
1489
+ token !== ''
1490
+ ) {
1491
+ this.#socket.send({ type: 'updateAuth', jwt: token })
1492
+ }
1493
+ }
1494
+
1495
+ // --- reactive reads -------------------------------------------------------
1496
+
1497
+ /**
1498
+ * Subscribe to a registered query (ADR-0021): resolve under args+ctx,
1499
+ * register upstream, return a reactive {@link View}. Local snapshots run
1500
+ * resolved SQL over base+overlay (RYOW). Re-project only when a local change
1501
+ * touches the instance's server Read-set.
1502
+ *
1503
+ * Bound form only (ADR-0027 / #200): {@link BoundQuery} or falsy →
1504
+ * SkippedView. `options.skip` is also an inert View with no desire.
1505
+ */
1506
+ subscribe<Row extends Record<string, unknown> = Record<string, SqlValue>>(
1507
+ query: BoundQuery<Row, boolean> | FalsyQuery,
1508
+ options?: SubscribeOptions,
1509
+ ): View<Row> {
1510
+ // Falsy ⇒ inert SkippedView, no desire/wire (ADR-0027). Distinct from
1511
+ // `options.skip` on a real bound query.
1512
+ if (isFalsyQuery(query)) {
1513
+ return new SkippedView() as unknown as View<Row>
1514
+ }
1515
+ const {
1516
+ query: leaf,
1517
+ args,
1518
+ options: opts,
1519
+ } = normalizeQuerySurface('subscribe', query, options)
1520
+ // Skipped (#104): no desire, no re-execution. Un-skip is a fresh subscribe
1521
+ // at the hook (#105), never in-place. Not keyed — holds no resources.
1522
+ if (opts?.skip) {
1523
+ return new SkippedView() as unknown as View<Row>
1524
+ }
1525
+ const name = leaf.name as string
1526
+ // Pure compute (ADR-0023/Q3): mint handle only. `retain()` is ownership.
1527
+ // Resolve here (engine consumption), never at BoundQuery bind (ADR-0027).
1528
+ const resolved = leaf.resolve({ args, ctx: this.#ctx })
1529
+ const statement: LocalStatement = {
1530
+ sql: resolved.sql,
1531
+ params: resolved.params,
1532
+ }
1533
+ const identity = resolved.identity
1534
+ const spec = { name, args, ttl: opts?.ttl, statement }
1535
+ const decode = resolved.decode
1536
+ const one = resolved.one ?? false
1537
+ return new ViewHandle(
1538
+ {
1539
+ peek: () => this.#desired.get(identity)?.read ?? null,
1540
+ retain: () => this.#retainSubscription(identity, spec),
1541
+ release: () => this.#releaseSubscription(identity),
1542
+ compute: () => {
1543
+ // Snapshot-gated like once(): mid-replay → empty seed.
1544
+ if (this.#chainBusy) {
1545
+ return { rows: EMPTY_ROWS, status: UNKNOWN_STATUS }
1546
+ }
1547
+ const raw = this.#db.exec(statement.sql, ...statement.params)
1548
+ const entry = this.#desired.get(identity)
1549
+ return {
1550
+ rows: decode ? decode(raw) : raw,
1551
+ status: phaseToStatus(entry?.phase ?? 'pending', entry?.error),
1552
+ }
1553
+ },
1554
+ },
1555
+ decode,
1556
+ one,
1557
+ ) as unknown as View<Row>
1558
+ }
1559
+
1560
+ /**
1561
+ * Warm the replica without materializing a View (#104, ADR-0019/0021):
1562
+ * register upstream so pokes hydrate rows other queries read, at zero local
1563
+ * recompute of the preload statement. Refcounts the same desired instance as
1564
+ * a live subscribe (byte-identical statement). `{cleanup}` releases into TTL
1565
+ * grace (ADR-0014). Bound form only (ADR-0027 / #200); falsy → no-op handle.
1566
+ */
1567
+ preload(
1568
+ query: BoundQuery | FalsyQuery,
1569
+ options?: PreloadOptions,
1570
+ ): PreloadHandle {
1571
+ if (isFalsyQuery(query)) {
1572
+ return NOOP_PRELOAD
1573
+ }
1574
+ const {
1575
+ query: leaf,
1576
+ args,
1577
+ options: opts,
1578
+ } = normalizeQuerySurface('preload', query, options)
1579
+ const name = leaf.name as string
1580
+ const resolved = leaf.resolve({ args, ctx: this.#ctx })
1581
+ const identity = resolved.identity
1582
+ this.#retainPreload(identity, {
1583
+ name,
1584
+ args,
1585
+ ttl: opts?.ttl,
1586
+ statement: { sql: resolved.sql, params: resolved.params },
1587
+ })
1588
+ let released = false
1589
+ return {
1590
+ cleanup: () => {
1591
+ // Idempotent — double cleanup must not under-count a shared instance.
1592
+ if (released) return
1593
+ released = true
1594
+ this.#releaseSubscription(identity)
1595
+ },
1596
+ }
1597
+ }
1598
+
1599
+ /**
1600
+ * Desire half of retain (subscribe AND preload): 0→1 entry + wire
1601
+ * `subscribe`, error-phase retry (#104/#114), ttl ratchet (#102, deviation
1602
+ * below).
1603
+ */
1604
+ #retainDesire(identity: string, spec: SubscriptionSpec): DesiredEntry {
1605
+ let entry = this.#desired.get(identity)
1606
+ if (entry === undefined) {
1607
+ // Capture heldAt BEFORE marking desired / dropping the stamp (ADR-0029 /
1608
+ // #225). `#heldAtFor` prefers desired+live → current cookie; doing those
1609
+ // first would attest the advanced cookie and trip the server's ahead
1610
+ // verdict against the parked baseline.
1611
+ const heldAt = this.#heldAtFor(identity)
1612
+ entry = {
1613
+ name: spec.name,
1614
+ args: spec.args,
1615
+ ttl: spec.ttl,
1616
+ count: 0,
1617
+ read: null,
1618
+ phase: 'pending',
1619
+ error: undefined,
1620
+ }
1621
+ this.#desired.set(identity, entry)
1622
+ // Drop parked stamp — only released instances keep one (ADR-0014 / #224).
1623
+ // Via `#sideWrite` so the delete doesn't join the open overlay (#139).
1624
+ this.#sideWrite(() => deleteReleaseStamp(this.#db, identity))
1625
+ if (this.#connected) {
1626
+ this.#socket.send({
1627
+ type: 'subscribe',
1628
+ queries: [this.#desiredQuery(identity, { heldAt })],
1629
+ })
1630
+ }
1631
+ } else if (entry.phase === 'error' && this.#connected) {
1632
+ // Fresh mount retries a failed subscribe (#setPhase notifies holders).
1633
+ this.#setPhase(identity, 'pending')
1634
+ this.#socket.send({
1635
+ type: 'subscribe',
1636
+ queries: [this.#desiredQuery(identity)],
1637
+ })
1638
+ } else if (
1639
+ spec.ttl !== undefined &&
1640
+ (entry.ttl === undefined || spec.ttl > entry.ttl)
1641
+ ) {
1642
+ // Raised ttl (#102): widen Mirror grace with a cheap re-send (ADR-0015).
1643
+ // DELIBERATE ratchet (max-of-EVER, not max-of-live) — lowering would need
1644
+ // per-holder ttl tracking. Cost: wider storage-only grace after a high-ttl
1645
+ // mount leaves (ADR-0014, 24h server clamp); wire lifetime (Q2) unchanged.
1646
+ entry.ttl = spec.ttl
1647
+ if (this.#connected) {
1648
+ this.#socket.send({
1649
+ type: 'subscribe',
1650
+ queries: [this.#desiredQuery(identity)],
1651
+ })
1652
+ }
1653
+ }
1654
+ entry.count += 1
1655
+ return entry
1656
+ }
1657
+
1658
+ /**
1659
+ * Commit ownership for one subscription: {@link #retainDesire} plus the shared
1660
+ * {@link RawRead} (reclaim warm, else build and catch up).
1661
+ */
1662
+ #retainSubscription(identity: string, spec: SubscriptionSpec): RawRead {
1663
+ const entry = this.#retainDesire(identity, spec)
1664
+ if (entry.read === null) {
1665
+ // Prefer warm reclaim (zero re-exec); else build the shared read.
1666
+ const warm = this.#warmReads.get(identity)
1667
+ if (warm !== undefined) {
1668
+ clearTimeout(warm.timer)
1669
+ this.#warmReads.delete(identity)
1670
+ entry.read = warm.read
1671
+ // Parked reads miss fan-out; catch up on reclaim. rowsEqual dedups so
1672
+ // unchanged → one SELECT, no notify; handles only move forward (Q1).
1673
+ this.#initialCompute(warm.read)
1674
+ } else {
1675
+ const read = new RawRead(
1676
+ {
1677
+ run: (st) => this.#db.exec(st.sql, ...st.params),
1678
+ readSet: () => {
1679
+ const info = this.#instanceInfo.get(identity)
1680
+ return info === undefined ? undefined : new Set(info.readSet)
1681
+ },
1682
+ },
1683
+ spec.statement,
1684
+ identity,
1685
+ phaseToStatus(entry.phase, entry.error),
1686
+ )
1687
+ entry.read = read
1688
+ this.#initialCompute(read)
1689
+ }
1690
+ }
1691
+ return entry.read
1692
+ }
1693
+
1694
+ /**
1695
+ * Preload ownership (#104): same desire as subscribe (shared instance on
1696
+ * byte-identical statements), no local read — poke hydrates. Cleanup is
1697
+ * ordinary release.
1698
+ */
1699
+ #retainPreload(identity: string, spec: SubscriptionSpec): void {
1700
+ this.#retainDesire(identity, spec)
1701
+ }
1702
+
1703
+ /**
1704
+ * Drop one desire ref (#104, #120). Last release removes the entry and, when
1705
+ * connected, sends `unsubscribe` (name+args; Mirror re-resolves under its
1706
+ * ctx). Mirror moves into ADR-0014 TTL grace (out of Sweep, membership warm)
1707
+ * so a re-subscribe is pk-list catch-up. Non-last release only decrements.
1708
+ *
1709
+ * Wire fires immediately on last drop — no client linger (#120/Q2). Warm pool
1710
+ * is ONLY local paint budget (StrictMode / hop-back); Mirror TTL is the
1711
+ * durable warmth. Re-retain within the tick re-sends `subscribe` (cheap via
1712
+ * #225 `heldAt`).
1713
+ *
1714
+ * Offline/halted (`#connected` false): drop desire silently, queue nothing —
1715
+ * next handshake rebuilds from {@link #desired}, which already omits this
1716
+ * instance. Racing an already-inactive Mirror sub is a no-op server-side.
1717
+ */
1718
+ #releaseSubscription(identity: string): void {
1719
+ const entry = this.#desired.get(identity)
1720
+ if (entry === undefined || --entry.count > 0) return
1721
+ // Leave declared truth immediately; wire unsubscribes NOW (Q2); rows → Warm.
1722
+ this.#desired.delete(identity)
1723
+ if (this.#connected) {
1724
+ this.#socket.send({
1725
+ type: 'unsubscribe',
1726
+ queries: [
1727
+ {
1728
+ name: entry.name,
1729
+ args:
1730
+ entry.args === undefined
1731
+ ? undefined
1732
+ : encodeArgs(entry.args, 'query args'),
1733
+ },
1734
+ ],
1735
+ })
1736
+ }
1737
+ // Stamp park position with the unsubscribe (ADR-0014 / #224) for heldAt
1738
+ // (#225) and GC (#226). No cookie ⇒ no stamp. Must not join the optimistic
1739
+ // SAVEPOINT (#139 / #223) — use the stamp envelope (also piggybacks clock).
1740
+ this.#stampLastRelease(identity, entry.ttl)
1741
+ if (entry.read !== null) this.#warmRead(identity, entry.read)
1742
+ }
1743
+
1744
+ /**
1745
+ * Last-release stamp (ADR-0014 / #224). No-ops without a scalar cookie. Same
1746
+ * envelope runs membership GC (#226) and persists the connected clock (#223)
1747
+ * — GC may delete fully-orphaned rows, so notify the touched set (not the
1748
+ * meta-only {@link #sideWrite} path).
1749
+ */
1750
+ #stampLastRelease(identity: string, declaredTtl: number | undefined): void {
1751
+ const cookie = this.#cookie
1752
+ if (cookie === null) return
1753
+ const stamp: ReleaseStamp = {
1754
+ instance: identity,
1755
+ cookie,
1756
+ releasedAt: this.#connectedClock,
1757
+ ttlMs: resolveReleaseTtlMs(declaredTtl),
1758
+ }
1759
+ this.#exclusive(() =>
1760
+ thenMaybe(
1761
+ this.#rebase(() => {
1762
+ upsertReleaseStamp(this.#db, stamp)
1763
+ // After stamp: non-positive ttl self-collects; aged/stampless siblings
1764
+ // ride the same write (`#desired` live ⇒ stampless of undesired is
1765
+ // safe). Clock always piggybacks on a stamp write (#223).
1766
+ this.#runMembershipGc({ includeStampless: true })
1767
+ this.#persistConnectedClock()
1768
+ }),
1769
+ (touched) => this.#notifyChange(touched),
1770
+ ),
1771
+ )
1772
+ }
1773
+
1774
+ /**
1775
+ * Persist connected-clock reading (ADR-0014 / #223). Caller provides the
1776
+ * durable envelope. Never a naive standalone writeMeta (#139).
1777
+ */
1778
+ #persistConnectedClock(): void {
1779
+ writeMeta(
1780
+ this.#db,
1781
+ __CONNECTED_CLOCK_META_KEY,
1782
+ String(this.#connectedClock),
1783
+ )
1784
+ }
1785
+
1786
+ /**
1787
+ * Membership GC (ADR-0014 / #226) via ADR-0028 {@link #breakStaleInstance}
1788
+ * (memberships + stamp + fully-orphaned rows).
1789
+ *
1790
+ * Eligible when not desired, and either: stamp with `ttlMs <= 0`; stamp age
1791
+ * `connectedClock - releasedAt > ttlMs`; or no stamp at all when
1792
+ * `includeStampless` (in-session only — boot `#desired` is empty, stampless
1793
+ * would wipe live failover survivors). Hygiene only under ADR-0029 (early ⇒
1794
+ * full hydrate; late ⇒ server already forgot). Inside a durable envelope;
1795
+ * piggybacks the clock when it breaks anything.
1796
+ */
1797
+ #runMembershipGc(opts: { includeStampless: boolean }): void {
1798
+ const victims = this.#gcEligibleInstances(opts.includeStampless)
1799
+ if (victims.length === 0) return
1800
+ for (const instance of victims) this.#breakStaleInstance(instance)
1801
+ this.#persistConnectedClock()
1802
+ }
1803
+
1804
+ /** Instances eligible for membership GC this sweep (order unspecified). */
1805
+ #gcEligibleInstances(includeStampless: boolean): string[] {
1806
+ const desired = this.#desired
1807
+ const stamps = listReleaseStamps(this.#db)
1808
+ const stamped = new Set(stamps.map((s) => s.instance))
1809
+ const eligible: string[] = []
1810
+
1811
+ // Stampless of un-desired — only with a live desired set (see GC doc).
1812
+ if (includeStampless) {
1813
+ const held = this.#db.exec<{ instance: SqlValue }>(
1814
+ `SELECT DISTINCT instance FROM __doync_membership`,
1815
+ )
1816
+ for (const row of held) {
1817
+ const instance = String(row.instance)
1818
+ if (desired.has(instance) || stamped.has(instance)) continue
1819
+ eligible.push(instance)
1820
+ }
1821
+ }
1822
+
1823
+ // Stamped releases past (or non-positive) ttl.
1824
+ const clock = this.#connectedClock
1825
+ for (const stamp of stamps) {
1826
+ if (desired.has(stamp.instance)) continue
1827
+ if (stamp.ttlMs <= 0 || clock - stamp.releasedAt > stamp.ttlMs) {
1828
+ eligible.push(stamp.instance)
1829
+ }
1830
+ }
1831
+ return eligible
1832
+ }
1833
+
1834
+ /**
1835
+ * Park a released read in the Warm pool (CONTEXT.md; ADR-0023) for one policy
1836
+ * tick ({@link WARM_POOL_TICK_MS}) — re-retain reclaims without re-exec
1837
+ * (StrictMode, j/k hops). Wire half already fired.
1838
+ */
1839
+ #warmRead(key: string, read: RawRead): void {
1840
+ const prior = this.#warmReads.get(key)
1841
+ if (prior !== undefined) clearTimeout(prior.timer)
1842
+ this.#warmReads.set(key, {
1843
+ read,
1844
+ timer: setTimeout(() => {
1845
+ const parked = this.#warmReads.get(key)
1846
+ if (parked === undefined || parked.read !== read) return
1847
+ this.#warmReads.delete(key)
1848
+ this.#gatedViews.delete(read)
1849
+ }, WARM_POOL_TICK_MS),
1850
+ })
1851
+ }
1852
+
1853
+ /**
1854
+ * Once (ADR-0012 / ADR-0021): cache-and-network. Local replica first (real
1855
+ * SQL `[]` when empty); Mirror executes once under its ctx. Local
1856
+ * exec/decode/shape errors throw at call (#141). Leaves no
1857
+ * Subscription/CVR/Membership. Bound form only (ADR-0027 / #200); falsy never
1858
+ * starts the network half.
1859
+ */
1860
+ once<Row extends Record<string, unknown> = Record<string, SqlValue>>(
1861
+ query: BoundQuery<Row, boolean> | FalsyQuery,
1862
+ ): OnceView<Row> {
1863
+ if (isFalsyQuery(query)) {
1864
+ return new SkippedOnceView() as unknown as OnceView<Row>
1865
+ }
1866
+ const { query: leaf, args } = normalizeQuerySurface('once', query)
1867
+ const name = leaf.name as string
1868
+ const resolved = leaf.resolve({ args, ctx: this.#ctx })
1869
+ // Cache half; gate only mid-async-replay (torn overlay). Fail-loud.
1870
+ let cached: readonly Record<string, unknown>[]
1871
+ if (this.#chainBusy) {
1872
+ cached = []
1873
+ } else {
1874
+ const raw = this.#db.exec(resolved.sql, ...resolved.params)
1875
+ cached = resolved.decode ? resolved.decode(raw) : raw
1876
+ }
1877
+ const id = `once-${this.#nextOnceSeq++}`
1878
+ // Decode also applies to the server answer. Pure construction (Q3/Q7):
1879
+ // network on first use; dispose deferred one Warm tick for StrictMode.
1880
+ // Encode wire args once (ADR-0031 / #230) for reconnect re-issue.
1881
+ const wireArgs =
1882
+ args === undefined ? undefined : encodeArgs(args, 'query args')
1883
+ const view = new OnceViewImpl(cached, resolved.decode, {
1884
+ start: () => {
1885
+ this.#onceRequests.set(id, { view, name, args: wireArgs })
1886
+ if (this.#connected) {
1887
+ this.#socket.send({ type: 'once', id, name, args: wireArgs })
1888
+ }
1889
+ },
1890
+ drop: () => {
1891
+ this.#onceRequests.delete(id)
1892
+ this.#onceBuffers.delete(id)
1893
+ },
1894
+ })
1895
+ return view as unknown as OnceView<Row>
1896
+ }
1897
+
1898
+ /**
1899
+ * Local read (ADR-0019/0021): arbitrary SQL over the replica, never upstream;
1900
+ * re-run on any local commit (unhinted).
1901
+ */
1902
+ local<Row extends Record<string, unknown> = Record<string, SqlValue>>(
1903
+ sql: string,
1904
+ ...params: SqlValue[]
1905
+ ): View<Row> {
1906
+ // Pure compute twin of subscribe (ADR-0023/Q3). Always `complete` — local
1907
+ // answer is authoritative (#104).
1908
+ const key = `local:${sql}\u0000${JSON.stringify(params)}`
1909
+ const statement: LocalStatement = { sql, params }
1910
+ return new ViewHandle(
1911
+ {
1912
+ peek: () => this.#localReads.get(key)?.read ?? null,
1913
+ retain: () => this.#retainLocal(key, statement),
1914
+ release: () => this.#releaseLocal(key),
1915
+ compute: () => {
1916
+ if (this.#chainBusy) {
1917
+ return { rows: EMPTY_ROWS, status: UNKNOWN_STATUS }
1918
+ }
1919
+ return {
1920
+ rows: this.#db.exec(statement.sql, ...statement.params),
1921
+ status: COMPLETE_STATUS,
1922
+ }
1923
+ },
1924
+ },
1925
+ undefined,
1926
+ undefined,
1927
+ ) as unknown as View<Row>
1928
+ }
1929
+
1930
+ /** Local-read retain (subscription twin, no wire). */
1931
+ #retainLocal(key: string, statement: LocalStatement): RawRead {
1932
+ let entry = this.#localReads.get(key)
1933
+ if (entry === undefined) {
1934
+ entry = { count: 0, read: null }
1935
+ this.#localReads.set(key, entry)
1936
+ }
1937
+ entry.count += 1
1938
+ if (entry.read === null) {
1939
+ const warm = this.#warmReads.get(key)
1940
+ if (warm !== undefined) {
1941
+ clearTimeout(warm.timer)
1942
+ this.#warmReads.delete(key)
1943
+ entry.read = warm.read
1944
+ // Catch up across the parked window (see the subscription reclaim).
1945
+ this.#initialCompute(warm.read)
1946
+ } else {
1947
+ const read = new RawRead(
1948
+ {
1949
+ run: (st) => this.#db.exec(st.sql, ...st.params),
1950
+ readSet: () => undefined,
1951
+ },
1952
+ statement,
1953
+ undefined,
1954
+ COMPLETE_STATUS,
1955
+ )
1956
+ entry.read = read
1957
+ this.#initialCompute(read)
1958
+ }
1959
+ }
1960
+ return entry.read
1961
+ }
1962
+
1963
+ #releaseLocal(key: string): void {
1964
+ const entry = this.#localReads.get(key)
1965
+ if (entry === undefined || --entry.count > 0) return
1966
+ this.#localReads.delete(key)
1967
+ if (entry.read !== null) this.#warmRead(key, entry.read)
1968
+ }
1969
+
1970
+ // --- socket handlers ------------------------------------------------------
1971
+
1972
+ #onOpen(): void {
1973
+ // Reload skew: never re-handshake until the app reloads.
1974
+ if (this.#halted) return
1975
+ // First pong after (re)connect contributes zero (ADR-0014 / #223).
1976
+ this.#lastPongAt = null
1977
+ this.#setConnected(true)
1978
+ this.#sendHandshake()
1979
+ }
1980
+
1981
+ /**
1982
+ * Connected-time tick on keepalive pong (ADR-0014 / #223): elapsed wall time
1983
+ * since the previous pong of THIS connection; first pong only re-anchors.
1984
+ * Memory-only — durable persist rides the next poke apply.
1985
+ */
1986
+ #onPong(): void {
1987
+ const now = this.#now()
1988
+ if (this.#lastPongAt !== null) {
1989
+ const delta = now - this.#lastPongAt
1990
+ // Never count backward Wall-clock skew.
1991
+ if (delta > 0) this.#connectedClock += delta
1992
+ }
1993
+ this.#lastPongAt = now
1994
+ }
1995
+
1996
+ /**
1997
+ * Handshake + durable outbox in order (ADR-0016). `connect` carries
1998
+ * `schemaVersion` (ADR-0020) and cookie; server dedups re-pushes by lmid.
1999
+ * Used on (re)connect and post-wipe resync.
2000
+ */
2001
+ #sendHandshake(): void {
2002
+ // Every (re)connect must reconfirm session: desired → `pending` (#104).
2003
+ // First connect is a no-op notify for already-pending; reconnect drops
2004
+ // `complete` back to unknown until ack + poke.
2005
+ for (const identity of this.#desired.keys()) {
2006
+ this.#setPhase(identity, 'pending')
2007
+ }
2008
+ this.#socket.send({
2009
+ type: 'connect',
2010
+ clientId: this.#clientId,
2011
+ // Bearer on every handshake (ADR-0016/0018) — not only the upgrade cookie
2012
+ // a surviving SharedWorker may have captured pre-login (#92).
2013
+ ...(this.#token !== undefined ? { jwt: this.#token } : {}),
2014
+ cookie: this.#cookie,
2015
+ // heldAt from memberships + stamps (ADR-0029); no memberships → no claim.
2016
+ desiredQueries: [...this.#desired.keys()].map((id) =>
2017
+ this.#desiredQuery(id),
2018
+ ),
2019
+ schemaVersion: this.#bundleVersion,
2020
+ })
2021
+ for (const p of this.#pending) this.#sendPush(p)
2022
+ // Once is not durable (ADR-0012): re-ask; cache holds until answer. Fresh
2023
+ // onceStart resets a mid-stream partial buffer.
2024
+ for (const [id, once] of this.#onceRequests) {
2025
+ this.#socket.send({
2026
+ type: 'once',
2027
+ id,
2028
+ name: once.name,
2029
+ args: once.args,
2030
+ })
2031
+ }
2032
+ }
2033
+
2034
+ /**
2035
+ * Wire `DesiredQuery` (ADR-0029 / #225). Optional `opts.heldAt` for re-desire
2036
+ * that must capture the claim BEFORE stamp drop / becoming desired — else
2037
+ * `#heldAtFor` would treat a warm remount as live at the advanced cookie.
2038
+ * Present `opts` with `heldAt === undefined` is a real absence (do not
2039
+ * re-derive).
2040
+ *
2041
+ * Args reserved-key encoded here (ADR-0031 / #230) so every subscribe path
2042
+ * shares one choke point; in-memory desire keeps live values for resolve.
2043
+ */
2044
+ #desiredQuery(
2045
+ identity: string,
2046
+ opts?: { readonly heldAt: number | undefined },
2047
+ ): DesiredQuery {
2048
+ const entry = this.#desired.get(identity)
2049
+ // opts present ⇒ caller-derived (before desired/stamp mutations).
2050
+ const heldAt = opts !== undefined ? opts.heldAt : this.#heldAtFor(identity)
2051
+ return {
2052
+ name: entry?.name ?? identity,
2053
+ args:
2054
+ entry?.args === undefined
2055
+ ? undefined
2056
+ : encodeArgs(entry.args, 'query args'),
2057
+ ...(entry?.ttl !== undefined ? { ttl: entry.ttl } : {}),
2058
+ ...(heldAt !== undefined ? { heldAt } : {}),
2059
+ }
2060
+ }
2061
+
2062
+ /**
2063
+ * HeldAt (ADR-0029): no memberships → absent; desired → current cookie; stamp
2064
+ * → park cookie; memberships without stamp and not desired → absent
2065
+ * (crash-gap). After the membership gate: desired first, then stamp. Callers
2066
+ * mutating desired capture first and pass override to {@link #desiredQuery}.
2067
+ */
2068
+ #heldAtFor(identity: string): number | undefined {
2069
+ const hasMembership =
2070
+ this.#db.exec(
2071
+ `SELECT 1 FROM __doync_membership WHERE instance = ? LIMIT 1`,
2072
+ identity,
2073
+ ).length > 0
2074
+ if (!hasMembership) return undefined
2075
+ if (this.#desired.has(identity)) {
2076
+ return this.#cookie ?? undefined
2077
+ }
2078
+ const stamp = readReleaseStamp(this.#db, identity)
2079
+ if (stamp !== null) return stamp.cookie
2080
+ // Crash-gap / pre-feature: honest silence → server full-hydrates.
2081
+ return undefined
2082
+ }
2083
+
2084
+ #onMessage(message: ServerMessage): void {
2085
+ // Reload skew: ignore all but framed errors (new-shape frames would corrupt).
2086
+ if (this.#halted) {
2087
+ if (message.type === 'error') this.#errors.push(message.message)
2088
+ return
2089
+ }
2090
+ // Keepalive pong (ADR-0014 / #223): ticks clock; not handshake proof (never
2091
+ // clears needs-auth; hub parity). Before auth inference.
2092
+ if (message.type === 'pong') {
2093
+ this.#onPong()
2094
+ return
2095
+ }
2096
+ // Auth inference (#136, ADR-0018; hub parity): `unauthorized` => sticky
2097
+ // needs-auth; any substantive later frame clears it. Framed `error` does
2098
+ // not. Engine-owned — seam never reports needs-auth.
2099
+ if (message.type === 'unauthorized') {
2100
+ this.#errors.push(message.message)
2101
+ this.#setNeedsAuth(true)
2102
+ return
2103
+ }
2104
+ if (this.#needsAuth && message.type !== 'error') {
2105
+ this.#setNeedsAuth(false)
2106
+ }
2107
+ switch (message.type) {
2108
+ case 'schemaSkew':
2109
+ this.#onSchemaSkew(message)
2110
+ break
2111
+ case 'schema':
2112
+ this.#onSchemaDirective(message)
2113
+ break
2114
+ case 'subscribeAck':
2115
+ // Ack proves handshake accepted — clear transient back-off / resync.
2116
+ this.#clearTransientSchemaEvent()
2117
+ for (const info of message.instances) {
2118
+ this.#instanceInfo.set(info.instance, info)
2119
+ // Half of complete (#104): -> acked until hydration pokeEnd. Clears
2120
+ // prior error; never regresses already-complete.
2121
+ const entry = this.#desired.get(info.instance)
2122
+ if (entry !== undefined && entry.phase !== 'complete') {
2123
+ this.#setPhase(info.instance, 'acked')
2124
+ }
2125
+ }
2126
+ break
2127
+ case 'pokeStart':
2128
+ this.#clearTransientSchemaEvent()
2129
+ this.#pokeBuffer = []
2130
+ break
2131
+ case 'pokePart':
2132
+ if (this.#pokeBuffer === null) {
2133
+ this.#errors.push('doync: pokePart arrived with no open poke')
2134
+ } else {
2135
+ this.#pokeBuffer.push(...message.patches)
2136
+ }
2137
+ break
2138
+ case 'pokeEnd':
2139
+ if (this.#pokeBuffer === null) {
2140
+ this.#errors.push('doync: pokeEnd arrived with no open poke')
2141
+ } else {
2142
+ this.#applyPoke(
2143
+ message.cookie,
2144
+ message.lastMutationId,
2145
+ message.confirms,
2146
+ )
2147
+ }
2148
+ break
2149
+ case 'pokeReject':
2150
+ this.#handleReject(message.mutationId, message.error)
2151
+ break
2152
+ case 'onceStart':
2153
+ // Fresh Once accumulator (ADR-0012); reconnect re-issue resets partial.
2154
+ this.#onceBuffers.set(message.id, [])
2155
+ break
2156
+ case 'oncePart': {
2157
+ const buffer = this.#onceBuffers.get(message.id)
2158
+ if (buffer === undefined) {
2159
+ this.#errors.push('doync: oncePart arrived with no open once')
2160
+ } else {
2161
+ buffer.push(...message.rows)
2162
+ }
2163
+ break
2164
+ }
2165
+ case 'onceEnd':
2166
+ this.#handleOnceEnd(message)
2167
+ break
2168
+ case 'resyncRequired':
2169
+ // heldAt-ahead tripwire (ADR-0029 / #225): resync self-heal (keep
2170
+ // clientId + pendings). First-class frame, not phrase-matched.
2171
+ this.#errors.push(message.message)
2172
+ this.#exclusive(() =>
2173
+ this.#wipeAndResync(
2174
+ `heldAt ahead of vouched baseline (instance ${message.instance}: heldAt=${message.heldAt} baseline=${message.baseline}) — ${message.message}`,
2175
+ ),
2176
+ )
2177
+ break
2178
+ case 'error':
2179
+ this.#errors.push(message.message)
2180
+ // Cookie-above-head (ADR-0020 addendum): wipe/redeploy makes every
2181
+ // returning cookie above head — heal via wipe-and-resync (keep pending
2182
+ // + clientId), not permanent sub error. Pendings re-push (ADR-0015).
2183
+ if (isCookieAboveHeadError(message.message)) {
2184
+ this.#exclusive(() =>
2185
+ this.#wipeAndResync(
2186
+ `cookie above the Origin head (server reset) — ${message.message}`,
2187
+ ),
2188
+ )
2189
+ break
2190
+ }
2191
+ // Named instances -> exact fail (#114); else coarse tar unconfirmed.
2192
+ if (message.instances !== undefined) {
2193
+ this.#failInstances(message.instances, message.message)
2194
+ } else {
2195
+ this.#failUnconfirmed(message.message)
2196
+ }
2197
+ break
2198
+ default:
2199
+ // Unknown ServerMessage type => Mirror ahead of bundle — surface loud.
2200
+ this.#errors.push(
2201
+ `doync: unknown ServerMessage type ${String(
2202
+ (message as { type?: unknown }).type,
2203
+ )}`,
2204
+ )
2205
+ }
2206
+ }
2207
+
2208
+ /**
2209
+ * Fail exactly the instances the Mirror named on an `error` frame (#114).
2210
+ * Skips already-complete/dropped; later ack recovers. Fallback for un-named
2211
+ * errors is {@link #failUnconfirmed}.
2212
+ */
2213
+ #failInstances(instances: readonly string[], message: string): void {
2214
+ const error = new Error(message)
2215
+ for (const identity of instances) {
2216
+ const entry = this.#desired.get(identity)
2217
+ if (
2218
+ entry !== undefined &&
2219
+ (entry.phase === 'pending' || entry.phase === 'acked')
2220
+ ) {
2221
+ this.#setPhase(identity, 'error', error)
2222
+ }
2223
+ }
2224
+ }
2225
+
2226
+ /**
2227
+ * Un-attributable framed `error` -> every pending/acked instance (#104/#114).
2228
+ * Complete untouched; later ack recovers. Prefer {@link #failInstances}.
2229
+ */
2230
+ #failUnconfirmed(message: string): void {
2231
+ const error = new Error(message)
2232
+ for (const [identity, entry] of this.#desired) {
2233
+ if (entry.phase === 'pending' || entry.phase === 'acked') {
2234
+ this.#setPhase(identity, 'error', error)
2235
+ }
2236
+ }
2237
+ }
2238
+
2239
+ /**
2240
+ * OnceEnd (ADR-0012): deliver accumulated parts to {@link OnceViewImpl} or
2241
+ * reject on framed error. No cookie/lmid. Stray onceEnd (disposed / already
2242
+ * settled) is benign — drop it.
2243
+ */
2244
+ #handleOnceEnd(message: OnceEndMessage): void {
2245
+ const buffered = this.#onceBuffers.get(message.id) ?? []
2246
+ this.#onceBuffers.delete(message.id)
2247
+ const pending = this.#onceRequests.get(message.id)
2248
+ if (pending === undefined) return
2249
+ this.#onceRequests.delete(message.id)
2250
+ if (message.error !== undefined) {
2251
+ pending.view.settleError(new Error(message.error))
2252
+ return
2253
+ }
2254
+ pending.view.deliver(buffered.map(decodeOnceRow))
2255
+ }
2256
+
2257
+ // --- rebase ---------------------------------------------------------------
2258
+
2259
+ /**
2260
+ * PokeEnd apply (ADR-0013/0016/0019). Capture buffer sync (next poke may
2261
+ * reset it), then exclusive chain so poke never rolls an overlay mid-await.
2262
+ * Rolls overlay, applies patches (puts before dels), drops rejected-then-
2263
+ * acked, persists cookie with batch, replays survivors.
2264
+ */
2265
+ #applyPoke(
2266
+ cookie: number,
2267
+ lastMutationId: number | null,
2268
+ confirms: readonly string[] | undefined,
2269
+ ): void {
2270
+ const patches = this.#pokeBuffer ?? []
2271
+ this.#pokeBuffer = null
2272
+ this.#exclusive(() =>
2273
+ this.#doApplyPoke(patches, cookie, lastMutationId, confirms),
2274
+ )
2275
+ }
2276
+
2277
+ #doApplyPoke(
2278
+ patches: readonly Patch[],
2279
+ cookie: number,
2280
+ lastMutationId: number | null,
2281
+ confirms: readonly string[] | undefined,
2282
+ ): Awaitable<void> {
2283
+ const db = this.#db
2284
+ const acked: number[] = []
2285
+
2286
+ const touched = this.#rebase(() => {
2287
+ // Puts before dels/prunes (ADR-0013) — row moving instances never flaps.
2288
+ for (const patch of patches) if (patch.op === 'put') this.#applyPut(patch)
2289
+ for (const patch of patches) if (patch.op === 'del') this.#applyDel(patch)
2290
+ // pks (ADR-0013/0015): after puts, prune held rows absent from the list
2291
+ // (cookie-filtered / warm reactivation). Same EXISTS refcount as dels.
2292
+ for (const patch of patches) if (patch.op === 'pks') this.#applyPks(patch)
2293
+
2294
+ // Drop rejected-then-acked (order): rejected already gone; lmid settles
2295
+ // only non-rejected; rejected ids covered by lmid stay rejected.
2296
+ if (lastMutationId !== null) {
2297
+ for (const p of this.#pending) {
2298
+ if (
2299
+ p.mutationId <= lastMutationId &&
2300
+ !this.#rejected.has(p.mutationId)
2301
+ ) {
2302
+ acked.push(p.mutationId)
2303
+ }
2304
+ }
2305
+ for (const id of acked) {
2306
+ db.exec(`DELETE FROM __doync_pending WHERE mutation_id = ?`, id)
2307
+ }
2308
+ }
2309
+
2310
+ // Cookie advances only here, with the batch (ADR-0016).
2311
+ this.#cookie = cookie
2312
+ writeMeta(db, 'cookie', String(cookie))
2313
+ // Connected clock on same txn (ADR-0014 / #223); crash => slow (safe).
2314
+ this.#persistConnectedClock()
2315
+ this.#pending = this.#pending.filter((p) => !acked.includes(p.mutationId))
2316
+ })
2317
+
2318
+ return thenMaybe(touched, (t) => {
2319
+ for (const id of acked) this.#settleServer(id, null)
2320
+ for (const id of acked) this.#rejected.delete(id)
2321
+ this.#notifyChange(t)
2322
+ // Other half of complete (#104/#114): promote named `confirms` after
2323
+ // rows (rows-then-status). Empty hydration still completes.
2324
+ this.#promoteConfirmed(confirms)
2325
+ })
2326
+ }
2327
+
2328
+ /**
2329
+ * Promote hydration/reactivation `confirms` to `complete` (#114). Marker-
2330
+ * only — a Sweep poke with no/empty confirms promotes nothing (#114 race).
2331
+ * Only `acked` promotes; pending waits for ack; already-complete is no-op.
2332
+ */
2333
+ #promoteConfirmed(confirms: readonly string[] | undefined): void {
2334
+ if (confirms === undefined) return
2335
+ for (const identity of confirms) {
2336
+ const entry = this.#desired.get(identity)
2337
+ if (entry !== undefined && entry.phase === 'acked') {
2338
+ this.#setPhase(identity, 'complete')
2339
+ }
2340
+ }
2341
+ }
2342
+
2343
+ /** Apply one put (ADR-0013): membership + upsert (delete-by-pk then insert). */
2344
+ #applyPut(patch: Extract<Patch, { op: 'put' }>): void {
2345
+ const db = this.#db
2346
+ const table = this.#tableFor(patch.instance, patch.level)
2347
+ db.exec(
2348
+ `INSERT INTO __doync_membership (instance, level, tbl, pk) VALUES (?, ?, ?, ?)
2349
+ ON CONFLICT (instance, level, pk) DO UPDATE SET tbl = excluded.tbl`,
2350
+ patch.instance,
2351
+ patch.level,
2352
+ table.name,
2353
+ patch.pk,
2354
+ )
2355
+ const pkValues = parsePk(patch.pk, table)
2356
+ db.exec(
2357
+ `DELETE FROM ${quoteIdent(table.name)} WHERE ${pkWhereClause(table)}`,
2358
+ ...pkValues,
2359
+ )
2360
+ const { columns, values } = decodePatchImage(table, patch.image)
2361
+ db.exec(
2362
+ `INSERT INTO ${quoteIdent(table.name)} (${columns.map(quoteIdent).join(', ')})
2363
+ VALUES (${columns.map(() => '?').join(', ')})`,
2364
+ ...values,
2365
+ )
2366
+ }
2367
+
2368
+ /**
2369
+ * Apply one del (ADR-0013): drop membership; delete row only when last ref is
2370
+ * gone (EXISTS).
2371
+ */
2372
+ #applyDel(patch: Extract<Patch, { op: 'del' }>): void {
2373
+ this.#retractRow(patch.instance, patch.level, patch.pk)
2374
+ }
2375
+
2376
+ /**
2377
+ * Apply one pks patch (ADR-0013/0015): complete pk-list for (instance,
2378
+ * level). Prune held rows absent from the list via the same EXISTS retract as
2379
+ * del.
2380
+ */
2381
+ #applyPks(patch: PksPatch): void {
2382
+ const keep = new Set(patch.pks)
2383
+ const held = this.#db.exec<{ pk: SqlValue }>(
2384
+ `SELECT pk FROM __doync_membership WHERE instance = ? AND level = ?`,
2385
+ patch.instance,
2386
+ patch.level,
2387
+ )
2388
+ for (const row of held) {
2389
+ const pk = String(row.pk)
2390
+ if (!keep.has(pk)) this.#retractRow(patch.instance, patch.level, pk)
2391
+ }
2392
+ }
2393
+
2394
+ /**
2395
+ * Retract one membership (ADR-0013/0028): drop ref; delete row when no
2396
+ * DESIRED holder remains (#215). Released holders must not pin
2397
+ * authoritatively retracted rows. Only-stale remaining => delete row and
2398
+ * {@link #breakStaleInstance} so next subscribe omits heldAt.
2399
+ */
2400
+ #retractRow(instance: string, level: number, pk: string): void {
2401
+ const db = this.#db
2402
+ const table = this.#tableFor(instance, level)
2403
+ db.exec(
2404
+ `DELETE FROM __doync_membership WHERE instance = ? AND level = ? AND pk = ?`,
2405
+ instance,
2406
+ level,
2407
+ pk,
2408
+ )
2409
+ const holders = db.exec<{ instance: string }>(
2410
+ `SELECT DISTINCT instance FROM __doync_membership WHERE tbl = ? AND pk = ?`,
2411
+ table.name,
2412
+ pk,
2413
+ )
2414
+ // Desired holder keeps the row (ADR-0013 EXISTS).
2415
+ if (holders.some((h) => this.#desired.has(String(h.instance)))) return
2416
+ db.exec(
2417
+ `DELETE FROM ${quoteIdent(table.name)} WHERE ${pkWhereClause(table)}`,
2418
+ ...parsePk(pk, table),
2419
+ )
2420
+ // Only stale holders: retraction outranks warmth (ADR-0028).
2421
+ for (const h of holders) this.#breakStaleInstance(String(h.instance))
2422
+ }
2423
+
2424
+ /**
2425
+ * Break stale (undesired) warmth (ADR-0028): drop all memberships and orphan
2426
+ * rows. Authoritative retraction proved parked state stale; clearing
2427
+ * memberships makes next subscribe's heldAt honestly absent (full rehydrate).
2428
+ * Rows still referenced elsewhere survive.
2429
+ */
2430
+ #breakStaleInstance(instance: string): void {
2431
+ const db = this.#db
2432
+ const refs = db.exec<{ tbl: string; pk: string }>(
2433
+ `SELECT DISTINCT tbl, pk FROM __doync_membership WHERE instance = ?`,
2434
+ instance,
2435
+ )
2436
+ db.exec(`DELETE FROM __doync_membership WHERE instance = ?`, instance)
2437
+ // Drop stamp with memberships (ADR-0028 / #224) so next subscribe cannot
2438
+ // claim a stale heldAt. Caller supplies durable envelope (poke apply or GC).
2439
+ deleteReleaseStamp(db, instance)
2440
+ for (const ref of refs) {
2441
+ const tblName = String(ref.tbl)
2442
+ const pk = String(ref.pk)
2443
+ const stillReferenced = db.exec(
2444
+ `SELECT 1 FROM __doync_membership WHERE tbl = ? AND pk = ? LIMIT 1`,
2445
+ tblName,
2446
+ pk,
2447
+ )
2448
+ if (stillReferenced.length === 0) {
2449
+ const table = requireTable(this.#schema, tblName)
2450
+ db.exec(
2451
+ `DELETE FROM ${quoteIdent(table.name)} WHERE ${pkWhereClause(table)}`,
2452
+ ...parsePk(pk, table),
2453
+ )
2454
+ }
2455
+ }
2456
+ }
2457
+
2458
+ #tableFor(instance: string, level: number): ReturnType<typeof requireTable> {
2459
+ const info = this.#instanceInfo.get(instance)
2460
+ if (info === undefined) {
2461
+ throw new Error(
2462
+ `doync: patch for unknown instance ${instance} — a subscribeAck must precede its hydration poke`,
2463
+ )
2464
+ }
2465
+ const levelInfo = info.levels.find((l) => l.level === level)
2466
+ if (levelInfo === undefined) {
2467
+ throw new Error(
2468
+ `doync: instance ${instance} has no Level ${level} in its subscribeAck`,
2469
+ )
2470
+ }
2471
+ return requireTable(this.#schema, levelInfo.table)
2472
+ }
2473
+
2474
+ /**
2475
+ * Rejection (ADR-0016/0019): mark rejected immediately (lmid cannot resolve
2476
+ * it even under race), then exclusive-chain drop pending, reject `server`,
2477
+ * re-project (optimistic effect vanishes).
2478
+ */
2479
+ #handleReject(mutationId: number, error: string): void {
2480
+ this.#rejected.add(mutationId)
2481
+ this.#exclusive(() =>
2482
+ thenMaybe(this.#dropPending(mutationId), (touched) => {
2483
+ this.#settleServer(mutationId, new Error(error))
2484
+ this.#notifyChange(touched)
2485
+ }),
2486
+ )
2487
+ }
2488
+
2489
+ // --- schema handling (ADR-0020) -------------------------------------------
2490
+
2491
+ /**
2492
+ * Schema-skew handshake answer (ADR-0020, reject-don't-project): -
2493
+ * `client-stale` — bundle behind Mirror; emit reload and HALT. -
2494
+ * `client-ahead` — mid-deploy Mirror; back off via socket reconnect (adapter
2495
+ * owns timer); clears on later successful handshake.
2496
+ */
2497
+ #onSchemaSkew(message: SchemaSkewMessage): void {
2498
+ const server =
2499
+ message.serverVersion === undefined
2500
+ ? ''
2501
+ : ` (server v${message.serverVersion})`
2502
+ if (message.reason === 'client-stale') {
2503
+ this.#emitSchemaEvent(
2504
+ 'reload',
2505
+ `schema client-stale: bundle v${this.#bundleVersion} is behind the Mirror${server} — reload for the new bundle`,
2506
+ )
2507
+ this.#halt()
2508
+ } else {
2509
+ this.#emitSchemaEvent(
2510
+ 'server-behind',
2511
+ `schema client-ahead: bundle v${this.#bundleVersion} is ahead of the Mirror${server} — backing off until it deploys`,
2512
+ )
2513
+ this.#setConnected(false)
2514
+ this.#socket.reconnect()
2515
+ }
2516
+ }
2517
+
2518
+ /**
2519
+ * Mid-session `schema` directive (ADR-0020): apply OWN bundled DDL at this
2520
+ * feed position (never wire SQL).
2521
+ *
2522
+ * - Version > bundle → reload + HALT
2523
+ * - Version <= applied → idempotent no-op (redelivery)
2524
+ * - Else apply `(applied, version]`; failure → wipe-and-resync
2525
+ */
2526
+ #onSchemaDirective(message: SchemaDirectiveMessage): void {
2527
+ const version = message.version
2528
+ if (version > this.#bundleVersion) {
2529
+ this.#emitSchemaEvent(
2530
+ 'reload',
2531
+ `schema directive v${version} is above the bundled track v${this.#bundleVersion} — reload for the new bundle`,
2532
+ )
2533
+ this.#halt()
2534
+ return
2535
+ }
2536
+ if (version <= this.#appliedVersion) return
2537
+ // Schema apply on exclusive chain like every overlay touching op.
2538
+ this.#exclusive(() => this.#applySchemaDirective(version))
2539
+ }
2540
+
2541
+ #applySchemaDirective(version: number): Awaitable<void> {
2542
+ const failReason = (error: unknown): string =>
2543
+ `local migration to schema v${version} failed: ${errorMessage(error)}`
2544
+ let touched: Awaitable<Set<string>>
2545
+ try {
2546
+ touched = this.#rebase(() => {
2547
+ applyBundledMigrations(
2548
+ this.#db,
2549
+ this.#schema,
2550
+ this.#appliedVersion,
2551
+ version,
2552
+ )
2553
+ })
2554
+ } catch (error) {
2555
+ // Sync migration failure (ADR-0020): #rebase left a clean handle.
2556
+ return this.#wipeAndResync(failReason(error))
2557
+ }
2558
+ return thenMaybeCatch(
2559
+ touched,
2560
+ (t) => {
2561
+ this.#appliedVersion = version
2562
+ // Shape change can touch every read — re-project all consumer tables.
2563
+ this.#notifyChange(new Set([...t, ...this.#allConsumerTables()]))
2564
+ },
2565
+ // Async migration failure: same wipe via rejected rebase promise.
2566
+ (error) => this.#wipeAndResync(failReason(error)),
2567
+ )
2568
+ }
2569
+
2570
+ // --- recovery surface (ADR-0022) ------------------------------------------
2571
+
2572
+ /**
2573
+ * Rebuild sync state (ADR-0022 resync): wipe replica/memberships/cookie, KEEP
2574
+ * clientId + pending, rebootstrap. Same core as failed-migration and
2575
+ * cookie-above-head heals ({@link #wipeAndResync}). Exclusive chain; offline
2576
+ * rebootstrap rides next reconnect.
2577
+ */
2578
+ resync(): void {
2579
+ this.#exclusive(() =>
2580
+ this.#wipeAndResync('resync() requested by the consumer'),
2581
+ )
2582
+ }
2583
+
2584
+ /**
2585
+ * Erase this identity's local data (ADR-0022 forget): wipe replica,
2586
+ * memberships, pending, and all meta (clientId), then fresh first-sight under
2587
+ * a new clientId. Erases WHO, not just WHAT. Awaiters of forgotten writes are
2588
+ * rejected. Topology can swap OPFS on active forget (#134); this resets the
2589
+ * current store. Exclusive chain.
2590
+ */
2591
+ forget(): void {
2592
+ this.#exclusive(() =>
2593
+ this.#forgetAndReset('forget() requested by the consumer'),
2594
+ )
2595
+ }
2596
+
2597
+ // --- durable meta side-writes (ADR-0022, closeio/doync#139) ---------------
2598
+
2599
+ /**
2600
+ * Durably set {@link LogoutBehavior} at runtime ({@link
2601
+ * DoyncClient.setLogoutBehavior}). Via {@link #sideWriteMeta} so it commits
2602
+ * outside the optimistic overlay (#135 / ADR-0019). Direct engine stores for
2603
+ * RN; web DB worker reports to hub.
2604
+ */
2605
+ setLogoutBehavior(behavior: LogoutBehavior): void {
2606
+ this.#sideWriteMeta(__LOGOUT_BEHAVIOR_META_KEY, behavior)
2607
+ }
2608
+
2609
+ /**
2610
+ * Durable `__doync_meta` side write via rebase envelope (#139). For low-
2611
+ * frequency runtime flags ({@link setLogoutBehavior}). High-frequency values
2612
+ * (connected clock #223) stay in memory and piggyback poke apply — full
2613
+ * rebase per tick is wrong. Exclusive chain + {@link #rebase} so the write
2614
+ * commits on the base, not the overlay SAVEPOINT. Meta-only → discard touched
2615
+ * set.
2616
+ */
2617
+ #sideWriteMeta(key: string, value: string): void {
2618
+ this.#sideWrite(() => writeMeta(this.#db, key, value))
2619
+ }
2620
+
2621
+ /**
2622
+ * Durable side-body through overlay envelope (#139/#223/#224): exclusive +
2623
+ * {@link #rebase}. For meta and release stamps. Must NOT nest inside an
2624
+ * in-flight durable body (self-queue); those exec SQL on `this.#db` directly
2625
+ * (see `#breakStaleInstance`).
2626
+ */
2627
+ #sideWrite(body: () => void): void {
2628
+ this.#exclusive(() => thenMaybe(this.#rebase(body), () => {}))
2629
+ }
2630
+
2631
+ /**
2632
+ * Store-erase core for forget / corrupt-meta heal: drop consumer tables,
2633
+ * memberships, pending, all meta; re-replay bundled track from empty. Caller
2634
+ * mints identity. FKs already off (ADR-0009).
2635
+ */
2636
+ #forgetStore(db: LocalDb): void {
2637
+ dropConsumerTables(db, this.#schema)
2638
+ db.exec('DELETE FROM __doync_membership')
2639
+ // Wipe stamps with memberships (ADR-0014 / #224); table shape stays.
2640
+ clearReleaseStamps(db)
2641
+ db.exec('DELETE FROM __doync_pending')
2642
+ // All meta gone => genuine first-sight boot.
2643
+ db.exec('DELETE FROM __doync_meta')
2644
+ // Full bundled track from empty (re-records schema_version).
2645
+ applyBundledMigrations(db, this.#schema, 0, this.#bundleVersion)
2646
+ }
2647
+
2648
+ /**
2649
+ * Mid-session forget (ADR-0022): erase store, mint fresh identity, re-
2650
+ * handshake (or next reconnect). Unlike resync, discards identity and
2651
+ * pending.
2652
+ */
2653
+ #forgetAndReset(reason: string): Awaitable<void> {
2654
+ const db = this.#db
2655
+ const touched = this.#rebase(() => {
2656
+ // Reject awaiters before clearing the queue under them.
2657
+ this.#rejectAllPending(
2658
+ new Error(`doync: ${reason} — pending writes were forgotten`),
2659
+ )
2660
+ this.#forgetStore(db)
2661
+ // Fresh first-sight identity; old server state lapses (ADR-0015).
2662
+ this.#clientId = this.#generateId()
2663
+ writeMeta(db, 'client_id', this.#clientId)
2664
+ this.#cookie = null
2665
+ this.#nextMutationId = 1
2666
+ // Fresh identity => zero connected-time (memory coherent with wiped store).
2667
+ this.#connectedClock = 0
2668
+ this.#lastPongAt = null
2669
+ this.#pending = []
2670
+ })
2671
+ return this.#rebootstrapAfterReset(
2672
+ touched,
2673
+ 'forget',
2674
+ `forgot the local store — ${reason}`,
2675
+ )
2676
+ }
2677
+
2678
+ /**
2679
+ * Rebootstrap tail for {@link #wipeAndResync} and {@link #forgetAndReset}: mark
2680
+ * fresh, clear instance info, emit event, handshake once (or next reconnect),
2681
+ * re-project all consumer tables.
2682
+ */
2683
+ #rebootstrapAfterReset(
2684
+ touched: Awaitable<Set<string>>,
2685
+ kind: SchemaEventKind,
2686
+ message: string,
2687
+ ): Awaitable<void> {
2688
+ return thenMaybe(touched, (t) => {
2689
+ this.#appliedVersion = this.#bundleVersion
2690
+ this.#instanceInfo.clear()
2691
+ this.#emitSchemaEvent(kind, message)
2692
+ if (this.#connected) this.#sendHandshake()
2693
+ this.#notifyChange(new Set([...t, ...this.#allConsumerTables()]))
2694
+ })
2695
+ }
2696
+
2697
+ /**
2698
+ * Reject every outstanding mutation promise (forget discards their queue).
2699
+ * Idempotent; orphaneds swallow — frees in-flight callers only.
2700
+ */
2701
+ #rejectAllPending(error: Error): void {
2702
+ // Safe: Map iteration skips removed keys; settleServer deletes when done.
2703
+ for (const id of this.#promises.keys()) {
2704
+ this.#settleClient(id, error)
2705
+ this.#settleServer(id, error)
2706
+ }
2707
+ this.#keyed.clear()
2708
+ this.#recoveredKeys.clear()
2709
+ this.#rejected.clear()
2710
+ this.#optimisticFailed.clear()
2711
+ }
2712
+
2713
+ /**
2714
+ * Wipe-replica-and-resync core (ADR-0020) for resync verb, failed migration,
2715
+ * and cookie-above-head. Drop consumer tables, re-replay bundle, null cookie,
2716
+ * rebootstrap. SURVIVE: pending, clientId, next-mutid, `__doync_engine`
2717
+ * ledger (ADR-0024). Survivors re-push (ADR-0015 pending-without-cursor).
2718
+ */
2719
+ #wipeAndResync(reason: string): Awaitable<void> {
2720
+ const db = this.#db
2721
+ const touched = this.#rebase(() => {
2722
+ dropConsumerTables(db, this.#schema)
2723
+ // Memberships of dropped rows go; pending/clientId/next-id/ledger stay.
2724
+ db.exec('DELETE FROM __doync_membership')
2725
+ // Stamps with memberships (ADR-0014 / #224); table shape stays.
2726
+ clearReleaseStamps(db)
2727
+ // Full bundled track from empty base.
2728
+ writeMeta(db, 'schema_version', '0')
2729
+ applyBundledMigrations(db, this.#schema, 0, this.#bundleVersion)
2730
+ // Null cookie => full rebootstrap on handshake.
2731
+ this.#cookie = null
2732
+ db.exec('DELETE FROM __doync_meta WHERE k = ?', 'cookie')
2733
+ })
2734
+ // Survivors re-push on rebootstrap.
2735
+ return this.#rebootstrapAfterReset(
2736
+ touched,
2737
+ 'resync',
2738
+ `wiped and resyncing — ${reason}`,
2739
+ )
2740
+ }
2741
+
2742
+ /** Every consumer table name (untargeted re-projection set). */
2743
+ #allConsumerTables(): string[] {
2744
+ return this.#schema.tables.map((t) => t.name)
2745
+ }
2746
+
2747
+ /**
2748
+ * Schema-state transitions including silent clear (#89) so hooks re-read
2749
+ * {@link schemaStatus} and banners don't stick after recovery.
2750
+ */
2751
+ onSchemaChange(listener: () => void): () => void {
2752
+ this.#schemaListeners.add(listener)
2753
+ return () => this.#schemaListeners.delete(listener)
2754
+ }
2755
+
2756
+ /** Record a schema-state transition and notify listeners/callback. */
2757
+ #emitSchemaEvent(kind: SchemaEventKind, message: string): void {
2758
+ const event: SchemaEvent = { kind, message }
2759
+ this.#schemaEvent = event
2760
+ this.#onSchemaEvent?.(event)
2761
+ this.#notifySchema()
2762
+ }
2763
+
2764
+ /**
2765
+ * Clear transient schema state once sync resumes (ack/poke after
2766
+ * re-handshake): server-behind / resync / forget. `reload` is terminal
2767
+ * (halted ignores frames). Notifies subscribers; config callback has no
2768
+ * cleared kind.
2769
+ */
2770
+ #clearTransientSchemaEvent(): void {
2771
+ const kind = this.#schemaEvent?.kind
2772
+ if (kind === 'server-behind' || kind === 'resync' || kind === 'forget') {
2773
+ this.#schemaEvent = null
2774
+ this.#notifySchema()
2775
+ }
2776
+ }
2777
+
2778
+ /** Notify schema-state subscribers (set or clear). */
2779
+ #notifySchema(): void {
2780
+ for (const listener of this.#schemaListeners) listener()
2781
+ }
2782
+
2783
+ /** Stop handshakes and frame apply until app reload (reload skew). */
2784
+ #halt(): void {
2785
+ this.#halted = true
2786
+ this.#setConnected(false)
2787
+ }
2788
+
2789
+ // --- connection status (closeio/doync#105, #136) --------------------------
2790
+
2791
+ /**
2792
+ * {@link ConnectionStatus} to the Mirror (#105/#136). Precedence: 1.
2793
+ * `needs-auth` — engine inference off framed `unauthorized` (ADR-0018),
2794
+ * sticky until a real frame proves the refreshed handshake; outranks seam and
2795
+ * even a reopened socket. 2. `connected` — live open outranks a stale seam
2796
+ * report. 3. Last {@link SeamStatus}, else `disconnected`.
2797
+ *
2798
+ * Full five states when the seam reports; degraded adapters (no seam) get
2799
+ * connected/disconnected (+ needs-auth). Parity with shared-hub.ts.
2800
+ */
2801
+ get connectionStatus(): ConnectionStatus {
2802
+ if (this.#needsAuth) return 'needs-auth'
2803
+ if (this.#connected) return 'connected'
2804
+ return this.#seamStatus ?? 'disconnected'
2805
+ }
2806
+
2807
+ /** Subscribe to connection-status transitions (#105). */
2808
+ onConnectionChange(listener: () => void): () => void {
2809
+ this.#connectionListeners.add(listener)
2810
+ return () => this.#connectionListeners.delete(listener)
2811
+ }
2812
+
2813
+ /**
2814
+ * Flip handshake-live (#136). Live open clears stale seam status; needs-auth
2815
+ * untouched (clears only on proven frame). {@link #transitionConnection}
2816
+ * notifies only on visible change.
2817
+ */
2818
+ #setConnected(value: boolean): void {
2819
+ this.#transitionConnection(() => {
2820
+ this.#connected = value
2821
+ if (value) this.#seamStatus = null
2822
+ })
2823
+ }
2824
+
2825
+ /**
2826
+ * Seam transient (#136): connecting/error. Stored even while outranked so it
2827
+ * surfaces when higher precedence clears.
2828
+ */
2829
+ #onSeamStatus(status: SeamStatus): void {
2830
+ this.#transitionConnection(() => {
2831
+ this.#seamStatus = status
2832
+ })
2833
+ }
2834
+
2835
+ /** Set/clear sticky auth-failure (#136, ADR-0018). */
2836
+ #setNeedsAuth(value: boolean): void {
2837
+ this.#transitionConnection(() => {
2838
+ this.#needsAuth = value
2839
+ })
2840
+ }
2841
+
2842
+ /**
2843
+ * Mutate connection state; notify only when visible {@link connectionStatus}
2844
+ * changes (outranked inputs stay silent).
2845
+ */
2846
+ #transitionConnection(mutate: () => void): void {
2847
+ const before = this.connectionStatus
2848
+ mutate()
2849
+ if (this.connectionStatus === before) return
2850
+ for (const listener of this.#connectionListeners) listener()
2851
+ }
2852
+
2853
+ // --- reactivity -----------------------------------------------------------
2854
+
2855
+ /**
2856
+ * Re-project after a local change: subscription re-runs when Read-set
2857
+ * intersects touched consumer tables; Local (no Read-set) always re-runs.
2858
+ * Unchanged rows => no notify.
2859
+ *
2860
+ * Target TOUCHED TABLES (ADR-0019 delta 3), not patched instances — shared
2861
+ * replica means A's patch can change B's SQL over the same table.
2862
+ */
2863
+ #notifyChange(touched: ReadonlySet<string>): void {
2864
+ const consumerTouched = consumerTables(touched)
2865
+ // Overlay settled: flush views gated during the just-finished replay.
2866
+ // Re-entrant gating inside notify() is picked up by `#flushGatedViews`.
2867
+ const wasGated = new Set(this.#gatedViews)
2868
+ this.#gatedViews.clear()
2869
+ for (const read of this.#liveReads()) {
2870
+ const readSet = read.readSet
2871
+ const affected =
2872
+ wasGated.has(read) ||
2873
+ readSet === undefined ||
2874
+ [...readSet].some((t) => consumerTouched.has(t))
2875
+ if (affected && read.recompute()) read.notify()
2876
+ }
2877
+ }
2878
+
2879
+ /** Every live reactive read: desired + local (ADR-0023). */
2880
+ *#liveReads(): IterableIterator<RawRead> {
2881
+ for (const entry of this.#desired.values()) {
2882
+ if (entry.read !== null) yield entry.read
2883
+ }
2884
+ for (const entry of this.#localReads.values()) {
2885
+ if (entry.read !== null) yield entry.read
2886
+ }
2887
+ }
2888
+
2889
+ // --- status lifecycle (closeio/doync#104) ---------------------------------
2890
+
2891
+ /**
2892
+ * Set desire phase and push visible {@link ViewStatus} (#104). No-op if
2893
+ * unknown. pending→acked is invisible (stable status, no notify).
2894
+ */
2895
+ #setPhase(identity: string, phase: SubPhase, error?: Error): void {
2896
+ const entry = this.#desired.get(identity)
2897
+ if (entry === undefined) return
2898
+ entry.phase = phase
2899
+ entry.error = error
2900
+ const next = phaseToStatus(phase, error)
2901
+ if (entry.read !== null && entry.read.updateStatus(next)) {
2902
+ entry.read.notify()
2903
+ }
2904
+ }
2905
+
2906
+ // --- promise bookkeeping --------------------------------------------------
2907
+
2908
+ #ensurePromise(id: number, orphan: boolean): PendingPromise {
2909
+ const existing = this.#promises.get(id)
2910
+ if (existing !== undefined) return existing
2911
+ const promise = this.#makePromise(orphan)
2912
+ this.#promises.set(id, promise)
2913
+ return promise
2914
+ }
2915
+
2916
+ /**
2917
+ * Make `{client, server}` without id keying. mutate returns sync; id is
2918
+ * allocated on the chain so fail-fast consumes none.
2919
+ */
2920
+ #makePromise(orphan: boolean): PendingPromise {
2921
+ let resolveClient!: () => void
2922
+ let rejectClient!: (error: unknown) => void
2923
+ let resolveServer!: () => void
2924
+ let rejectServer!: (error: unknown) => void
2925
+ const client = new Promise<void>((res, rej) => {
2926
+ resolveClient = res
2927
+ rejectClient = rej
2928
+ })
2929
+ const server = new Promise<void>((res, rej) => {
2930
+ resolveServer = res
2931
+ rejectServer = rej
2932
+ })
2933
+ if (orphan) {
2934
+ // Orphan: swallow settlement (no caller in this process).
2935
+ client.catch(() => {})
2936
+ server.catch(() => {})
2937
+ }
2938
+ return {
2939
+ pair: { client, server },
2940
+ resolveClient,
2941
+ rejectClient,
2942
+ resolveServer,
2943
+ rejectServer,
2944
+ clientSettled: false,
2945
+ serverSettled: false,
2946
+ }
2947
+ }
2948
+
2949
+ #settleClient(id: number, error: unknown): void {
2950
+ const promise = this.#promises.get(id)
2951
+ if (promise === undefined) {
2952
+ // Unknown id would hang a waiting mutate — surface instead of silent no-op.
2953
+ this.#errors.push(
2954
+ `doync: settleClient for unknown mutation ${id} — no pending promise (a mutate() awaiting it would hang)`,
2955
+ )
2956
+ return
2957
+ }
2958
+ if (promise.clientSettled) return
2959
+ promise.clientSettled = true
2960
+ if (error === null) promise.resolveClient()
2961
+ else promise.rejectClient(error)
2962
+ }
2963
+
2964
+ #settleServer(id: number, error: unknown): void {
2965
+ const promise = this.#promises.get(id)
2966
+ if (promise === undefined) {
2967
+ this.#errors.push(
2968
+ `doync: settleServer for unknown mutation ${id} — no pending promise (a mutate() awaiting it would hang)`,
2969
+ )
2970
+ return
2971
+ }
2972
+ if (promise.serverSettled) return
2973
+ promise.serverSettled = true
2974
+ if (error === null) promise.resolveServer()
2975
+ else promise.rejectServer(error)
2976
+ if (promise.clientSettled) this.#promises.delete(id)
2977
+ }
2978
+ }
2979
+
2980
+ /**
2981
+ * Sync-or-async overlay currency (ADR-0019 addendum): ops return T now or a
2982
+ * Promise. Chained via isThenable / thenMaybe without microtasking the all-
2983
+ * sync path.
2984
+ */
2985
+ type Awaitable<T> = T | Promise<T>
2986
+
2987
+ /** One overlay-touching step on the exclusive chain (ADR-0019 addendum). */
2988
+ type ChainStep = () => Awaitable<void>
2989
+
2990
+ function isThenable(value: unknown): value is Promise<unknown> {
2991
+ return value instanceof Promise
2992
+ }
2993
+
2994
+ /** Continue with `fn` sync when already settled, else via Promise. */
2995
+ function thenMaybe<T, R>(
2996
+ value: Awaitable<T>,
2997
+ fn: (value: T) => Awaitable<R>,
2998
+ ): Awaitable<R> {
2999
+ return value instanceof Promise ? value.then(fn) : fn(value)
3000
+ }
3001
+
3002
+ /**
3003
+ * {@link thenMaybe} with async rejection continuation. Sync values only run
3004
+ * `onOk` (caller try/catch already handled sync throws).
3005
+ */
3006
+ function thenMaybeCatch<T, R>(
3007
+ value: Awaitable<T>,
3008
+ onOk: (value: T) => Awaitable<R>,
3009
+ onErr: (error: unknown) => Awaitable<R>,
3010
+ ): Awaitable<R> {
3011
+ return value instanceof Promise ? value.then(onOk, onErr) : onOk(value)
3012
+ }
3013
+
3014
+ type AnyRow = Record<string, SqlValue>
3015
+
3016
+ /**
3017
+ * Inert {@link View} for skipped subscribe (#104): no desire, no re-exec, empty
3018
+ * rows, status unknown, no-op retain/release. Not keyed. Un-skip = fresh
3019
+ * subscribe at the hook (#105).
3020
+ */
3021
+ class SkippedView implements View<Record<string, unknown>> {
3022
+ current(): readonly Record<string, unknown>[] {
3023
+ return EMPTY_ROWS
3024
+ }
3025
+
3026
+ status(): ViewStatus {
3027
+ return UNKNOWN_STATUS
3028
+ }
3029
+
3030
+ onChange(): () => void {
3031
+ return () => {}
3032
+ }
3033
+
3034
+ retain(): void {}
3035
+
3036
+ release(): void {}
3037
+ }
3038
+
3039
+ /**
3040
+ * In-flight Once handle (ADR-0012/0021): local cache first, server answer
3041
+ * later, `server` promise for hook status. Internal over {@link AnyRow}; typed
3042
+ * at boundary.
3043
+ */
3044
+ class OnceViewImpl implements OnceView<Record<string, unknown>> {
3045
+ #snapshot: readonly Record<string, unknown>[]
3046
+ readonly #listeners = new Set<() => void>()
3047
+ #started = false
3048
+ #disposed = false
3049
+ #serverPromiseWithResolvers =
3050
+ Promise.withResolvers<readonly Record<string, unknown>[]>()
3051
+ #disposeTimer: ReturnType<typeof setTimeout> | null = null
3052
+ /** Nested-relation decode; applied to server answer too. */
3053
+ readonly #decode: RowDecoder | undefined
3054
+ readonly #startNetwork: () => void
3055
+ readonly #drop: () => void
3056
+
3057
+ constructor(
3058
+ cached: readonly Record<string, unknown>[],
3059
+ decode: RowDecoder | undefined,
3060
+ hooks: { start: () => void; drop: () => void },
3061
+ ) {
3062
+ this.#snapshot = cached
3063
+ this.#decode = decode
3064
+ this.#startNetwork = hooks.start
3065
+ this.#drop = hooks.drop
3066
+ }
3067
+
3068
+ current(): readonly Record<string, unknown>[] {
3069
+ // First read arms the network half (#91). StrictMode-discarded renders
3070
+ // never read (uSES after subscribe), so construction stays pure.
3071
+ this.#ensureOpen()
3072
+ return this.#snapshot
3073
+ }
3074
+
3075
+ onChange(listener: () => void): () => void {
3076
+ this.#ensureOpen()
3077
+ this.#listeners.add(listener)
3078
+ return () => this.#listeners.delete(listener)
3079
+ }
3080
+
3081
+ /** Lazily arm the network half (Q7 commit-owns); first onChange/server starts. */
3082
+ get server(): Promise<readonly Record<string, unknown>[]> {
3083
+ this.#ensureOpen()
3084
+ return this.#serverPromiseWithResolvers.promise
3085
+ }
3086
+
3087
+ /** Server answer landed: decode, swap snapshot, resolve, notify. */
3088
+ deliver(rows: AnyRow[]): void {
3089
+ if (this.#disposed) return
3090
+ const decoded = this.#decode ? this.#decode(rows) : rows
3091
+ this.#snapshot = decoded
3092
+ this.#serverPromiseWithResolvers.resolve(decoded)
3093
+ for (const listener of this.#listeners) listener()
3094
+ }
3095
+
3096
+ /** Mirror could not answer: reject `server`. */
3097
+ settleError(error: unknown): void {
3098
+ if (this.#disposed) return
3099
+ this.#serverPromiseWithResolvers.reject(error)
3100
+ }
3101
+
3102
+ /**
3103
+ * Drop after one Warm tick. Idempotent; re-open within tick cancels
3104
+ * (StrictMode).
3105
+ */
3106
+ dispose(): void {
3107
+ if (this.#disposed) return
3108
+ if (this.#disposeTimer !== null) return
3109
+ this.#disposeTimer = setTimeout(() => {
3110
+ this.#disposeTimer = null
3111
+ if (this.#disposed) return
3112
+ this.#disposed = true
3113
+ this.#listeners.clear()
3114
+ this.#drop()
3115
+ }, WARM_POOL_TICK_MS)
3116
+ }
3117
+
3118
+ #ensureOpen(): void {
3119
+ if (this.#disposed) return
3120
+ if (this.#disposeTimer !== null) {
3121
+ clearTimeout(this.#disposeTimer)
3122
+ this.#disposeTimer = null
3123
+ }
3124
+ if (this.#started) return
3125
+ this.#started = true
3126
+ this.#serverPromiseWithResolvers =
3127
+ Promise.withResolvers<readonly Record<string, unknown>[]>()
3128
+ // Swallow server rejection if caller never awaits (no unhandled rejection).
3129
+ this.#serverPromiseWithResolvers.promise.catch(() => {})
3130
+ this.#startNetwork()
3131
+ }
3132
+ }
3133
+
3134
+ /**
3135
+ * `false | null | undefined` — the "no query" sentinel (ADR-0027 /
3136
+ * closeio/doync#195/#200). Exported so adapters reuse the same predicate the
3137
+ * direct engine uses rather than shadowing a three-literal check.
3138
+ */
3139
+ export function isFalsyQuery(value: unknown): value is FalsyQuery {
3140
+ return value === false || value === null || value === undefined
3141
+ }
3142
+
3143
+ /**
3144
+ * Bound-form surface normalize (ADR-0027 / closeio/doync#200): peel a
3145
+ * {@link BoundQuery} into `{leaf, args, options?}`, or reject a truthy non-bound
3146
+ * impostor with a surface-named error. An uncalled RegisteredQuery (a function)
3147
+ * hits "did you forget to call it?" — registration is a type-level fact, so the
3148
+ * legacy definition arm is gone.
3149
+ *
3150
+ * Exported from `@doync/client` so the web topology and mobile wrapper share
3151
+ * one surface check with the direct engine — no per-adapter copy of the
3152
+ * impostor wording.
3153
+ */
3154
+ export function normalizeQuerySurface<Options = never>(
3155
+ surface: 'subscribe' | 'once' | 'preload',
3156
+ queryOrBound: unknown,
3157
+ options?: Options,
3158
+ ): {
3159
+ query: BoundQuery['query']
3160
+ args: unknown
3161
+ options: Options | undefined
3162
+ } {
3163
+ if (isBoundQuery(queryOrBound)) {
3164
+ // Cast erases the bound leaf's `One` phantom — resolve/name are all the
3165
+ // engine needs from here on; Row/One flow from the public overloads only.
3166
+ return {
3167
+ query: queryOrBound.query,
3168
+ args: queryOrBound.args,
3169
+ options,
3170
+ }
3171
+ }
3172
+ throw querySurfaceImpostorError(surface, queryOrBound)
3173
+ }
3174
+
3175
+ /**
3176
+ * Named impostor rejection for a truthy non-bound value on a query surface
3177
+ * (ADR-0027): one error shape per surface, with the received-a-function case
3178
+ * called out as the uncalled-query mistake.
3179
+ */
3180
+ function querySurfaceImpostorError(surface: string, value: unknown): Error {
3181
+ if (typeof value === 'function') {
3182
+ return new Error(
3183
+ `doync: client.${surface} expected a BoundQuery — received a function; did you forget to call it?`,
3184
+ )
3185
+ }
3186
+ return new Error(
3187
+ `doync: client.${surface} expected a BoundQuery, got ${describeImpostor(value)}`,
3188
+ )
3189
+ }
3190
+
3191
+ /** Short runtime description for an impostor value (never dumps large objects). */
3192
+ function describeImpostor(value: unknown): string {
3193
+ if (value === null) return 'null'
3194
+ if (Array.isArray(value)) return 'an array'
3195
+ const type = typeof value
3196
+ if (type === 'object') {
3197
+ const kind = (value as { kind?: unknown }).kind
3198
+ if (typeof kind === 'string') return `an object with kind "${kind}"`
3199
+ return 'an object'
3200
+ }
3201
+ return type
3202
+ }
3203
+
3204
+ /**
3205
+ * Inert {@link OnceView} for falsy Once (ADR-0027 / #195): no network, empty
3206
+ * cache, never-settling `server` (hooks map to skipped). Safe no-op methods.
3207
+ */
3208
+ class SkippedOnceView implements OnceView<Record<string, unknown>> {
3209
+ current(): readonly Record<string, unknown>[] {
3210
+ return EMPTY_ROWS
3211
+ }
3212
+
3213
+ onChange(): () => void {
3214
+ return () => {}
3215
+ }
3216
+
3217
+ get server(): Promise<readonly Record<string, unknown>[]> {
3218
+ // Never settles — network never started; hook layer owns the skipped status.
3219
+ return new Promise(() => {})
3220
+ }
3221
+
3222
+ dispose(): void {}
3223
+ }
3224
+
3225
+ /** No-op {@link PreloadHandle} for preload(falsy) (ADR-0027). */
3226
+ const NOOP_PRELOAD: PreloadHandle = {
3227
+ cleanup(): void {},
3228
+ }
3229
+
3230
+ /** Decode a codec-encoded Once row (ADR-0010). */
3231
+ function decodeOnceRow(image: Record<string, unknown>): AnyRow {
3232
+ const row: AnyRow = {}
3233
+ for (const [column, value] of Object.entries(image)) {
3234
+ row[column] = decodeImageValue(value)
3235
+ }
3236
+ return row
3237
+ }
3238
+
3239
+ /** Drop `__doync_`-internal tables from a set (ADR-0007). */
3240
+ function consumerTables(tables: ReadonlySet<string>): Set<string> {
3241
+ return new Set([...tables].filter((t) => !isInternalTable(t)))
3242
+ }
3243
+
3244
+ /**
3245
+ * A `mutate()` whose failure is known synchronously (unknown name, bad args).
3246
+ * Exported for the web topology's tab-side `mutate`, which shares the
3247
+ * never-throw contract (#161).
3248
+ */
3249
+ export function settledRejection(error: unknown): MutationResult {
3250
+ const client = Promise.reject(error)
3251
+ const server = Promise.reject(error)
3252
+ // The caller may await only one of the pair; swallow the other so a validation
3253
+ // error never surfaces as an unhandled rejection.
3254
+ client.catch(() => {})
3255
+ server.catch(() => {})
3256
+ return { client, server }
3257
+ }
3258
+
3259
+ /** Message of a thrown value (Error or otherwise). */
3260
+ function errorMessage(error: unknown): string {
3261
+ return error instanceof Error ? error.message : String(error)
3262
+ }
3263
+
3264
+ /**
3265
+ * Warm pool retention (CONTEXT.md; ADR-0023) — one knob (StrictMode /
3266
+ * soft-nav). Future count/time bounds change only this value's shape.
3267
+ */
3268
+ const WARM_POOL_TICK_MS = 0
3269
+
3270
+ /** Shared empty-rows snapshot for skipped/unseeded Views (stable). */
3271
+ const EMPTY_ROWS: readonly Record<string, unknown>[] = Object.freeze([])
3272
+ /** Shared unknown/complete status snapshots (stable, #104). */
3273
+ const UNKNOWN_STATUS: ViewStatus = Object.freeze({ status: 'unknown' })
3274
+ const COMPLETE_STATUS: ViewStatus = Object.freeze({ status: 'complete' })
3275
+
3276
+ /**
3277
+ * {@link SubPhase} -> visible {@link ViewStatus} (#104): pending/acked share
3278
+ * frozen unknown; complete uses frozen singleton; error is a fresh object.
3279
+ */
3280
+ function phaseToStatus(phase: SubPhase, error: Error | undefined): ViewStatus {
3281
+ if (phase === 'complete') return COMPLETE_STATUS
3282
+ if (phase === 'error') {
3283
+ return error === undefined
3284
+ ? { status: 'error' }
3285
+ : { status: 'error', error }
3286
+ }
3287
+ return UNKNOWN_STATUS
3288
+ }
3289
+
3290
+ const defaultGenerateID = (): string => {
3291
+ const g = globalThis as { crypto?: { randomUUID?: () => string } }
3292
+ const uuid = g.crypto?.randomUUID?.()
3293
+ if (uuid !== undefined) return uuid
3294
+ return `client-${Math.random().toString(16).slice(2)}-${Date.now().toString(16)}`
3295
+ }
3296
+
3297
+ /**
3298
+ * Durable int meta keys for boot corruption scan (ADR-0022). Excludes connected
3299
+ * clock (#223 — never on wire; lenient zero reset, not forget-heal).
3300
+ */
3301
+ const DURABLE_INT_META_KEYS = ['schema_version', 'next_mutation_id', 'cookie']
3302
+
3303
+ /**
3304
+ * First durable-int meta key with a present non-integer, or null (ADR-0022).
3305
+ * Boot scan before store read — corrupt counter => loud forget-heal, not refuse
3306
+ * (#128 C6). Same keys `#durableInt` later reads.
3307
+ */
3308
+ function corruptMetaKey(db: LocalDb): string | null {
3309
+ for (const key of DURABLE_INT_META_KEYS) {
3310
+ const raw = readMeta(db, key)
3311
+ if (raw !== null && !Number.isInteger(Number(raw))) return key
3312
+ }
3313
+ return null
3314
+ }
3315
+
3316
+ /**
3317
+ * Cookie-above-Origin-head framed error (ADR-0016/0020): keys on the harness-
3318
+ * pinned phrase; heal via automatic resync, not permanent sub error.
3319
+ */
3320
+ function isCookieAboveHeadError(message: string): boolean {
3321
+ return message.includes('above the Origin head')
3322
+ }