@doync/client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1 -17
  2. package/dist/adapter.cjs +1 -1
  3. package/dist/adapter.d.cts +1 -1
  4. package/dist/adapter.d.ts +1 -1
  5. package/dist/adapter.js +1 -1
  6. package/dist/adapter.js.map +1 -1
  7. package/dist/client-Bv6QMenB.cjs +15 -0
  8. package/dist/client-Db0Jfwuy.js +16 -0
  9. package/dist/client-Db0Jfwuy.js.map +1 -0
  10. package/dist/{client-CNyLMCw0.d.ts → client-OAVaC3pt.d.ts} +79 -91
  11. package/dist/client-OAVaC3pt.d.ts.map +1 -0
  12. package/dist/{client-DHXO0dbf.d.cts → client-OPt8Axyp.d.cts} +79 -91
  13. package/dist/client-OPt8Axyp.d.cts.map +1 -0
  14. package/dist/index.d.cts +3 -2
  15. package/dist/index.d.ts +3 -2
  16. package/dist/internal.cjs +1 -1
  17. package/dist/internal.d.cts +3 -12
  18. package/dist/internal.d.cts.map +1 -1
  19. package/dist/internal.d.ts +3 -12
  20. package/dist/internal.d.ts.map +1 -1
  21. package/dist/internal.js +1 -1
  22. package/package.json +3 -3
  23. package/src/client.ts +82 -32
  24. package/src/engine.ts +188 -134
  25. package/src/index.ts +1 -6
  26. package/src/internal.ts +4 -4
  27. package/src/migrate.ts +1 -1
  28. package/src/replica/db/0002_prefs_table.sql +9 -0
  29. package/src/replica/db/meta/0002_snapshot.json +191 -0
  30. package/src/replica/db/meta/_journal.json +7 -0
  31. package/src/replica/db/schema.ts +12 -3
  32. package/src/replica/index.ts +5 -4
  33. package/src/replica/meta.ts +23 -0
  34. package/src/replica/track.ts +20 -39
  35. package/src/socket-reconnect.ts +29 -5
  36. package/dist/client-C6jAdhbe.cjs +0 -15
  37. package/dist/client-CNyLMCw0.d.ts.map +0 -1
  38. package/dist/client-ClV8ce6X.js +0 -16
  39. package/dist/client-ClV8ce6X.js.map +0 -1
  40. package/dist/client-DHXO0dbf.d.cts.map +0 -1
  41. package/src/client-mutation-registry.ts +0 -31
  42. package/src/mutations.ts +0 -96
package/src/engine.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type {
2
+ AuthContext,
2
3
  BoundQuery,
3
4
  DoyncSchema,
4
5
  MutationDefinition,
6
+ MutationExec,
5
7
  RowDecoder,
6
8
  SqlValue,
7
9
  } from '@doync/core'
@@ -18,21 +20,18 @@ import type {
18
20
 
19
21
  import { isBoundQuery } from '@doync/core/internal'
20
22
  import {
21
- assertWireRepresentable,
22
- canonicalizeArgsBinary,
23
23
  declaredSchemaVersion,
24
24
  decodeArgs,
25
25
  decodeImageValue,
26
26
  encodeArgs,
27
27
  isInternalTable,
28
+ prepareArgs,
28
29
  } from '@doync/core/internal'
29
30
 
30
- import type { ClientMutationRegistry, ClientMutationTx } from './mutations'
31
31
  import type { LocalDb } from './port'
32
32
  import type { SeamStatus, SyncSocket } from './socket'
33
33
 
34
- import { asClientMutation, validateMutationArgs } from './mutations'
35
- import { RawRead, type LocalStatement } from './raw-read'
34
+ import { type LocalStatement, RawRead } from './raw-read'
36
35
  import {
37
36
  applyBundledMigrations,
38
37
  assertNoReservedTables,
@@ -47,12 +46,13 @@ import {
47
46
  quoteIdent,
48
47
  readMeta,
49
48
  readReleaseStamp,
49
+ type ReleaseStamp,
50
50
  replayMigrations,
51
51
  requireTable,
52
52
  resolveReleaseTtlMs,
53
53
  upsertReleaseStamp,
54
54
  writeMeta,
55
- type ReleaseStamp,
55
+ writePref,
56
56
  } from './replica'
57
57
 
58
58
  /**
@@ -76,12 +76,12 @@ export interface MutationResult {
76
76
  export type LogoutBehavior = 'keep' | 'forget'
77
77
 
78
78
  /**
79
- * The `__doync_meta` key the durable {@link LogoutBehavior} lives under
79
+ * The `__doync_prefs` key the durable {@link LogoutBehavior} lives under
80
80
  * (closeio/doync#135) — shared by the direct engine's
81
81
  * {@link ClientEngine.setLogoutBehavior} / `createClient` write and the web DB
82
82
  * worker's per-identity write + boot read, so the ends can never drift.
83
83
  */
84
- export const __LOGOUT_BEHAVIOR_META_KEY = 'logout_behavior'
84
+ export const __LOGOUT_BEHAVIOR_PREF_KEY = 'logout_behavior'
85
85
 
86
86
  /**
87
87
  * The `__doync_meta` key the Client's durable connected-time counter lives
@@ -99,12 +99,22 @@ export const __CONNECTED_CLOCK_META_KEY = 'connected_clock'
99
99
  * after deploying a matching client. Terminal until reload.
100
100
  * - `server-behind` — client is ahead of a mid-deploy Mirror; the client backs
101
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.
102
+ * - `resync` — local sync state wiped and rebuilding as a fresh Client under the
103
+ * same identity (pending writes dropped, new clientId). Transient; clears
104
+ * when sync resumes.
105
+ * - `reaped` — same fresh-client rebuild as `resync`, but the Origin forgot this
106
+ * Client (gone past the client-state lifetime). Transient; clears when sync
107
+ * resumes. Distinct so the app can say "you were away too long" rather than
108
+ * "data refreshed".
104
109
  * - `forget` — local store erased (including identity); boots as a fresh client.
105
110
  * Transient; clears when sync resumes.
106
111
  */
107
- export type SchemaEventKind = 'reload' | 'server-behind' | 'resync' | 'forget'
112
+ export type SchemaEventKind =
113
+ | 'reload'
114
+ | 'server-behind'
115
+ | 'resync'
116
+ | 'reaped'
117
+ | 'forget'
108
118
 
109
119
  /**
110
120
  * One schema-status transition: `kind` plus a human-readable `message` for
@@ -274,17 +284,18 @@ export interface DoyncClient {
274
284
  */
275
285
  onSchemaChange(listener: () => void): () => void
276
286
  /**
277
- * Wipe the replica and resubscribe while keeping clientId and pending writes.
278
- * Safe "my local data looks wrong" refresh. Optional on the interface —
287
+ * Rebuild sync state as a fresh Client under the same identity: drop pending
288
+ * writes, mint a new clientId, wipe the replica, and rebootstrap. Destructive
289
+ * to unsent work — not a harmless refresh. Optional on the interface —
279
290
  * platform clients implement it.
280
291
  */
281
- resync?(): void
292
+ resync(): void
282
293
  /**
283
294
  * Erase this identity's local data (replica, pendings, identity). Privacy /
284
295
  * logout-forget path. Optional on the interface — platform clients implement
285
296
  * it.
286
297
  */
287
- forget?(): void
298
+ forget(identity?: string): void
288
299
  /**
289
300
  * Durably set {@link LogoutBehavior} at runtime (e.g. a "remember me"
290
301
  * checkbox). Survives restart. Optional — platform clients implement it.
@@ -363,15 +374,15 @@ export interface ClientEngineConfig {
363
374
  * Named mutation bodies resolved on `push` — deterministic, DB-only
364
375
  * (ADR-0017/0021).
365
376
  */
366
- readonly mutations: ClientMutationRegistry
377
+ readonly mutations: Record<string, MutationDefinition>
367
378
  /** The Mirror socket seam (ADR-0016). */
368
379
  readonly socket: SyncSocket
369
380
  /**
370
381
  * The asserted projected auth context (ADR-0018 addendum / closeio/doync#167)
371
382
  * queries and mutation bodies read. Consumer-owned; the library ships no
372
- * decode. Anonymous `{}`.
383
+ * decode. `null` when anonymous.
373
384
  */
374
- readonly ctx?: Record<string, unknown>
385
+ readonly ctx?: AuthContext
375
386
  /**
376
387
  * Transport credential PRESENTED as `connect{jwt}` (ADR-0016/0018) — the
377
388
  * Mirror resolves token-first. Auth must ride the HANDSHAKE, not only the
@@ -678,13 +689,13 @@ class ViewHandle implements View<Record<string, unknown>> {
678
689
  export class ClientEngine implements DoyncClient {
679
690
  readonly #db: LocalDb
680
691
  readonly #schema: DoyncSchema
681
- readonly #mutations: ClientMutationRegistry
692
+ readonly #mutations: Record<string, MutationDefinition>
682
693
  readonly #socket: SyncSocket
683
694
  /**
684
695
  * Projected auth ctx (ADR-0018) for queries/bodies. Updated by
685
- * {@link updateAuth} on same-user refresh (#102).
696
+ * {@link updateAuth} on same-user refresh (#102). `null` when anonymous.
686
697
  */
687
- #ctx: Record<string, unknown>
698
+ #ctx: AuthContext
688
699
  /**
689
700
  * Bearer for every handshake (ADR-0016/0018). Updated by {@link updateAuth} so
690
701
  * refresh rides in-band update AND the next reconnect (#102).
@@ -850,7 +861,7 @@ export class ClientEngine implements DoyncClient {
850
861
  this.#schema = config.schema
851
862
  this.#mutations = config.mutations
852
863
  this.#socket = config.socket
853
- this.#ctx = config.ctx ?? {}
864
+ this.#ctx = config.ctx ?? null
854
865
  this.#token = config.token
855
866
  // Asserted identity (#167) — never from the token.
856
867
  this.#userId = config.userId ?? null
@@ -953,19 +964,12 @@ export class ClientEngine implements DoyncClient {
953
964
  // rebuilds engine tables and flags consumer wipe-and-resync below.
954
965
  const { rolledBack } = createEngineTables(db)
955
966
  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
+ // Consumer half of wipe-and-resync. Boot mints a fresh clientId because
968
+ // the stored one goes with the rest of the cookie/cursor state. The
969
+ // bundle version is passed explicitly — `#bundleVersion` is assigned
970
+ // below, after the track settles.
971
+ this.#wipeStore(db, declaredSchemaVersion(this.#schema))
967
972
  this.#cookie = null
968
- db.exec('DELETE FROM __doync_meta WHERE k = ?', 'cookie')
969
973
  } else {
970
974
  replayMigrations(db, this.#schema)
971
975
  }
@@ -1262,7 +1266,7 @@ export class ClientEngine implements DoyncClient {
1262
1266
  try {
1263
1267
  const entry = this.#mutations[p.name]
1264
1268
  if (entry === undefined) throw new Error(`unknown mutation "${p.name}"`)
1265
- running = asClientMutation(entry).handler(p.args, this.#ctx, this.#tx())
1269
+ running = entry.body({ args: p.args, ctx: this.#ctx, sql: this.#tx() })
1266
1270
  } catch (error) {
1267
1271
  this.#failBody(p.mutationId, error)
1268
1272
  return
@@ -1290,8 +1294,16 @@ export class ClientEngine implements DoyncClient {
1290
1294
  }
1291
1295
 
1292
1296
  /** Mutation-body write surface (exec returns rows, ADR-0021). */
1293
- #tx(): ClientMutationTx {
1294
- return { exec: (query, ...params) => this.#db.exec(query, ...params) }
1297
+ #tx(): MutationExec {
1298
+ // Booleans canonicalize to 1/0 at the seam — the LocalDb port speaks
1299
+ // SqlValue only (SQLite has no boolean affinity).
1300
+ return {
1301
+ exec: (query, ...params) =>
1302
+ this.#db.exec(
1303
+ query,
1304
+ ...params.map((p) => (typeof p === 'boolean' ? (p ? 1 : 0) : p)),
1305
+ ),
1306
+ }
1295
1307
  }
1296
1308
 
1297
1309
  /**
@@ -1337,25 +1349,15 @@ export class ClientEngine implements DoyncClient {
1337
1349
  if (entry === undefined) {
1338
1350
  return settledRejection(new Error(`doync: unknown mutation "${name}"`))
1339
1351
  }
1340
- const registered = asClientMutation(entry)
1341
1352
  let liveArgs: unknown
1342
1353
  let wireArgs: unknown
1343
1354
  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
1355
+ // The one args-preparation path (ADR-0031), shared with the Origin push
1356
+ // seam: guard, canonicalize, validate through the definition's schema.
1357
+ // Throw is sync fail-fast: nothing enqueued, `client` rejects. Encode
1358
+ // the post-schema value so wire and durable queue match what the
1359
+ // optimistic body saw (validators assumed pass-through / idempotent).
1360
+ liveArgs = prepareArgs(entry.args, args, 'mutation args')
1359
1361
  wireArgs = encodeArgs(liveArgs, 'mutation args')
1360
1362
  } catch (error) {
1361
1363
  return settledRejection(error)
@@ -1464,19 +1466,19 @@ export class ClientEngine implements DoyncClient {
1464
1466
  // --- auth (ADR-0018, closeio/doync#102) -----------------------------------
1465
1467
 
1466
1468
  /**
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.
1469
+ * Adopt a refreshed SAME-USER identity (#167). When connected with a bearer,
1470
+ * send in-band `updateAuth` (ADR-0018 / #85) so the Mirror extends auth
1471
+ * without reconnect when userId matches and `issuedAt` is newer. Identity
1472
+ * CHANGE (different `userId`, incl. anon↔user) is topology-level replica swap
1473
+ * — SharedWorker routes only same-user refreshes here.
1472
1474
  *
1473
1475
  * Token is stored for the next reconnect (#92: surviving SharedWorker must
1474
1476
  * not reuse a stale token). `ctx`/`userId` asserted, never derived. Halted
1475
- * engines never emit.
1477
+ * engines never emit. Pass `ctx: null` for anonymous.
1476
1478
  */
1477
1479
  updateAuth(
1478
1480
  token: string | null | undefined,
1479
- ctx?: Record<string, unknown>,
1481
+ ctx?: AuthContext,
1480
1482
  userId?: string | null,
1481
1483
  ): void {
1482
1484
  this.#token = token ?? undefined
@@ -2147,7 +2149,7 @@ export class ClientEngine implements DoyncClient {
2147
2149
  }
2148
2150
  break
2149
2151
  case 'pokeReject':
2150
- this.#handleReject(message.mutationId, message.error)
2152
+ this.#handleReject(message.mutationId, message.error, message.reason)
2151
2153
  break
2152
2154
  case 'onceStart':
2153
2155
  // Fresh Once accumulator (ADR-0012); reconnect re-issue resets partial.
@@ -2166,8 +2168,8 @@ export class ClientEngine implements DoyncClient {
2166
2168
  this.#handleOnceEnd(message)
2167
2169
  break
2168
2170
  case 'resyncRequired':
2169
- // heldAt-ahead tripwire (ADR-0029 / #225): resync self-heal (keep
2170
- // clientId + pendings). First-class frame, not phrase-matched.
2171
+ // heldAt-ahead tripwire (ADR-0029 / #225): resync self-heal as a fresh
2172
+ // client. First-class frame, not phrase-matched.
2171
2173
  this.#errors.push(message.message)
2172
2174
  this.#exclusive(() =>
2173
2175
  this.#wipeAndResync(
@@ -2178,8 +2180,8 @@ export class ClientEngine implements DoyncClient {
2178
2180
  case 'error':
2179
2181
  this.#errors.push(message.message)
2180
2182
  // 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
+ // returning cookie above head — heal via wipe-and-resync as a fresh
2184
+ // client, not a permanent subscription error.
2183
2185
  if (isCookieAboveHeadError(message.message)) {
2184
2186
  this.#exclusive(() =>
2185
2187
  this.#wipeAndResync(
@@ -2475,9 +2477,24 @@ export class ClientEngine implements DoyncClient {
2475
2477
  * Rejection (ADR-0016/0019): mark rejected immediately (lmid cannot resolve
2476
2478
  * it even under race), then exclusive-chain drop pending, reject `server`,
2477
2479
  * re-project (optimistic effect vanishes).
2480
+ *
2481
+ * `reason: 'reaped'` (ADR-0015 / ADR-0037) is recovery, not a per-mutation
2482
+ * failure: the Origin has no cursor for this Client, so every pending is
2483
+ * dropped as a fresh-client heal — first-class tag, never phrase-matched.
2478
2484
  */
2479
- #handleReject(mutationId: number, error: string): void {
2485
+ #handleReject(mutationId: number, error: string, reason?: 'reaped'): void {
2480
2486
  this.#rejected.add(mutationId)
2487
+ if (reason === 'reaped') {
2488
+ const dropped = this.#pending.length
2489
+ this.#errors.push(error)
2490
+ this.#exclusive(() =>
2491
+ this.#wipeAndResync(
2492
+ `server forgot this client (reaped) — dropped ${dropped} pending write${dropped === 1 ? '' : 's'} — ${error}`,
2493
+ 'reaped',
2494
+ ),
2495
+ )
2496
+ return
2497
+ }
2481
2498
  this.#exclusive(() =>
2482
2499
  thenMaybe(this.#dropPending(mutationId), (touched) => {
2483
2500
  this.#settleServer(mutationId, new Error(error))
@@ -2567,13 +2584,15 @@ export class ClientEngine implements DoyncClient {
2567
2584
  )
2568
2585
  }
2569
2586
 
2570
- // --- recovery surface (ADR-0022) ------------------------------------------
2587
+ // --- recovery surface (ADR-0022 / ADR-0037) --------------------------------
2571
2588
 
2572
2589
  /**
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.
2590
+ * Rebuild sync state as a fresh Client under the same identity (ADR-0022 /
2591
+ * ADR-0037 resync): drop pending writes, mint a new clientId, wipe replica /
2592
+ * memberships / cookie, rebootstrap. Same core as failed-migration,
2593
+ * cookie-above-head, and heldAt-ahead heals ({@link #wipeAndResync}).
2594
+ * Exclusive chain; offline rebootstrap rides next reconnect. Preferences
2595
+ * survive (`__doync_prefs`).
2577
2596
  */
2578
2597
  resync(): void {
2579
2598
  this.#exclusive(() =>
@@ -2583,10 +2602,10 @@ export class ClientEngine implements DoyncClient {
2583
2602
 
2584
2603
  /**
2585
2604
  * 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.
2605
+ * memberships, pending, meta, and preferences, then fresh first-sight under a
2606
+ * new clientId. Erases WHO, not just WHAT. Awaiters of forgotten writes are
2607
+ * rejected. Topology can erase the identity's database file on active forget
2608
+ * (#134); this resets the current store. Exclusive chain.
2590
2609
  */
2591
2610
  forget(): void {
2592
2611
  this.#exclusive(() =>
@@ -2598,24 +2617,24 @@ export class ClientEngine implements DoyncClient {
2598
2617
 
2599
2618
  /**
2600
2619
  * Durably set {@link LogoutBehavior} at runtime ({@link
2601
- * DoyncClient.setLogoutBehavior}). Via {@link #sideWriteMeta} so it commits
2620
+ * DoyncClient.setLogoutBehavior}). Via {@link #sideWritePref} so it commits
2602
2621
  * outside the optimistic overlay (#135 / ADR-0019). Direct engine stores for
2603
2622
  * RN; web DB worker reports to hub.
2604
2623
  */
2605
2624
  setLogoutBehavior(behavior: LogoutBehavior): void {
2606
- this.#sideWriteMeta(__LOGOUT_BEHAVIOR_META_KEY, behavior)
2625
+ this.#sideWritePref(__LOGOUT_BEHAVIOR_PREF_KEY, behavior)
2607
2626
  }
2608
2627
 
2609
2628
  /**
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.
2629
+ * Durable `__doync_prefs` side write via rebase envelope (#139). For low-
2630
+ * frequency runtime preferences ({@link setLogoutBehavior}). High-frequency
2631
+ * values (connected clock #223) stay in memory and piggyback poke apply —
2632
+ * full rebase per tick is wrong. Exclusive chain + {@link #rebase} so the
2633
+ * write commits on the base, not the overlay SAVEPOINT. Pref-only → discard
2634
+ * touched set.
2616
2635
  */
2617
- #sideWriteMeta(key: string, value: string): void {
2618
- this.#sideWrite(() => writeMeta(this.#db, key, value))
2636
+ #sideWritePref(key: string, value: string): void {
2637
+ this.#sideWrite(() => writePref(this.#db, key, value))
2619
2638
  }
2620
2639
 
2621
2640
  /**
@@ -2629,44 +2648,66 @@ export class ClientEngine implements DoyncClient {
2629
2648
  }
2630
2649
 
2631
2650
  /**
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).
2651
+ * Shared fresh-client store wipe for Resync, Forget, and the boot-time
2652
+ * ledger-rollback heal (ADR-0037): drop consumer tables, memberships, stamps,
2653
+ * pending queue, and all sync meta; re-replay the bundled track from empty.
2654
+ * Preferences (`__doync_prefs`) and the engine ledger stay. Caller mints the
2655
+ * fresh identity. FKs already off (ADR-0009).
2656
+ *
2657
+ * `bundleVersion` defaults to the engine's, and is passed explicitly from
2658
+ * boot, which runs before that field is assigned.
2635
2659
  */
2636
- #forgetStore(db: LocalDb): void {
2660
+ #wipeStore(db: LocalDb, bundleVersion = this.#bundleVersion): void {
2637
2661
  dropConsumerTables(db, this.#schema)
2638
2662
  db.exec('DELETE FROM __doync_membership')
2639
2663
  // Wipe stamps with memberships (ADR-0014 / #224); table shape stays.
2640
2664
  clearReleaseStamps(db)
2641
2665
  db.exec('DELETE FROM __doync_pending')
2642
- // All meta gone => genuine first-sight boot.
2666
+ // All sync meta gone cookie, clientId, next-mutid, connected clock.
2643
2667
  db.exec('DELETE FROM __doync_meta')
2644
2668
  // Full bundled track from empty (re-records schema_version).
2645
- applyBundledMigrations(db, this.#schema, 0, this.#bundleVersion)
2669
+ applyBundledMigrations(db, this.#schema, 0, bundleVersion)
2670
+ }
2671
+
2672
+ /**
2673
+ * Store-erase core for forget / corrupt-meta heal: shared wipe plus
2674
+ * preferences. Caller mints identity. Topology may also erase the database
2675
+ * file.
2676
+ */
2677
+ #forgetStore(db: LocalDb): void {
2678
+ this.#wipeStore(db)
2679
+ db.exec('DELETE FROM __doync_prefs')
2646
2680
  }
2647
2681
 
2648
2682
  /**
2649
- * Mid-session forget (ADR-0022): erase store, mint fresh identity, re-
2650
- * handshake (or next reconnect). Unlike resync, discards identity and
2651
- * pending.
2683
+ * Shared memory reset after a store wipe: reject awaiters, mint a fresh
2684
+ * clientId, empty the pending queue, null the cookie, reset the mutation
2685
+ * counter and connected clock.
2686
+ */
2687
+ #resetToFreshClient(dropError: Error): void {
2688
+ const db = this.#db
2689
+ this.#rejectAllPending(dropError)
2690
+ this.#clientId = this.#generateId()
2691
+ writeMeta(db, 'client_id', this.#clientId)
2692
+ this.#cookie = null
2693
+ this.#nextMutationId = 1
2694
+ this.#connectedClock = 0
2695
+ this.#lastPongAt = null
2696
+ this.#pending = []
2697
+ }
2698
+
2699
+ /**
2700
+ * Mid-session forget (ADR-0022): erase store (including preferences), mint
2701
+ * fresh identity, re-handshake (or next reconnect). Topology may also erase
2702
+ * the identity's database file.
2652
2703
  */
2653
2704
  #forgetAndReset(reason: string): Awaitable<void> {
2654
2705
  const db = this.#db
2655
2706
  const touched = this.#rebase(() => {
2656
- // Reject awaiters before clearing the queue under them.
2657
- this.#rejectAllPending(
2707
+ this.#forgetStore(db)
2708
+ this.#resetToFreshClient(
2658
2709
  new Error(`doync: ${reason} — pending writes were forgotten`),
2659
2710
  )
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
2711
  })
2671
2712
  return this.#rebootstrapAfterReset(
2672
2713
  touched,
@@ -2677,8 +2718,9 @@ export class ClientEngine implements DoyncClient {
2677
2718
 
2678
2719
  /**
2679
2720
  * 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.
2721
+ * fresh, clear instance info, emit event, drop-and-redial when live so the
2722
+ * new clientId rides the upgrade pin (ADR-0016), re-project all consumer
2723
+ * tables. Offline tanks until the next connect (no live socket to drop).
2682
2724
  */
2683
2725
  #rebootstrapAfterReset(
2684
2726
  touched: Awaitable<Set<string>>,
@@ -2689,14 +2731,20 @@ export class ClientEngine implements DoyncClient {
2689
2731
  this.#appliedVersion = this.#bundleVersion
2690
2732
  this.#instanceInfo.clear()
2691
2733
  this.#emitSchemaEvent(kind, message)
2692
- if (this.#connected) this.#sendHandshake()
2734
+ // Fresh clientId cannot ride an already-pinned socket (ADR-0016 upgrade
2735
+ // pin). Drop and redial so the next open re-handshakes as the new Client
2736
+ // — same posture as client-ahead skew backoff.
2737
+ if (this.#connected) {
2738
+ this.#setConnected(false)
2739
+ this.#socket.reconnect()
2740
+ }
2693
2741
  this.#notifyChange(new Set([...t, ...this.#allConsumerTables()]))
2694
2742
  })
2695
2743
  }
2696
2744
 
2697
2745
  /**
2698
- * Reject every outstanding mutation promise (forget discards their queue).
2699
- * Idempotent; orphaneds swallow — frees in-flight callers only.
2746
+ * Reject every outstanding mutation promise (resync/forget discard their
2747
+ * queue). Idempotent; orphaneds swallow — frees in-flight callers only.
2700
2748
  */
2701
2749
  #rejectAllPending(error: Error): void {
2702
2750
  // Safe: Map iteration skips removed keys; settleServer deletes when done.
@@ -2711,31 +2759,32 @@ export class ClientEngine implements DoyncClient {
2711
2759
  }
2712
2760
 
2713
2761
  /**
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).
2762
+ * Wipe-replica-and-resync core (ADR-0020 / ADR-0037) for the resync verb,
2763
+ * failed migration, cookie-above-head, heldAt-ahead, and client-state reap:
2764
+ * drop consumer tables, pending writes, and sync meta; mint a fresh clientId;
2765
+ * re-replay the bundle; rebootstrap. Preferences (`__doync_prefs`) and the
2766
+ * `__doync_engine` ledger survive. A clientId the Origin has never seen
2767
+ * starts its mutation counter at 1 by construction. `kind` is `reaped` when
2768
+ * the Origin forgot the Client so the consumer can distinguish that from an
2769
+ * ordinary repair; the default is `resync`.
2718
2770
  */
2719
- #wipeAndResync(reason: string): Awaitable<void> {
2771
+ #wipeAndResync(
2772
+ reason: string,
2773
+ kind: 'resync' | 'reaped' = 'resync',
2774
+ ): Awaitable<void> {
2720
2775
  const db = this.#db
2721
2776
  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')
2777
+ this.#wipeStore(db)
2778
+ this.#resetToFreshClient(
2779
+ new Error(`doync: ${reason} — pending writes were dropped`),
2780
+ )
2733
2781
  })
2734
- // Survivors re-push on rebootstrap.
2735
2782
  return this.#rebootstrapAfterReset(
2736
2783
  touched,
2737
- 'resync',
2738
- `wiped and resyncing — ${reason}`,
2784
+ kind,
2785
+ kind === 'reaped'
2786
+ ? `client state was reaped — ${reason}`
2787
+ : `wiped and resyncing — ${reason}`,
2739
2788
  )
2740
2789
  }
2741
2790
 
@@ -2763,13 +2812,18 @@ export class ClientEngine implements DoyncClient {
2763
2812
 
2764
2813
  /**
2765
2814
  * 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.
2815
+ * re-handshake): server-behind / resync / reaped / forget. `reload` is
2816
+ * terminal (halted ignores frames). Notifies subscribers; config callback has
2817
+ * no cleared kind.
2769
2818
  */
2770
2819
  #clearTransientSchemaEvent(): void {
2771
2820
  const kind = this.#schemaEvent?.kind
2772
- if (kind === 'server-behind' || kind === 'resync' || kind === 'forget') {
2821
+ if (
2822
+ kind === 'server-behind' ||
2823
+ kind === 'resync' ||
2824
+ kind === 'reaped' ||
2825
+ kind === 'forget'
2826
+ ) {
2773
2827
  this.#schemaEvent = null
2774
2828
  this.#notifySchema()
2775
2829
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  export type { LogoutBehavior } from './client'
15
+ export type { AuthData } from '@doync/core'
15
16
  export type {
16
17
  ConnectionStatus,
17
18
  DoyncClient,
@@ -28,9 +29,3 @@ export type {
28
29
  View,
29
30
  ViewStatus,
30
31
  } from './engine'
31
- export type {
32
- ClientMutation,
33
- ClientMutationHandler,
34
- ClientMutationRegistry,
35
- ClientMutationTx,
36
- } from './mutations'
package/src/internal.ts CHANGED
@@ -7,12 +7,11 @@
7
7
  * entries, not here (no double-listing).
8
8
  */
9
9
 
10
- export { createClientEngine } from './client'
11
- export { toClientMutationRegistry } from './client-mutation-registry'
10
+ export { createClientEngine, resolveClientAuthData } from './client'
12
11
  export {
13
12
  ClientEngine,
14
13
  __CONNECTED_CLOCK_META_KEY,
15
- __LOGOUT_BEHAVIOR_META_KEY,
14
+ __LOGOUT_BEHAVIOR_PREF_KEY,
16
15
  settledRejection,
17
16
  isFalsyQuery,
18
17
  normalizeQuerySurface,
@@ -25,13 +24,14 @@ export {
25
24
  splitClientStatements,
26
25
  type ClientStatementKind,
27
26
  } from './migrate'
28
- export { asClientMutation, validateMutationArgs } from './mutations'
29
27
  export { extractWriteTable } from './port'
30
28
  export {
31
29
  applyBundledMigrations,
32
30
  createEngineTables,
33
31
  dropConsumerTables,
34
32
  readMeta,
33
+ readPref,
35
34
  replayMigrations,
36
35
  writeMeta,
36
+ writePref,
37
37
  } from './replica'
package/src/migrate.ts CHANGED
@@ -4,8 +4,8 @@ import {
4
4
  parse,
5
5
  parseAll,
6
6
  SQLiteParserError,
7
- unparse,
8
7
  type Statement,
8
+ unparse,
9
9
  } from '@doync/sqlite-parser'
10
10
 
11
11
  /**
@@ -0,0 +1,9 @@
1
+ CREATE TABLE `__doync_prefs` (
2
+ `k` text PRIMARY KEY NOT NULL,
3
+ `v` blob
4
+ ) WITHOUT ROWID;
5
+ --> statement-breakpoint
6
+ INSERT INTO `__doync_prefs` (`k`, `v`)
7
+ SELECT `k`, `v` FROM `__doync_meta` WHERE `k` = 'logout_behavior';
8
+ --> statement-breakpoint
9
+ DELETE FROM `__doync_meta` WHERE `k` = 'logout_behavior';