@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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/adapter.cjs +1 -0
- package/dist/adapter.d.cts +86 -0
- package/dist/adapter.d.cts.map +1 -0
- package/dist/adapter.d.ts +86 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/client-C6jAdhbe.cjs +15 -0
- package/dist/client-CNyLMCw0.d.ts +812 -0
- package/dist/client-CNyLMCw0.d.ts.map +1 -0
- package/dist/client-ClV8ce6X.js +16 -0
- package/dist/client-ClV8ce6X.js.map +1 -0
- package/dist/client-DHXO0dbf.d.cts +812 -0
- package/dist/client-DHXO0dbf.d.cts.map +1 -0
- package/dist/index.cjs +0 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +0 -0
- package/dist/internal.cjs +1 -0
- package/dist/internal.d.cts +69 -0
- package/dist/internal.d.cts.map +1 -0
- package/dist/internal.d.ts +69 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -0
- package/package.json +79 -0
- package/src/adapter.ts +25 -0
- package/src/client-mutation-registry.ts +31 -0
- package/src/client.ts +100 -0
- package/src/engine.ts +3322 -0
- package/src/identity.ts +41 -0
- package/src/index.ts +36 -0
- package/src/internal.ts +37 -0
- package/src/migrate.ts +156 -0
- package/src/mutations.ts +96 -0
- package/src/port.ts +74 -0
- package/src/raw-read.ts +135 -0
- package/src/replica/db/0000_replica_engine_v0.sql +20 -0
- package/src/replica/db/0001_release_stamps.sql +6 -0
- package/src/replica/db/meta/0000_snapshot.json +129 -0
- package/src/replica/db/meta/0001_snapshot.json +167 -0
- package/src/replica/db/meta/_journal.json +20 -0
- package/src/replica/db/schema.ts +67 -0
- package/src/replica/index.ts +185 -0
- package/src/replica/meta.ts +26 -0
- package/src/replica/stamps.ts +102 -0
- package/src/replica/track.ts +190 -0
- package/src/socket-reconnect.ts +242 -0
- package/src/socket.ts +44 -0
- 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
|
+
}
|