@tanstack/db-sqlite-persistence-core 0.2.21 → 0.4.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 (58) hide show
  1. package/README.md +258 -7
  2. package/dist/cjs/broadcast-coordinator.cjs +1689 -0
  3. package/dist/cjs/broadcast-coordinator.cjs.map +1 -0
  4. package/dist/cjs/broadcast-coordinator.d.cts +116 -0
  5. package/dist/cjs/errors.cjs +73 -0
  6. package/dist/cjs/errors.cjs.map +1 -1
  7. package/dist/cjs/errors.d.cts +44 -0
  8. package/dist/cjs/index.cjs +19 -0
  9. package/dist/cjs/index.cjs.map +1 -1
  10. package/dist/cjs/index.d.cts +2 -0
  11. package/dist/cjs/persisted.cjs +1840 -399
  12. package/dist/cjs/persisted.cjs.map +1 -1
  13. package/dist/cjs/persisted.d.cts +177 -11
  14. package/dist/cjs/remote-subset-owner.cjs +20 -0
  15. package/dist/cjs/remote-subset-owner.cjs.map +1 -0
  16. package/dist/cjs/remote-subset-owner.d.cts +7 -0
  17. package/dist/cjs/remote-subset-wire.cjs +681 -0
  18. package/dist/cjs/remote-subset-wire.cjs.map +1 -0
  19. package/dist/cjs/remote-subset-wire.d.cts +59 -0
  20. package/dist/cjs/sqlite-core-adapter.cjs +969 -364
  21. package/dist/cjs/sqlite-core-adapter.cjs.map +1 -1
  22. package/dist/cjs/sqlite-core-adapter.d.cts +45 -1
  23. package/dist/cjs/sqlite-value.cjs +31 -0
  24. package/dist/cjs/sqlite-value.cjs.map +1 -0
  25. package/dist/cjs/sqlite-value.d.cts +10 -0
  26. package/dist/esm/broadcast-coordinator.d.ts +116 -0
  27. package/dist/esm/broadcast-coordinator.js +1689 -0
  28. package/dist/esm/broadcast-coordinator.js.map +1 -0
  29. package/dist/esm/errors.d.ts +44 -0
  30. package/dist/esm/errors.js +74 -1
  31. package/dist/esm/errors.js.map +1 -1
  32. package/dist/esm/index.d.ts +2 -0
  33. package/dist/esm/index.js +21 -2
  34. package/dist/esm/index.js.map +1 -1
  35. package/dist/esm/persisted.d.ts +177 -11
  36. package/dist/esm/persisted.js +1842 -401
  37. package/dist/esm/persisted.js.map +1 -1
  38. package/dist/esm/remote-subset-owner.d.ts +7 -0
  39. package/dist/esm/remote-subset-owner.js +20 -0
  40. package/dist/esm/remote-subset-owner.js.map +1 -0
  41. package/dist/esm/remote-subset-wire.d.ts +59 -0
  42. package/dist/esm/remote-subset-wire.js +681 -0
  43. package/dist/esm/remote-subset-wire.js.map +1 -0
  44. package/dist/esm/sqlite-core-adapter.d.ts +45 -1
  45. package/dist/esm/sqlite-core-adapter.js +955 -350
  46. package/dist/esm/sqlite-core-adapter.js.map +1 -1
  47. package/dist/esm/sqlite-value.d.ts +10 -0
  48. package/dist/esm/sqlite-value.js +31 -0
  49. package/dist/esm/sqlite-value.js.map +1 -0
  50. package/package.json +12 -2
  51. package/src/broadcast-coordinator.ts +2456 -0
  52. package/src/errors.ts +120 -0
  53. package/src/index.ts +2 -0
  54. package/src/persisted.ts +2690 -444
  55. package/src/remote-subset-owner.ts +36 -0
  56. package/src/remote-subset-wire.ts +946 -0
  57. package/src/sqlite-core-adapter.ts +1297 -410
  58. package/src/sqlite-value.ts +30 -0
package/src/persisted.ts CHANGED
@@ -1,18 +1,38 @@
1
1
  import {
2
+ NoPendingSyncTransactionCommitError,
3
+ NoPendingSyncTransactionWriteError,
4
+ PERSISTED_READINESS,
5
+ SYNC_PERSISTENCE_PROTOCOL,
6
+ SYNC_PERSISTENCE_VERSION,
2
7
  SyncTransactionAbortedError,
3
8
  compileSingleRowExpression,
9
+ getLoadSubsetDemandKey,
4
10
  safeRandomUUID,
5
11
  toBooleanPredicate,
6
12
  withCollectionConfigFactory,
7
13
  } from '@tanstack/db'
8
14
  import {
15
+ DuplicateRemoteSubsetOwnerError,
9
16
  InvalidPersistedCollectionConfigError,
10
17
  InvalidPersistedCollectionCoordinatorError,
11
18
  InvalidPersistedStorageKeyEncodingError,
12
19
  InvalidPersistedStorageKeyError,
13
20
  InvalidPersistenceAdapterError,
14
21
  InvalidSyncConfigError,
22
+ PersistedCollectionDurabilityError,
23
+ toPersistedCollectionDurabilityError,
15
24
  } from './errors'
25
+ import { serializeSQLiteBigInt } from './sqlite-value'
26
+ import {
27
+ toProcessLocalLoadSubsetOptions,
28
+ toTransportedLoadSubsetOptions,
29
+ } from './remote-subset-wire'
30
+ import {
31
+ reportRemoteSubsetOwnerError,
32
+ unloadRemoteSubsetOwner,
33
+ } from './remote-subset-owner'
34
+ import type { TransportedLoadSubsetOptions } from './remote-subset-wire'
35
+ import type { RemoteSubsetOwner } from './remote-subset-owner'
16
36
  import type { StandardSchemaV1 } from '@standard-schema/spec'
17
37
  import type {
18
38
  ChangeMessageOrDeleteKeyMessage,
@@ -20,14 +40,20 @@ import type {
20
40
  CollectionConfig,
21
41
  CollectionIndexMetadata,
22
42
  DeleteMutationFnParams,
43
+ InferSchemaOutput,
23
44
  InsertMutationFnParams,
24
45
  LoadSubsetFn,
25
46
  LoadSubsetOptions,
26
47
  PendingMutation,
48
+ PersistedReadinessSnapshot,
49
+ PersistedReadinessSource,
27
50
  SyncAppliedReceipt,
28
51
  SyncConfig,
29
52
  SyncConfigRes,
30
53
  SyncMetadataApi,
54
+ SyncPersistenceCapabilityV1,
55
+ SyncPersistenceKeySetEvidence,
56
+ SyncPersistenceScanOptions,
31
57
  UpdateMutationFnParams,
32
58
  UtilsRecord,
33
59
  } from '@tanstack/db'
@@ -38,12 +64,20 @@ export type PersistedMutationEnvelope =
38
64
  type: `insert`
39
65
  key: string | number
40
66
  value: Record<string, unknown>
67
+ /** Persisted row metadata, not optimistic-transaction metadata. */
68
+ metadata?: unknown
69
+ /** Whether this envelope replaces or deletes the persisted row metadata. */
70
+ metadataChanged?: boolean
41
71
  }
42
72
  | {
43
73
  mutationId: string
44
74
  type: `update`
45
75
  key: string | number
46
76
  value: Record<string, unknown>
77
+ /** Persisted row metadata, not optimistic-transaction metadata. */
78
+ metadata?: unknown
79
+ /** Whether this envelope replaces or deletes the persisted row metadata. */
80
+ metadataChanged?: boolean
47
81
  }
48
82
  | {
49
83
  mutationId: string
@@ -96,7 +130,8 @@ export type TxCommitted = {
96
130
  export type EnsureRemoteSubsetRequest = {
97
131
  type: `rpc:ensureRemoteSubset:req`
98
132
  rpcId: string
99
- options: LoadSubsetOptions
133
+ acquisitionId: string
134
+ options: TransportedLoadSubsetOptions
100
135
  }
101
136
 
102
137
  export type EnsureRemoteSubsetResponse =
@@ -104,12 +139,33 @@ export type EnsureRemoteSubsetResponse =
104
139
  type: `rpc:ensureRemoteSubset:res`
105
140
  rpcId: string
106
141
  ok: true
142
+ leaderId: string
107
143
  }
108
144
  | {
109
145
  type: `rpc:ensureRemoteSubset:res`
110
146
  rpcId: string
111
147
  ok: false
112
148
  error: string
149
+ retryable?: true
150
+ }
151
+
152
+ export type ReleaseRemoteSubsetRequest = {
153
+ type: `rpc:releaseRemoteSubset:req`
154
+ rpcId: string
155
+ acquisitionId: string
156
+ }
157
+
158
+ export type ReleaseRemoteSubsetResponse =
159
+ | {
160
+ type: `rpc:releaseRemoteSubset:res`
161
+ rpcId: string
162
+ ok: true
163
+ }
164
+ | {
165
+ type: `rpc:releaseRemoteSubset:res`
166
+ rpcId: string
167
+ ok: false
168
+ error: string
113
169
  }
114
170
 
115
171
  export type ApplyLocalMutationsRequest = {
@@ -136,6 +192,48 @@ export type ApplyLocalMutationsResponse =
136
192
  code: `NOT_LEADER` | `VALIDATION_ERROR` | `CONFLICT` | `TIMEOUT`
137
193
  error: string
138
194
  }
195
+ | {
196
+ type: `rpc:applyLocalMutations:res`
197
+ rpcId: string
198
+ ok: false
199
+ code: `PERSISTENCE_ERROR`
200
+ error: string
201
+ sourceCode?: string | number
202
+ path?: string | ReadonlyArray<string | number>
203
+ }
204
+
205
+ export type ApplyCommittedTxRequest = {
206
+ type: `rpc:applyCommittedTx:req`
207
+ rpcId: string
208
+ envelopeId: string
209
+ tx: PersistedTx
210
+ }
211
+
212
+ export type ApplyCommittedTxResponse =
213
+ | {
214
+ type: `rpc:applyCommittedTx:res`
215
+ rpcId: string
216
+ ok: true
217
+ term: number
218
+ seq: number
219
+ latestRowVersion: number
220
+ }
221
+ | {
222
+ type: `rpc:applyCommittedTx:res`
223
+ rpcId: string
224
+ ok: false
225
+ code: `NOT_LEADER` | `CONFLICT` | `TIMEOUT`
226
+ error: string
227
+ }
228
+ | {
229
+ type: `rpc:applyCommittedTx:res`
230
+ rpcId: string
231
+ ok: false
232
+ code: `PERSISTENCE_ERROR`
233
+ error: string
234
+ sourceCode?: string | number
235
+ path?: string | ReadonlyArray<string | number>
236
+ }
139
237
 
140
238
  export type PullSinceRequest = {
141
239
  type: `rpc:pullSince:req`
@@ -215,10 +313,32 @@ export type PersistedScannedRow<
215
313
  metadata?: unknown
216
314
  }
217
315
 
218
- export type PersistedRowScanOptions = {
219
- metadataOnly?: boolean
316
+ export type PersistedRowScanOptions = SyncPersistenceScanOptions
317
+
318
+ export type PersistedKeySetEvidence = SyncPersistenceKeySetEvidence
319
+
320
+ type PersistedResumeGeneration = {
321
+ latestTerm: number
322
+ latestSeq: number
323
+ latestRowVersion: number
324
+ resetEpoch: number
220
325
  }
221
326
 
327
+ export type PersistencePullSinceResult =
328
+ | {
329
+ latestRowVersion: number
330
+ requiresFullReload: true
331
+ }
332
+ | {
333
+ latestRowVersion: number
334
+ requiresFullReload: false
335
+ changedKeys: Array<string | number>
336
+ deletedKeys: Array<string | number>
337
+ deltas?: Array<
338
+ ReplayableTxDelta<Record<string, unknown>, string | number>
339
+ >
340
+ }
341
+
222
342
  export type PersistedTx<
223
343
  T extends object = Record<string, unknown>,
224
344
  TKey extends string | number = string | number,
@@ -249,6 +369,21 @@ export type PersistedTx<
249
369
  collectionMetadataMutations?: Array<PersistedCollectionMetadataMutation>
250
370
  }
251
371
 
372
+ /**
373
+ * Opaque identity shared by every adapter over the same physical SQLite driver.
374
+ * The core adapter uses it to serialize non-preemptible logical operations
375
+ * while alternating one regular operation between queued hydrations.
376
+ *
377
+ * Delegating drivers must forward this property before they are passed to a
378
+ * SQLite persistence adapter. A driver may also brand each returned Promise
379
+ * with the same key for late capability discovery, but a wrapper must return
380
+ * that exact Promise: discovering the key after a call cannot retroactively
381
+ * schedule the wrapper's first logical operation.
382
+ */
383
+ export const SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY = Symbol.for(
384
+ `tanstack-db.sqlite-driver-supports-shared-logical-scheduling`,
385
+ )
386
+
252
387
  export interface PersistenceAdapter {
253
388
  loadSubset: (
254
389
  collectionId: string,
@@ -261,6 +396,25 @@ export interface PersistenceAdapter {
261
396
  metadata?: unknown
262
397
  }>
263
398
  >
399
+ loadResumeSnapshot: (
400
+ collectionId: string,
401
+ ctx?: {
402
+ requiredIndexSignatures?: ReadonlyArray<string>
403
+ includeRows?: boolean
404
+ },
405
+ ) => Promise<{
406
+ rows: Array<{
407
+ key: string | number
408
+ value: Record<string, unknown>
409
+ metadata?: unknown
410
+ }>
411
+ keySet?: PersistedKeySetEvidence
412
+ collectionMetadata: Array<{ key: string; value: unknown }>
413
+ latestTerm: number
414
+ latestSeq: number
415
+ latestRowVersion: number
416
+ resetEpoch: number
417
+ }>
264
418
  applyCommittedTx: (collectionId: string, tx: PersistedTx) => Promise<void>
265
419
  loadCollectionMetadata?: (
266
420
  collectionId: string,
@@ -280,9 +434,41 @@ export interface PersistenceAdapter {
280
434
  latestSeq: number
281
435
  latestRowVersion: number
282
436
  }>
437
+ /**
438
+ * Runs one complete logical hydrate as a non-preemptible scheduler unit.
439
+ * The callback must use the supplied unscheduled adapter for all nested
440
+ * persistence work and must not retain it after the callback settles.
441
+ */
442
+ runInHydrationScope?: <T>(
443
+ task: (adapter: HydrationPersistenceAdapter) => Promise<T>,
444
+ ) => Promise<T>
445
+ /** Schedule one regular operation before it acquires external writer locks. */
446
+ runInRegularScope?: <T>(
447
+ task: (adapter: HydrationPersistenceAdapter) => Promise<T>,
448
+ ) => Promise<T>
449
+ /** Whether hydration scopes currently enter a shared driver scheduler. */
450
+ isHydrationScopeScheduled?: () => boolean
451
+ }
452
+
453
+ export type HydrationPersistenceAdapter = PersistenceAdapter & {
454
+ pullSince?: (
455
+ collectionId: string,
456
+ fromRowVersion: number,
457
+ ) => Promise<PersistencePullSinceResult>
458
+ }
459
+
460
+ export type { RemoteSubsetOwner } from './remote-subset-owner'
461
+
462
+ type SingleProcessRemoteSubsetAcquisition = {
463
+ owner: RemoteSubsetOwner
464
+ options: TransportedLoadSubsetOptions
465
+ load: Promise<void>
283
466
  }
284
467
 
285
468
  export interface SQLiteDriver {
469
+ readonly [SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY]?: object
470
+ /** Host-specific limit on bound parameters in one query, when lower than SQLite's. */
471
+ readonly maxBoundParameters?: number
286
472
  exec: (sql: string) => Promise<void>
287
473
  query: <T>(
288
474
  sql: string,
@@ -297,8 +483,30 @@ export interface SQLiteDriver {
297
483
  ) => Promise<T>
298
484
  }
299
485
 
486
+ /**
487
+ * Forwards a driver's shared logical scheduling identity to a transparent
488
+ * delegating driver before the wrapper is used by a persistence adapter.
489
+ */
490
+ export function forwardSQLiteDriverSharedLogicalScheduling<
491
+ TDriver extends SQLiteDriver,
492
+ >(source: SQLiteDriver, wrapper: TDriver): TDriver {
493
+ const key = source[SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY]
494
+ if (key) {
495
+ Object.defineProperty(
496
+ wrapper,
497
+ SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY,
498
+ { value: key },
499
+ )
500
+ }
501
+ return wrapper
502
+ }
503
+
300
504
  export interface PersistedCollectionCoordinator {
301
505
  getNodeId: () => string
506
+ setAdapterForCollection?: (
507
+ collectionId: string,
508
+ adapter: PersistenceAdapter,
509
+ ) => void
302
510
  subscribe: (
303
511
  collectionId: string,
304
512
  onMessage: (message: ProtocolEnvelope<unknown>) => void,
@@ -306,22 +514,44 @@ export interface PersistedCollectionCoordinator {
306
514
  publish: (collectionId: string, message: ProtocolEnvelope<unknown>) => void
307
515
  isLeader: (collectionId: string) => boolean
308
516
  ensureLeadership: (collectionId: string) => Promise<void>
309
- requestEnsureRemoteSubset?: (
517
+ requestEnsureRemoteSubset: (
518
+ collectionId: string,
519
+ options: LoadSubsetOptions,
520
+ ) => Promise<void>
521
+ requestReleaseRemoteSubset: (
310
522
  collectionId: string,
311
523
  options: LoadSubsetOptions,
312
524
  ) => Promise<void>
525
+ registerRemoteSubsetOwner: (
526
+ collectionId: string,
527
+ owner: RemoteSubsetOwner,
528
+ ) => () => void
529
+ /**
530
+ * Requests leader-side index creation. The scoped adapter is leader-local
531
+ * and is never serialized to a follower. `localEnsureCompleted` lets the
532
+ * built-in leader avoid repeating successful local work.
533
+ */
313
534
  requestEnsurePersistedIndex: (
314
535
  collectionId: string,
315
536
  signature: string,
316
537
  spec: PersistedIndexSpec,
538
+ scopedAdapter?: HydrationPersistenceAdapter,
539
+ localEnsureCompleted?: boolean,
317
540
  ) => Promise<void>
318
541
  requestApplyLocalMutations?: (
319
542
  collectionId: string,
320
543
  mutations: Array<PersistedMutationEnvelope>,
321
544
  ) => Promise<ApplyLocalMutationsResponse>
545
+ requestApplyCommittedTx: (
546
+ collectionId: string,
547
+ tx: PersistedTx,
548
+ scopedAdapter?: HydrationPersistenceAdapter,
549
+ ) => Promise<ApplyCommittedTxResponse>
550
+ /** The scoped adapter is leader-local and is never serialized to a follower. */
322
551
  pullSince?: (
323
552
  collectionId: string,
324
553
  fromRowVersion: number,
554
+ scopedAdapter?: HydrationPersistenceAdapter,
325
555
  ) => Promise<PullSinceResponse>
326
556
  }
327
557
 
@@ -352,9 +582,78 @@ export interface PersistedCollectionUtils extends UtilsRecord {
352
582
  mutations: Array<PendingMutation<Record<string, unknown>>>
353
583
  }) => Promise<void> | void
354
584
  getLeadershipState?: () => PersistedCollectionLeadershipState
585
+ /** Hydrate once without acquiring a new ongoing subset lease. */
355
586
  forceReloadSubset?: (options: LoadSubsetOptions) => Promise<void> | void
356
587
  }
357
588
 
589
+ class PersistedReadinessTracker implements PersistedReadinessSource {
590
+ private snapshot: PersistedReadinessSnapshot = { status: `loading` }
591
+ private networkDeadlineAt: number | undefined
592
+ private readonly listeners = new Set<() => void>()
593
+
594
+ constructor(readonly networkTimeoutMs: number) {}
595
+
596
+ getSnapshot = (): PersistedReadinessSnapshot => this.snapshot
597
+
598
+ getOrStartNetworkDeadline = (): number => {
599
+ return (this.networkDeadlineAt ??= Date.now() + this.networkTimeoutMs)
600
+ }
601
+
602
+ subscribe = (listener: () => void): (() => void) => {
603
+ this.listeners.add(listener)
604
+ return () => this.listeners.delete(listener)
605
+ }
606
+
607
+ set(snapshot: PersistedReadinessSnapshot): void {
608
+ const deadlineRestarted =
609
+ snapshot.status === `loading` && this.networkDeadlineAt !== undefined
610
+ if (deadlineRestarted) {
611
+ this.networkDeadlineAt = Date.now() + this.networkTimeoutMs
612
+ }
613
+ if (
614
+ this.snapshot.status === snapshot.status &&
615
+ this.snapshot.error === snapshot.error &&
616
+ !deadlineRestarted
617
+ ) {
618
+ return
619
+ }
620
+ this.snapshot = snapshot
621
+ for (const listener of this.listeners) listener()
622
+ }
623
+ }
624
+
625
+ export type PersistedInitialRenderOptions = {
626
+ strategy: `network-first`
627
+ /** Time to prefer network before restored rows may render; defaults to 3s. */
628
+ networkTimeoutMs?: number
629
+ }
630
+
631
+ const DEFAULT_NETWORK_TIMEOUT_MS = 3_000
632
+ const MAX_TIMER_TIMEOUT_MS = 2_147_483_647
633
+
634
+ function getNetworkTimeoutMs(
635
+ initialRender: PersistedInitialRenderOptions | undefined,
636
+ ): number | undefined {
637
+ if (initialRender === undefined) return undefined
638
+ const strategy = (initialRender as { strategy: unknown }).strategy
639
+ if (strategy !== `network-first`) {
640
+ throw new InvalidPersistedCollectionConfigError(
641
+ `initialRender.strategy must be "network-first"`,
642
+ )
643
+ }
644
+ const timeoutMs = initialRender.networkTimeoutMs ?? DEFAULT_NETWORK_TIMEOUT_MS
645
+ if (
646
+ !Number.isFinite(timeoutMs) ||
647
+ timeoutMs < 0 ||
648
+ timeoutMs > MAX_TIMER_TIMEOUT_MS
649
+ ) {
650
+ throw new InvalidPersistedCollectionConfigError(
651
+ `initialRender.networkTimeoutMs must be between 0 and ${MAX_TIMER_TIMEOUT_MS}`,
652
+ )
653
+ }
654
+ return timeoutMs
655
+ }
656
+
358
657
  export type PersistedSyncWrappedOptions<
359
658
  T extends object,
360
659
  TKey extends string | number,
@@ -364,6 +663,8 @@ export type PersistedSyncWrappedOptions<
364
663
  sync: SyncConfig<T, TKey>
365
664
  persistence: PersistedCollectionPersistence
366
665
  schemaVersion?: number
666
+ /** Opt into network-first rendering with a persisted fallback. */
667
+ initialRender?: PersistedInitialRenderOptions
367
668
  }
368
669
 
369
670
  export type PersistedLocalOnlyOptions<
@@ -374,6 +675,8 @@ export type PersistedLocalOnlyOptions<
374
675
  > = Omit<CollectionConfig<T, TKey, TSchema, TUtils>, `sync`> & {
375
676
  persistence: PersistedCollectionPersistence
376
677
  schemaVersion?: number
678
+ /** Opt into network-first rendering with a persisted fallback. */
679
+ initialRender?: PersistedInitialRenderOptions
377
680
  }
378
681
 
379
682
  type PersistedSyncOptionsResult<
@@ -404,7 +707,11 @@ const REQUIRED_COORDINATOR_METHODS: ReadonlyArray<
404
707
  | `publish`
405
708
  | `isLeader`
406
709
  | `ensureLeadership`
710
+ | `requestEnsureRemoteSubset`
711
+ | `requestReleaseRemoteSubset`
712
+ | `registerRemoteSubsetOwner`
407
713
  | `requestEnsurePersistedIndex`
714
+ | `requestApplyCommittedTx`
408
715
  >
409
716
  > = [
410
717
  `getNodeId`,
@@ -412,15 +719,19 @@ const REQUIRED_COORDINATOR_METHODS: ReadonlyArray<
412
719
  `publish`,
413
720
  `isLeader`,
414
721
  `ensureLeadership`,
722
+ `requestEnsureRemoteSubset`,
723
+ `requestReleaseRemoteSubset`,
724
+ `registerRemoteSubsetOwner`,
415
725
  `requestEnsurePersistedIndex`,
726
+ `requestApplyCommittedTx`,
416
727
  ]
417
728
 
418
729
  const REQUIRED_ADAPTER_METHODS: ReadonlyArray<
419
730
  keyof Pick<
420
731
  PersistenceAdapter,
421
- `loadSubset` | `applyCommittedTx` | `ensureIndex`
732
+ `loadSubset` | `loadResumeSnapshot` | `applyCommittedTx` | `ensureIndex`
422
733
  >
423
- > = [`loadSubset`, `applyCommittedTx`, `ensureIndex`]
734
+ > = [`loadSubset`, `loadResumeSnapshot`, `applyCommittedTx`, `ensureIndex`]
424
735
 
425
736
  const TARGETED_INVALIDATION_KEY_LIMIT = 128
426
737
  const DEFAULT_DB_NAME = `tanstack-db`
@@ -439,6 +750,7 @@ type SyncControlFns<T extends object, TKey extends string | number> = {
439
750
  commit: ((signal?: AbortSignal) => SyncAppliedReceipt) | null
440
751
  truncate: (() => void) | null
441
752
  metadata: SyncMetadataApi<TKey> | null
753
+ markError: ((error: unknown) => void) | null
442
754
  }
443
755
 
444
756
  /**
@@ -447,6 +759,12 @@ type SyncControlFns<T extends object, TKey extends string | number> = {
447
759
  */
448
760
  export class SingleProcessCoordinator implements PersistedCollectionCoordinator {
449
761
  private readonly nodeId: string
762
+ private readonly collectionAdapters = new Map<string, PersistenceAdapter>()
763
+ private readonly remoteSubsetOwners = new Map<string, RemoteSubsetOwner>()
764
+ private readonly remoteSubsetAcquisitions = new Map<
765
+ string,
766
+ Map<LoadSubsetOptions, SingleProcessRemoteSubsetAcquisition>
767
+ >()
450
768
 
451
769
  constructor(nodeId: string = safeRandomUUID()) {
452
770
  this.nodeId = nodeId
@@ -468,10 +786,129 @@ export class SingleProcessCoordinator implements PersistedCollectionCoordinator
468
786
 
469
787
  public async ensureLeadership(): Promise<void> {}
470
788
 
471
- public async requestEnsureRemoteSubset(): Promise<void> {}
789
+ public async requestEnsureRemoteSubset(
790
+ collectionId: string,
791
+ options: LoadSubsetOptions,
792
+ ): Promise<void> {
793
+ const transported = toTransportedLoadSubsetOptions(options)
794
+ const localOptions = toProcessLocalLoadSubsetOptions(options, transported)
795
+ const owner = this.remoteSubsetOwners.get(collectionId)
796
+ if (!owner) {
797
+ throw new InvalidPersistedCollectionConfigError(
798
+ `SingleProcessCoordinator has no remote subset owner configured for collection "${collectionId}"`,
799
+ )
800
+ }
801
+ let acquisitions = this.remoteSubsetAcquisitions.get(collectionId)
802
+ if (!acquisitions) {
803
+ acquisitions = new Map()
804
+ this.remoteSubsetAcquisitions.set(collectionId, acquisitions)
805
+ }
806
+ const existing = acquisitions.get(options)
807
+ if (existing) {
808
+ await existing.load
809
+ return
810
+ }
811
+
812
+ let resolveLoad!: () => void
813
+ let rejectLoad!: (error: unknown) => void
814
+ const load = new Promise<void>((resolve, reject) => {
815
+ resolveLoad = resolve
816
+ rejectLoad = reject
817
+ })
818
+ acquisitions.set(options, { owner, options: localOptions, load })
819
+ try {
820
+ const ownerLoad = owner(localOptions)
821
+ void Promise.resolve(ownerLoad).then(resolveLoad, (error) => {
822
+ reportRemoteSubsetOwnerError(owner, error)
823
+ rejectLoad(error)
824
+ })
825
+ } catch (error) {
826
+ reportRemoteSubsetOwnerError(owner, error)
827
+ rejectLoad(error)
828
+ }
829
+ await load
830
+ }
831
+
832
+ public async requestReleaseRemoteSubset(
833
+ collectionId: string,
834
+ options: LoadSubsetOptions,
835
+ ): Promise<void> {
836
+ const acquisitions = this.remoteSubsetAcquisitions.get(collectionId)
837
+ const acquisition = acquisitions?.get(options)
838
+ if (!acquisition) return
839
+ acquisitions!.delete(options)
840
+ if (acquisitions!.size === 0) {
841
+ this.remoteSubsetAcquisitions.delete(collectionId)
842
+ }
843
+ try {
844
+ await acquisition.load
845
+ } catch {
846
+ // Calling the owner transferred the lease even when its load rejected.
847
+ }
848
+ await unloadRemoteSubsetOwner(acquisition.owner, acquisition.options)
849
+ }
850
+
851
+ public registerRemoteSubsetOwner(
852
+ collectionId: string,
853
+ owner: RemoteSubsetOwner,
854
+ ): () => void {
855
+ if (this.remoteSubsetOwners.has(collectionId)) {
856
+ throw new DuplicateRemoteSubsetOwnerError(collectionId)
857
+ }
858
+ this.remoteSubsetOwners.set(collectionId, owner)
859
+ return () => {
860
+ if (this.remoteSubsetOwners.get(collectionId) !== owner) return
861
+ const acquisitions = this.remoteSubsetAcquisitions.get(collectionId)
862
+ this.remoteSubsetAcquisitions.delete(collectionId)
863
+ this.remoteSubsetOwners.delete(collectionId)
864
+ for (const acquisition of acquisitions?.values() ?? []) {
865
+ void (async () => {
866
+ try {
867
+ await acquisition.load
868
+ } catch {
869
+ // Calling the owner transferred the lease even when its load rejected.
870
+ }
871
+ await unloadRemoteSubsetOwner(owner, acquisition.options)
872
+ })().catch(() => undefined)
873
+ }
874
+ }
875
+ }
472
876
 
473
877
  public async requestEnsurePersistedIndex(): Promise<void> {}
474
878
 
879
+ public setAdapterForCollection(
880
+ collectionId: string,
881
+ adapter: PersistenceAdapter,
882
+ ): void {
883
+ this.collectionAdapters.set(collectionId, adapter)
884
+ }
885
+
886
+ public async requestApplyCommittedTx(
887
+ collectionId: string,
888
+ tx: PersistedTx,
889
+ ): Promise<ApplyCommittedTxResponse> {
890
+ const adapter = this.collectionAdapters.get(collectionId)
891
+ if (!adapter) {
892
+ throw new InvalidPersistedCollectionConfigError(
893
+ `SingleProcessCoordinator has no persistence adapter configured for collection "${collectionId}"`,
894
+ )
895
+ }
896
+
897
+ try {
898
+ await adapter.applyCommittedTx(collectionId, tx)
899
+ } catch (error) {
900
+ throw toPersistedCollectionDurabilityError(collectionId, error)
901
+ }
902
+ return {
903
+ type: `rpc:applyCommittedTx:res`,
904
+ rpcId: safeRandomUUID(),
905
+ ok: true,
906
+ term: tx.term,
907
+ seq: tx.seq,
908
+ latestRowVersion: tx.rowVersion,
909
+ }
910
+ }
911
+
475
912
  public pullSince(): Promise<PullSinceResponse> {
476
913
  return Promise.resolve({
477
914
  type: `rpc:pullSince:res`,
@@ -508,10 +945,14 @@ function validatePersistenceAdapter(adapter: PersistenceAdapter): void {
508
945
 
509
946
  function resolvePersistence(
510
947
  persistence: PersistedCollectionPersistence,
948
+ collectionId: string,
511
949
  ): PersistedResolvedPersistence {
512
950
  validatePersistenceAdapter(persistence.adapter)
513
951
 
514
952
  const coordinator = persistence.coordinator ?? new SingleProcessCoordinator()
953
+ if (coordinator instanceof SingleProcessCoordinator) {
954
+ coordinator.setAdapterForCollection(collectionId, persistence.adapter)
955
+ }
515
956
  validatePersistedCollectionCoordinator(coordinator)
516
957
 
517
958
  return {
@@ -523,9 +964,13 @@ function resolvePersistence(
523
964
  function resolvePersistenceForMode(
524
965
  persistence: PersistedCollectionPersistence,
525
966
  mode: PersistedCollectionMode,
967
+ collectionId: string,
526
968
  ): PersistedResolvedPersistence {
527
969
  const modeSpecificPersistence = persistence.resolvePersistenceForMode?.(mode)
528
- return resolvePersistence(modeSpecificPersistence ?? persistence)
970
+ return resolvePersistence(
971
+ modeSpecificPersistence ?? persistence,
972
+ collectionId,
973
+ )
529
974
  }
530
975
 
531
976
  function resolvePersistenceForCollection(
@@ -539,10 +984,17 @@ function resolvePersistenceForCollection(
539
984
  const collectionSpecificPersistence =
540
985
  persistence.resolvePersistenceForCollection?.(options)
541
986
  if (collectionSpecificPersistence) {
542
- return resolvePersistence(collectionSpecificPersistence)
987
+ return resolvePersistence(
988
+ collectionSpecificPersistence,
989
+ options.collectionId,
990
+ )
543
991
  }
544
992
 
545
- return resolvePersistenceForMode(persistence, options.mode)
993
+ return resolvePersistenceForMode(
994
+ persistence,
995
+ options.mode,
996
+ options.collectionId,
997
+ )
546
998
  }
547
999
 
548
1000
  function hasOwnSyncKey(options: object): options is { sync: unknown } {
@@ -579,6 +1031,7 @@ type NormalizedSyncOperation<T extends object, TKey extends string | number> =
579
1031
 
580
1032
  type BufferedSyncTransaction<T extends object, TKey extends string | number> = {
581
1033
  operations: Array<NormalizedSyncOperation<T, TKey>>
1034
+ partialUpdateOperationIndexes: Set<number>
582
1035
  rowMetadataWrites: Map<
583
1036
  TKey,
584
1037
  { type: `set`; value: unknown } | { type: `delete` }
@@ -587,44 +1040,95 @@ type BufferedSyncTransaction<T extends object, TKey extends string | number> = {
587
1040
  string,
588
1041
  { type: `set`; value: unknown } | { type: `delete` }
589
1042
  >
1043
+ deferredHydrationMetadataDeleteKeys: Set<TKey>
1044
+ hydrationContext?: { suppliedRowKeys: Set<TKey> }
1045
+ hydrationSequence?: number
590
1046
  truncate: boolean
591
1047
  internal: boolean
1048
+ lifecycleGeneration: number
1049
+ beginOptions?: { immediate?: boolean }
1050
+ expectedResumeGenerationOwner?: symbol
592
1051
  signal?: AbortSignal
1052
+ prependHydrationRows: (
1053
+ rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
1054
+ ) => void
1055
+ applyToCollection: () => SyncAppliedReceipt
1056
+ shouldFailStopOnAbort?: () => boolean
593
1057
  resolveApplied?: () => void
594
1058
  rejectApplied?: (error: unknown) => void
595
1059
  }
596
1060
 
597
- type OpenSyncTransaction<
598
- T extends object,
599
- TKey extends string | number,
600
- > = BufferedSyncTransaction<T, TKey> & {
1061
+ type OpenSyncTransaction<T extends object, TKey extends string | number> = Omit<
1062
+ BufferedSyncTransaction<T, TKey>,
1063
+ `applyToCollection` | `prependHydrationRows` | `shouldFailStopOnAbort`
1064
+ > & {
1065
+ signal?: AbortSignal
1066
+ applicationReceipt?: SyncAppliedReceipt
1067
+ operationKeys: Set<TKey>
601
1068
  queuedBecauseHydrating: boolean
1069
+ hasDependentSuccessor: boolean
1070
+ publicationAdmissionWaiters?: Set<{
1071
+ resolve: () => void
1072
+ reject: (error: unknown) => void
1073
+ }>
1074
+ terminalFailure?: { error: unknown }
602
1075
  }
603
1076
 
604
1077
  type SyncWriteNormalization<T extends object, TKey extends string | number> = {
605
- forwardMessage:
606
- | {
607
- type: `update`
608
- value: T
609
- metadata?: Record<string, unknown>
610
- }
611
- | {
612
- type: `delete`
613
- key: TKey
614
- }
615
1078
  operation: NormalizedSyncOperation<T, TKey>
616
1079
  }
617
1080
 
618
1081
  class ApplyMutex {
619
1082
  private queue: Promise<void> = Promise.resolve()
1083
+ private pending = 0
620
1084
 
621
- async run<T>(task: () => Promise<T>): Promise<T> {
622
- const taskPromise = this.queue.then(() => task())
623
- this.queue = taskPromise.then(
624
- () => undefined,
625
- () => undefined,
626
- )
627
- return taskPromise
1085
+ reserve(): {
1086
+ run: <T>(task: () => Promise<T> | T) => Promise<T>
1087
+ } {
1088
+ const runImmediately = this.pending === 0
1089
+ const predecessor = this.queue
1090
+ this.pending++
1091
+
1092
+ let releaseReservation!: () => void
1093
+ const reservation = new Promise<void>((resolve) => {
1094
+ releaseReservation = resolve
1095
+ })
1096
+ this.queue = runImmediately
1097
+ ? reservation
1098
+ : predecessor.then(() => reservation)
1099
+
1100
+ let used = false
1101
+ return {
1102
+ run: <T>(task: () => Promise<T> | T): Promise<T> => {
1103
+ if (used) {
1104
+ return Promise.reject(
1105
+ new Error(`an apply-mutex reservation can only run once`),
1106
+ )
1107
+ }
1108
+ used = true
1109
+
1110
+ let taskPromise: Promise<T>
1111
+ if (runImmediately) {
1112
+ try {
1113
+ taskPromise = Promise.resolve(task())
1114
+ } catch (error) {
1115
+ taskPromise = Promise.reject(error)
1116
+ }
1117
+ } else {
1118
+ taskPromise = predecessor.then(() => task())
1119
+ }
1120
+ const markComplete = () => {
1121
+ this.pending--
1122
+ releaseReservation()
1123
+ }
1124
+ void taskPromise.then(markComplete, markComplete)
1125
+ return taskPromise
1126
+ },
1127
+ }
1128
+ }
1129
+
1130
+ run<T>(task: () => Promise<T> | T): Promise<T> {
1131
+ return this.reserve().run(task)
628
1132
  }
629
1133
  }
630
1134
 
@@ -639,7 +1143,7 @@ function toStableSerializable(value: unknown): unknown {
639
1143
  case `boolean`:
640
1144
  return value
641
1145
  case `bigint`:
642
- return value.toString()
1146
+ return serializeSQLiteBigInt(value)
643
1147
  case `function`:
644
1148
  case `symbol`:
645
1149
  case `undefined`:
@@ -707,18 +1211,6 @@ function stableSerialize(value: unknown): string {
707
1211
  return JSON.stringify(toStableSerializable(value) ?? null)
708
1212
  }
709
1213
 
710
- function normalizeSubsetOptionsForKey(
711
- options: LoadSubsetOptions,
712
- ): Record<string, unknown> {
713
- return {
714
- where: toStableSerializable(options.where),
715
- orderBy: toStableSerializable(options.orderBy),
716
- limit: options.limit,
717
- cursor: toStableSerializable(options.cursor),
718
- offset: options.offset,
719
- }
720
- }
721
-
722
1214
  function normalizeSyncFnResult(result: void | (() => void) | SyncConfigRes) {
723
1215
  if (typeof result === `function`) {
724
1216
  return { cleanup: result } satisfies SyncConfigRes
@@ -780,6 +1272,8 @@ function toPersistedMutationEnvelope(
780
1272
  ? (mutation.original as Record<string, unknown>)
781
1273
  : mutation.modified
782
1274
 
1275
+ // PendingMutation.metadata belongs to the optimistic transaction and is
1276
+ // consumed by mutation handlers. It must not overwrite persisted row metadata.
783
1277
  return {
784
1278
  mutationId: mutation.mutationId,
785
1279
  type: mutation.type,
@@ -794,6 +1288,10 @@ class PersistedCollectionRuntime<
794
1288
  > {
795
1289
  private readonly applyMutex = new ApplyMutex()
796
1290
  private readonly activeSubsets = new Map<string, LoadSubsetOptions>()
1291
+ private activeHydrationContext: { suppliedRowKeys: Set<TKey> } | undefined
1292
+ private hydrationSequence = 0
1293
+ // An unscheduled startup read may finish after a coordinator reset publishes.
1294
+ private resetSequence = 0
797
1295
  private readonly pendingRemoteSubsetEnsures = new Map<
798
1296
  string,
799
1297
  LoadSubsetOptions
@@ -801,8 +1299,12 @@ class PersistedCollectionRuntime<
801
1299
  private readonly queuedHydrationTransactions: Array<
802
1300
  BufferedSyncTransaction<T, TKey>
803
1301
  > = []
804
- private readonly queuedTxCommitted: Array<TxCommitted> = []
805
- private readonly subscriptionIds = new WeakMap<object, string>()
1302
+ private readonly queuedTxCommitted: Array<{
1303
+ txCommitted: TxCommitted
1304
+ lifecycleGeneration: number
1305
+ }> = []
1306
+ private readonly requestIds = new WeakMap<LoadSubsetOptions, string>()
1307
+ private readonly hydratedDemands = new Set<string | undefined>()
806
1308
 
807
1309
  private collection: Collection<T, TKey, PersistedCollectionUtils> | null =
808
1310
  null
@@ -812,19 +1314,35 @@ class PersistedCollectionRuntime<
812
1314
  commit: null,
813
1315
  truncate: null,
814
1316
  metadata: null,
1317
+ markError: null,
815
1318
  }
816
- private started = false
817
1319
  private startupMetadataPromise: Promise<void> | null = null
818
1320
  private startPromise: Promise<void> | null = null
1321
+ private hasAttemptedStartup = false
1322
+ private resumeBaselinePromise: Promise<void> | null = null
1323
+ private resumeCertificationPromise: Promise<void> | null = null
1324
+ private persistedKeySetEvidence: PersistedKeySetEvidence | undefined
1325
+ private persistedResumeGeneration: PersistedResumeGeneration | undefined
1326
+ private resumeGenerationOwner = Symbol(`persisted resume generation owner`)
1327
+ private lifecycleGeneration = 0
819
1328
  private internalApplyDepth = 0
1329
+ private sourcePublicationWaitDepth = 0
820
1330
  private appliedReceiptSequence = 0
1331
+ private syncErrorReported = false
1332
+ private reportedSyncError: unknown
821
1333
  private readonly pendingAppliedReceipts = new Map<number, Promise<void>>()
822
- private isHydrating = false
1334
+ private hydratingGeneration: number | null = null
1335
+ private terminalFailure:
1336
+ | { lifecycleGeneration: number; error: unknown }
1337
+ | undefined
823
1338
  private coordinatorUnsubscribe: (() => void) | null = null
1339
+ private remoteSubsetOwnerUnsubscribe: (() => void) | null = null
824
1340
  private indexAddedUnsubscribe: (() => void) | null = null
825
1341
  private indexRemovedUnsubscribe: (() => void) | null = null
826
1342
  private remoteEnsureRetryTimer: ReturnType<typeof setTimeout> | null = null
827
- private nextSubscriptionId = 0
1343
+ private nextRequestId = 0
1344
+ private startupSettled = false
1345
+ private sourceTruncateGeneration = 0
828
1346
 
829
1347
  private latestTerm = 0
830
1348
  private latestSeq = 0
@@ -839,9 +1357,14 @@ class PersistedCollectionRuntime<
839
1357
  private readonly persistence: PersistedResolvedPersistence,
840
1358
  private readonly syncMode: `eager` | `on-demand`,
841
1359
  private readonly dbName: string,
1360
+ private readonly persistedReadiness?: PersistedReadinessTracker,
842
1361
  ) {}
843
1362
 
844
1363
  setSyncControls(syncControls: SyncControlFns<T, TKey>): void {
1364
+ this.advanceLifecycle()
1365
+ this.syncErrorReported = false
1366
+ this.reportedSyncError = undefined
1367
+
845
1368
  const commit = syncControls.commit
846
1369
  this.syncControls = {
847
1370
  ...syncControls,
@@ -851,6 +1374,30 @@ class PersistedCollectionRuntime<
851
1374
  }
852
1375
  }
853
1376
 
1377
+ reportSyncError(error: unknown): unknown {
1378
+ const markError = this.syncControls.markError
1379
+ if (this.syncErrorReported) return this.reportedSyncError
1380
+ if (!markError) return error
1381
+
1382
+ this.syncErrorReported = true
1383
+ this.reportedSyncError = error
1384
+ try {
1385
+ markError(error)
1386
+ } catch {
1387
+ // Reporting must not replace the original asynchronous failure.
1388
+ }
1389
+ return error
1390
+ }
1391
+
1392
+ registerRemoteSubsetOwner(owner: RemoteSubsetOwner): void {
1393
+ this.remoteSubsetOwnerUnsubscribe?.()
1394
+ this.remoteSubsetOwnerUnsubscribe =
1395
+ this.persistence.coordinator.registerRemoteSubsetOwner(
1396
+ this.collectionId,
1397
+ owner,
1398
+ )
1399
+ }
1400
+
854
1401
  private trackAppliedReceipt(receipt: SyncAppliedReceipt): SyncAppliedReceipt {
855
1402
  const sequence = ++this.appliedReceiptSequence
856
1403
  if (receipt === true) {
@@ -870,18 +1417,75 @@ class PersistedCollectionRuntime<
870
1417
  )
871
1418
  }
872
1419
 
873
- clearSyncControls(): void {
1420
+ private clearSyncControls(): void {
874
1421
  this.syncControls = {
875
1422
  begin: null,
876
1423
  write: null,
877
1424
  commit: null,
878
1425
  truncate: null,
879
1426
  metadata: null,
1427
+ markError: null,
880
1428
  }
881
1429
  }
882
1430
 
883
1431
  isHydratingNow(): boolean {
884
- return this.isHydrating
1432
+ return this.hydratingGeneration === this.lifecycleGeneration
1433
+ }
1434
+
1435
+ getActiveHydrationContext(): { suppliedRowKeys: Set<TKey> } | undefined {
1436
+ return this.activeHydrationContext
1437
+ }
1438
+
1439
+ getHydrationSequence(): number {
1440
+ return this.hydrationSequence
1441
+ }
1442
+
1443
+ getCurrentTerminalFailure(): { error: unknown } | undefined {
1444
+ const failure = this.terminalFailure
1445
+ return failure?.lifecycleGeneration === this.lifecycleGeneration
1446
+ ? failure
1447
+ : undefined
1448
+ }
1449
+
1450
+ getLifecycleGeneration(): number {
1451
+ return this.lifecycleGeneration
1452
+ }
1453
+
1454
+ private throwIfLifecycleReplaced(lifecycleGeneration: number): void {
1455
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
1456
+ throw new SyncTransactionAbortedError()
1457
+ }
1458
+ }
1459
+
1460
+ private throwIfTerminal(): void {
1461
+ const failure = this.getCurrentTerminalFailure()
1462
+ if (failure) throw failure.error
1463
+ }
1464
+
1465
+ private markTerminalFailure(
1466
+ error: unknown,
1467
+ lifecycleGeneration = this.lifecycleGeneration,
1468
+ ): unknown {
1469
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
1470
+ return error
1471
+ }
1472
+
1473
+ const existing = this.getCurrentTerminalFailure()
1474
+ if (existing) {
1475
+ return existing.error
1476
+ }
1477
+
1478
+ this.terminalFailure = { lifecycleGeneration, error }
1479
+ this.persistedReadiness?.set({ status: `error`, error })
1480
+ this.pendingRemoteSubsetEnsures.clear()
1481
+ this.queuedTxCommitted.length = 0
1482
+ if (this.remoteEnsureRetryTimer !== null) {
1483
+ clearTimeout(this.remoteEnsureRetryTimer)
1484
+ this.remoteEnsureRetryTimer = null
1485
+ }
1486
+ this.rejectQueuedHydrationTransactions(error)
1487
+ this.reportSyncError(error)
1488
+ return error
885
1489
  }
886
1490
 
887
1491
  isApplyingInternally(): boolean {
@@ -906,88 +1510,313 @@ class PersistedCollectionRuntime<
906
1510
  }
907
1511
  }
908
1512
 
1513
+ private runInHydrationScope<TResult>(
1514
+ task: (adapter: HydrationPersistenceAdapter) => Promise<TResult>,
1515
+ adapter: HydrationPersistenceAdapter = this.persistence.adapter,
1516
+ ): Promise<TResult> {
1517
+ if (adapter.runInHydrationScope) {
1518
+ return adapter.runInHydrationScope(task)
1519
+ }
1520
+ return Promise.resolve().then(() => task(adapter))
1521
+ }
1522
+
909
1523
  async ensureStarted(): Promise<void> {
1524
+ this.throwIfTerminal()
910
1525
  if (this.startPromise) {
911
1526
  return this.startPromise
912
1527
  }
913
1528
 
914
- this.startPromise = this.startInternal()
1529
+ const lifecycleGeneration = this.lifecycleGeneration
1530
+ const isRestart = this.hasAttemptedStartup
1531
+ this.hasAttemptedStartup = true
1532
+ let resolveStartupMetadata!: () => void
1533
+ let rejectStartupMetadata!: (error: unknown) => void
1534
+ this.startupMetadataPromise = new Promise<void>((resolve, reject) => {
1535
+ resolveStartupMetadata = resolve
1536
+ rejectStartupMetadata = reject
1537
+ })
1538
+ void this.startupMetadataPromise.catch(() => undefined)
1539
+
1540
+ this.startPromise = (async () => {
1541
+ const loadStartupMetadata = async (
1542
+ adapter: HydrationPersistenceAdapter,
1543
+ ) => {
1544
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
1545
+ resolveStartupMetadata()
1546
+ return false
1547
+ }
1548
+
1549
+ try {
1550
+ await this.loadStartupMetadataInternal(lifecycleGeneration, adapter)
1551
+ return lifecycleGeneration === this.lifecycleGeneration
1552
+ } catch (error) {
1553
+ rejectStartupMetadata(error)
1554
+ throw error
1555
+ }
1556
+ }
1557
+
1558
+ let startup:
1559
+ | {
1560
+ appliedCursor: number | undefined
1561
+ indexBootstrapSnapshot: Array<CollectionIndexMetadata>
1562
+ completedLocalIndexSignatures: Set<string>
1563
+ }
1564
+ | undefined
1565
+ const scheduleStartupAsOneHydrate =
1566
+ this.persistence.adapter.runInHydrationScope !== undefined &&
1567
+ (!isRestart ||
1568
+ (this.persistence.adapter.isHydrationScopeScheduled?.() ?? true))
1569
+ if (scheduleStartupAsOneHydrate) {
1570
+ startup = await this.applyMutex.run(async () => {
1571
+ const result = await this.runInHydrationScope(async (adapter) => {
1572
+ if (!(await loadStartupMetadata(adapter))) return undefined
1573
+ return this.startInternal(
1574
+ lifecycleGeneration,
1575
+ adapter,
1576
+ resolveStartupMetadata,
1577
+ )
1578
+ })
1579
+ if (lifecycleGeneration === this.lifecycleGeneration) {
1580
+ await this.flushQueuedTxCommittedUnsafe()
1581
+ }
1582
+ return result
1583
+ })
1584
+ } else {
1585
+ // Preserve the existing unscheduled-adapter lifecycle contract: a
1586
+ // replacement upstream may start while stale hydration is settling.
1587
+ if (await loadStartupMetadata(this.persistence.adapter)) {
1588
+ if (this.persistence.adapter.isHydrationScopeScheduled?.()) {
1589
+ startup = await this.applyMutex.run(async () => {
1590
+ const result = await this.runInHydrationScope((adapter) =>
1591
+ this.startInternal(
1592
+ lifecycleGeneration,
1593
+ adapter,
1594
+ resolveStartupMetadata,
1595
+ ),
1596
+ )
1597
+ if (lifecycleGeneration === this.lifecycleGeneration) {
1598
+ await this.flushQueuedTxCommittedUnsafe()
1599
+ }
1600
+ return result
1601
+ })
1602
+ } else {
1603
+ startup = await this.startInternal(
1604
+ lifecycleGeneration,
1605
+ this.persistence.adapter,
1606
+ resolveStartupMetadata,
1607
+ )
1608
+ if (lifecycleGeneration === this.lifecycleGeneration) {
1609
+ await this.flushQueuedTxCommittedUnsafe()
1610
+ }
1611
+ }
1612
+ }
1613
+ }
1614
+ if (
1615
+ startup !== undefined &&
1616
+ lifecycleGeneration === this.lifecycleGeneration
1617
+ ) {
1618
+ await this.requestCoordinatorPersistedIndexes(
1619
+ startup.indexBootstrapSnapshot,
1620
+ startup.completedLocalIndexSignatures,
1621
+ )
1622
+ if (
1623
+ startup.appliedCursor !== undefined &&
1624
+ lifecycleGeneration === this.lifecycleGeneration
1625
+ ) {
1626
+ await this.waitForAppliedReceiptsAfter(startup.appliedCursor)
1627
+ }
1628
+ }
1629
+ if (lifecycleGeneration === this.lifecycleGeneration) {
1630
+ this.startupSettled = true
1631
+ this.persistedReadiness?.set({ status: `ready` })
1632
+ }
1633
+ resolveStartupMetadata()
1634
+ })().catch((error) => {
1635
+ rejectStartupMetadata(error)
1636
+ throw this.markTerminalFailure(error, lifecycleGeneration)
1637
+ })
1638
+ void this.startPromise.catch(() => undefined)
915
1639
  return this.startPromise
916
1640
  }
917
1641
 
918
- async ensureStartupMetadataLoaded(): Promise<void> {
919
- if (this.startupMetadataPromise) {
920
- return this.startupMetadataPromise
1642
+ ensureResumeBaselineHydrated(): Promise<void> {
1643
+ this.throwIfTerminal()
1644
+ if (this.resumeBaselinePromise) {
1645
+ return this.resumeBaselinePromise
921
1646
  }
922
1647
 
923
- this.startupMetadataPromise = this.loadStartupMetadataInternal()
924
- return this.startupMetadataPromise
1648
+ const lifecycleGeneration = this.lifecycleGeneration
1649
+ this.resumeBaselinePromise = (async () => {
1650
+ await this.ensureStarted()
1651
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1652
+ if (this.syncMode !== `on-demand`) return
1653
+
1654
+ const appliedCursor = await this.applyMutex.run(async () => {
1655
+ const result = await this.runInHydrationScope((adapter) =>
1656
+ this.hydrateBaseline(lifecycleGeneration, adapter),
1657
+ )
1658
+ if (lifecycleGeneration === this.lifecycleGeneration) {
1659
+ await this.flushQueuedTxCommittedUnsafe()
1660
+ }
1661
+ return result
1662
+ })
1663
+ if (
1664
+ appliedCursor !== undefined &&
1665
+ lifecycleGeneration === this.lifecycleGeneration
1666
+ ) {
1667
+ await this.waitForAppliedReceiptsAfter(appliedCursor)
1668
+ }
1669
+ })().catch((error) => {
1670
+ throw this.markTerminalFailure(error, lifecycleGeneration)
1671
+ })
1672
+ return this.resumeBaselinePromise
925
1673
  }
926
1674
 
927
- private async startInternal(): Promise<void> {
928
- if (this.started) {
929
- return
1675
+ ensureResumeBaselineCertified(): Promise<void> {
1676
+ if (this.resumeCertificationPromise) {
1677
+ return this.resumeCertificationPromise
930
1678
  }
931
1679
 
932
- this.started = true
1680
+ const lifecycleGeneration = this.lifecycleGeneration
1681
+ this.resumeCertificationPromise = (async () => {
1682
+ await this.ensureStarted()
1683
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
933
1684
 
934
- await this.ensureStartupMetadataLoaded()
1685
+ const snapshot = await this.persistence.adapter.loadResumeSnapshot(
1686
+ this.collectionId,
1687
+ {
1688
+ requiredIndexSignatures: this.getRequiredIndexSignatures(),
1689
+ includeRows: false,
1690
+ },
1691
+ )
1692
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1693
+ this.bindResumeSnapshotEvidence(snapshot)
1694
+ })()
1695
+ return this.resumeCertificationPromise
1696
+ }
935
1697
 
936
- const indexBootstrapSnapshot = this.collection?.getIndexMetadata() ?? []
937
- this.attachIndexLifecycleListeners()
938
- await this.bootstrapPersistedIndexes(indexBootstrapSnapshot)
1698
+ getKeySetEvidence(): PersistedKeySetEvidence | undefined {
1699
+ return this.persistedKeySetEvidence
1700
+ }
939
1701
 
940
- if (this.syncMode !== `on-demand`) {
941
- this.activeSubsets.set(this.getSubsetKey({}), {})
942
- const appliedCursor = this.appliedReceiptSequence
943
- await this.applyMutex.run(() =>
944
- this.hydrateSubsetUnsafe({}, { requestRemoteEnsure: false }),
945
- )
946
- await this.waitForAppliedReceiptsAfter(appliedCursor)
947
- }
1702
+ getResumeGenerationOwner(): symbol {
1703
+ return this.resumeGenerationOwner
948
1704
  }
949
1705
 
950
- private async loadStartupMetadataInternal(): Promise<void> {
951
- // Restore stream position from the database so that new mutations
952
- // don't collide with previously applied transactions.
953
- if (this.persistence.adapter.getStreamPosition) {
954
- const position = await this.persistence.adapter.getStreamPosition(
955
- this.collectionId,
956
- )
957
- this.observeStreamPosition(
958
- position.latestTerm,
959
- position.latestSeq,
960
- position.latestRowVersion,
961
- )
962
- }
1706
+ private async hydrateBaseline(
1707
+ lifecycleGeneration: number,
1708
+ adapter: HydrationPersistenceAdapter,
1709
+ ): Promise<number | undefined> {
1710
+ if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
963
1711
 
964
- await this.loadCollectionMetadataIntoCollection()
1712
+ // The baseline shares the unconstrained demand key. Its lease is never
1713
+ // released, and every reload rereads it, so that coverage stays valid.
1714
+ const baseline = {}
1715
+ this.activeSubsets.set(this.getSubsetKey(baseline), baseline)
1716
+ const appliedCursor = this.appliedReceiptSequence
1717
+ await this.hydrateSubsetUnsafe(
1718
+ baseline,
1719
+ {
1720
+ requestRemoteEnsure: false,
1721
+ lifecycleGeneration,
1722
+ bindKeySetEvidence: true,
1723
+ },
1724
+ adapter,
1725
+ )
1726
+ return lifecycleGeneration === this.lifecycleGeneration
1727
+ ? appliedCursor
1728
+ : undefined
965
1729
  }
966
1730
 
967
- private async loadCollectionMetadataIntoCollection(): Promise<void> {
968
- const collectionMetadata = await this.loadCollectionMetadataSnapshot()
969
- this.replaceCollectionMetadataSnapshot(collectionMetadata)
1731
+ async ensureStartupMetadataLoaded(): Promise<void> {
1732
+ this.throwIfTerminal()
1733
+ if (this.startupMetadataPromise) {
1734
+ return this.startupMetadataPromise
1735
+ }
1736
+
1737
+ void this.ensureStarted()
1738
+ return this.startupMetadataPromise!
970
1739
  }
971
1740
 
972
- private async loadCollectionMetadataSnapshot(): Promise<
973
- Array<{ key: string; value: unknown }>
1741
+ private async startInternal(
1742
+ lifecycleGeneration: number,
1743
+ adapter: HydrationPersistenceAdapter,
1744
+ onStartupMetadataLoaded: () => void,
1745
+ ): Promise<
1746
+ | {
1747
+ appliedCursor: number | undefined
1748
+ indexBootstrapSnapshot: Array<CollectionIndexMetadata>
1749
+ completedLocalIndexSignatures: Set<string>
1750
+ }
1751
+ | undefined
974
1752
  > {
975
- if (!this.persistence.adapter.loadCollectionMetadata) {
1753
+ if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
1754
+
1755
+ const indexBootstrapSnapshot = this.collection?.getIndexMetadata() ?? []
1756
+ this.attachIndexLifecycleListeners()
1757
+ const completedLocalIndexSignatures = await this.bootstrapPersistedIndexes(
1758
+ indexBootstrapSnapshot,
1759
+ adapter,
1760
+ )
1761
+ if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
1762
+
1763
+ // Let the source run only once the startup hydrate is about to begin.
1764
+ // Its first transaction must bind to that hydrate's sequence, not the
1765
+ // sequence before index bootstrap yielded.
1766
+ onStartupMetadataLoaded()
1767
+ const appliedCursor =
1768
+ this.syncMode !== `on-demand`
1769
+ ? await this.hydrateBaseline(lifecycleGeneration, adapter)
1770
+ : undefined
1771
+ return lifecycleGeneration === this.lifecycleGeneration
1772
+ ? {
1773
+ appliedCursor,
1774
+ indexBootstrapSnapshot,
1775
+ completedLocalIndexSignatures,
1776
+ }
1777
+ : undefined
1778
+ }
1779
+
1780
+ private async loadStartupMetadataInternal(
1781
+ lifecycleGeneration: number,
1782
+ adapter: HydrationPersistenceAdapter,
1783
+ ): Promise<void> {
1784
+ const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
1785
+ includeRows: false,
1786
+ })
1787
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1788
+ this.persistedResumeGeneration = this.getResumeSnapshotGeneration(snapshot)
1789
+ this.persistedKeySetEvidence = snapshot.keySet
1790
+ this.observeStreamPosition(
1791
+ snapshot.latestTerm,
1792
+ snapshot.latestSeq,
1793
+ snapshot.latestRowVersion,
1794
+ )
1795
+ const applied = this.replaceCollectionMetadataSnapshot(
1796
+ snapshot.collectionMetadata,
1797
+ )
1798
+ if (applied !== true) await applied
1799
+ }
1800
+
1801
+ private async loadCollectionMetadataSnapshot(
1802
+ adapter: HydrationPersistenceAdapter,
1803
+ ): Promise<Array<{ key: string; value: unknown }>> {
1804
+ if (!adapter.loadCollectionMetadata) {
976
1805
  return []
977
1806
  }
978
1807
 
979
- return this.persistence.adapter.loadCollectionMetadata(this.collectionId)
1808
+ return adapter.loadCollectionMetadata(this.collectionId)
980
1809
  }
981
1810
 
982
1811
  private replaceCollectionMetadataSnapshot(
983
1812
  collectionMetadata: Array<{ key: string; value: unknown }>,
984
- ): void {
1813
+ ): SyncAppliedReceipt {
985
1814
  if (
986
1815
  !this.syncControls.begin ||
987
1816
  !this.syncControls.commit ||
988
1817
  !this.syncControls.metadata
989
1818
  ) {
990
- return
1819
+ return true
991
1820
  }
992
1821
 
993
1822
  const nextMetadata = new Map(
@@ -997,7 +1826,7 @@ class PersistedCollectionRuntime<
997
1826
  .list()
998
1827
  .map(({ key }) => key)
999
1828
 
1000
- this.withInternalApply(() => {
1829
+ return this.withInternalApply(() => {
1001
1830
  this.syncControls.begin?.({ immediate: true })
1002
1831
 
1003
1832
  currentKeys.forEach((key) => {
@@ -1010,7 +1839,7 @@ class PersistedCollectionRuntime<
1010
1839
  this.syncControls.metadata?.collection.set(key, value)
1011
1840
  })
1012
1841
 
1013
- this.syncControls.commit?.()
1842
+ return this.syncControls.commit?.() ?? true
1014
1843
  })
1015
1844
  }
1016
1845
 
@@ -1018,19 +1847,62 @@ class PersistedCollectionRuntime<
1018
1847
  options: LoadSubsetOptions,
1019
1848
  upstreamLoadSubset?: LoadSubsetFn,
1020
1849
  ): Promise<void> {
1021
- this.activeSubsets.set(this.getSubsetKey(options), options)
1022
-
1850
+ this.throwIfTerminal()
1851
+ const lifecycleGeneration = this.lifecycleGeneration
1852
+ const subsetKey = this.getSubsetKey(options)
1853
+ const truncateGeneration = this.sourceTruncateGeneration
1854
+ const routeRemoteDemandDuringHydration =
1855
+ this.canRouteRemoteDemandThroughCoordinator()
1856
+ this.activeSubsets.set(subsetKey, options)
1023
1857
  const appliedCursor = this.appliedReceiptSequence
1024
- await this.applyMutex.run(() =>
1025
- this.hydrateSubsetUnsafe(options, {
1026
- requestRemoteEnsure: this.mode === `sync-present`,
1027
- }),
1028
- )
1029
- await this.waitForAppliedReceiptsAfter(appliedCursor)
1858
+ try {
1859
+ await this.applyMutex.run(async () => {
1860
+ try {
1861
+ await this.runInHydrationScope((adapter) =>
1862
+ this.hydrateSubsetUnsafe(
1863
+ options,
1864
+ {
1865
+ requestRemoteEnsure:
1866
+ this.mode === `sync-present` &&
1867
+ !routeRemoteDemandDuringHydration,
1868
+ lifecycleGeneration,
1869
+ requestLocalLoadFailure: true,
1870
+ rejectBufferedReplayFailure: true,
1871
+ },
1872
+ adapter,
1873
+ ),
1874
+ )
1875
+ } finally {
1876
+ if (
1877
+ lifecycleGeneration === this.lifecycleGeneration &&
1878
+ !this.getCurrentTerminalFailure()
1879
+ ) {
1880
+ await this.flushQueuedTxCommittedUnsafe()
1881
+ }
1882
+ }
1883
+ })
1884
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1885
+ await this.waitForAppliedReceiptsAfter(appliedCursor)
1886
+ } catch (error) {
1887
+ if (this.activeSubsets.get(subsetKey) === options) {
1888
+ this.activeSubsets.delete(subsetKey)
1889
+ }
1890
+ throw error
1891
+ }
1030
1892
 
1031
- if (upstreamLoadSubset) {
1893
+ if (
1894
+ options.signal?.aborted ||
1895
+ this.activeSubsets.get(this.getSubsetKey(options)) !== options
1896
+ ) {
1897
+ return
1898
+ }
1899
+
1900
+ if (this.canRouteRemoteDemandThroughCoordinator()) {
1032
1901
  try {
1033
- await upstreamLoadSubset(options)
1902
+ await this.persistence.coordinator.requestEnsureRemoteSubset(
1903
+ this.collectionId,
1904
+ options,
1905
+ )
1034
1906
  } catch (error) {
1035
1907
  if (
1036
1908
  options.signal?.aborted ||
@@ -1042,40 +1914,177 @@ class PersistedCollectionRuntime<
1042
1914
  this.pendingRemoteSubsetEnsures.delete(this.getSubsetKey(options))
1043
1915
  throw error
1044
1916
  }
1045
- console.warn(`Failed to trigger remote subset load:`, error)
1046
1917
  this.queueRemoteSubsetEnsure(options)
1918
+ throw error
1919
+ }
1920
+ return
1921
+ }
1922
+
1923
+ if (
1924
+ truncateGeneration === this.sourceTruncateGeneration &&
1925
+ this.activeSubsets.get(subsetKey) === options
1926
+ ) {
1927
+ this.hydratedDemands.add(getLoadSubsetDemandKey(options))
1928
+ }
1929
+
1930
+ if (upstreamLoadSubset) {
1931
+ try {
1932
+ await upstreamLoadSubset(options)
1933
+ } catch (error) {
1934
+ this.noteUpstreamLoadFailure(options, error)
1935
+ throw error
1047
1936
  }
1048
1937
  }
1049
1938
  }
1050
1939
 
1940
+ loadHydratedSubset(
1941
+ options: LoadSubsetOptions,
1942
+ upstreamLoadSubset?: LoadSubsetFn,
1943
+ ): true | Promise<void> | undefined {
1944
+ const failure = this.getCurrentTerminalFailure()
1945
+ if (failure) return Promise.reject(failure.error)
1946
+ if (
1947
+ !this.startupSettled ||
1948
+ this.isHydratingNow() ||
1949
+ this.canRouteRemoteDemandThroughCoordinator() ||
1950
+ options.refetch === true ||
1951
+ !this.hydratedDemands.has(getLoadSubsetDemandKey(options))
1952
+ ) {
1953
+ return
1954
+ }
1955
+
1956
+ this.activeSubsets.set(this.getSubsetKey(options), options)
1957
+ let result: true | Promise<void>
1958
+ try {
1959
+ result = upstreamLoadSubset?.(options) ?? true
1960
+ } catch (error) {
1961
+ this.noteUpstreamLoadFailure(options, error)
1962
+ return Promise.reject(error)
1963
+ }
1964
+ if (result === true) {
1965
+ return true
1966
+ }
1967
+
1968
+ return result.then(
1969
+ () => {
1970
+ this.queueRemoteSubsetEnsure(options)
1971
+ },
1972
+ (error: unknown) => {
1973
+ this.noteUpstreamLoadFailure(options, error)
1974
+ throw error
1975
+ },
1976
+ )
1977
+ }
1978
+
1979
+ private noteUpstreamLoadFailure(
1980
+ options: LoadSubsetOptions,
1981
+ error: unknown,
1982
+ ): void {
1983
+ if (
1984
+ options.signal?.aborted ||
1985
+ (typeof error === `object` &&
1986
+ error !== null &&
1987
+ `name` in error &&
1988
+ error.name === `AbortError`)
1989
+ ) {
1990
+ this.pendingRemoteSubsetEnsures.delete(this.getSubsetKey(options))
1991
+ return
1992
+ }
1993
+ console.warn(`Failed to trigger remote subset load:`, error)
1994
+ this.queueRemoteSubsetEnsure(options)
1995
+ }
1996
+
1997
+ noteSourceTruncate(): void {
1998
+ this.sourceTruncateGeneration++
1999
+ this.hydratedDemands.clear()
2000
+ }
2001
+
1051
2002
  unloadSubset(
1052
2003
  options: LoadSubsetOptions,
1053
2004
  upstreamUnloadSubset?: (options: LoadSubsetOptions) => void,
1054
2005
  ): void {
1055
- this.activeSubsets.delete(this.getSubsetKey(options))
1056
- this.pendingRemoteSubsetEnsures.delete(this.getSubsetKey(options))
1057
- upstreamUnloadSubset?.(options)
2006
+ const subsetKey = this.getSubsetKey(options)
2007
+ this.activeSubsets.delete(subsetKey)
2008
+ this.pendingRemoteSubsetEnsures.delete(subsetKey)
2009
+ const demandKey = getLoadSubsetDemandKey(options)
2010
+ const stillActive = Array.from(this.activeSubsets.values()).some(
2011
+ (active) => getLoadSubsetDemandKey(active) === demandKey,
2012
+ )
2013
+ if (!stillActive) this.hydratedDemands.delete(demandKey)
2014
+ if (this.mode === `sync-present`) {
2015
+ void this.persistence.coordinator
2016
+ .requestReleaseRemoteSubset(this.collectionId, options)
2017
+ .catch((error) => {
2018
+ this.reportSyncError(error)
2019
+ })
2020
+ }
2021
+ if (upstreamUnloadSubset) {
2022
+ try {
2023
+ const result = (
2024
+ upstreamUnloadSubset as unknown as (
2025
+ options: LoadSubsetOptions,
2026
+ ) => unknown
2027
+ )(options)
2028
+ void Promise.resolve(result).catch((error) => {
2029
+ this.reportSyncError(error)
2030
+ })
2031
+ } catch (error) {
2032
+ this.reportSyncError(error)
2033
+ }
2034
+ }
1058
2035
  }
1059
2036
 
1060
2037
  async forceReloadSubset(options: LoadSubsetOptions): Promise<void> {
1061
- this.activeSubsets.set(this.getSubsetKey(options), options)
1062
- await this.applyMutex.run(() =>
1063
- this.hydrateSubsetUnsafe(options, { requestRemoteEnsure: false }),
1064
- )
2038
+ this.throwIfTerminal()
2039
+ const lifecycleGeneration = this.lifecycleGeneration
2040
+ // A one-shot refresh does not acquire an enduring subscription lease.
2041
+ await this.applyMutex.run(async () => {
2042
+ await this.runInHydrationScope((adapter) =>
2043
+ this.hydrateSubsetUnsafe(
2044
+ options,
2045
+ {
2046
+ requestRemoteEnsure: false,
2047
+ lifecycleGeneration,
2048
+ rejectBufferedReplayFailure: true,
2049
+ },
2050
+ adapter,
2051
+ ),
2052
+ )
2053
+ if (lifecycleGeneration === this.lifecycleGeneration) {
2054
+ await this.flushQueuedTxCommittedUnsafe()
2055
+ }
2056
+ })
1065
2057
  }
1066
2058
 
1067
2059
  queueHydrationBufferedTransaction(
1068
2060
  transaction: BufferedSyncTransaction<T, TKey>,
1069
2061
  ): void {
2062
+ const failure = this.getCurrentTerminalFailure()
2063
+ if (failure) {
2064
+ transaction.rejectApplied?.(failure.error)
2065
+ return
2066
+ }
1070
2067
  this.queuedHydrationTransactions.push(transaction)
1071
2068
  }
1072
2069
 
1073
- async persistAndBroadcastExternalSyncTransaction(
2070
+ applyHydrationBufferedTransaction(
1074
2071
  transaction: BufferedSyncTransaction<T, TKey>,
1075
2072
  ): Promise<void> {
1076
- await this.applyMutex.run(() =>
1077
- this.persistAndBroadcastExternalSyncTransactionUnsafe(transaction),
1078
- )
2073
+ const failure = this.getCurrentTerminalFailure()
2074
+ if (failure) return Promise.reject(failure.error)
2075
+ if (
2076
+ transaction.beginOptions?.immediate &&
2077
+ this.sourcePublicationWaitDepth > 0
2078
+ ) {
2079
+ return Promise.reject(
2080
+ new InvalidPersistedCollectionConfigError(
2081
+ `immediate persisted source replay cannot enter while an earlier source publication is waiting`,
2082
+ ),
2083
+ )
2084
+ }
2085
+ return this.applyMutex.run(async () => {
2086
+ await this.applyBufferedSyncTransactionUnsafe(transaction)
2087
+ })
1079
2088
  }
1080
2089
 
1081
2090
  normalizeSyncWriteMessage(
@@ -1092,10 +2101,6 @@ class PersistedCollectionRuntime<
1092
2101
  const previousValue = this.collection.get(key) ?? ({} as T)
1093
2102
 
1094
2103
  return {
1095
- forwardMessage: {
1096
- type: `delete`,
1097
- key,
1098
- },
1099
2104
  operation: {
1100
2105
  type: `delete`,
1101
2106
  key,
@@ -1111,10 +2116,6 @@ class PersistedCollectionRuntime<
1111
2116
  const previousValue = this.collection.get(key) ?? message.value
1112
2117
 
1113
2118
  return {
1114
- forwardMessage: {
1115
- type: `delete`,
1116
- key,
1117
- },
1118
2119
  operation: {
1119
2120
  type: `delete`,
1120
2121
  key,
@@ -1125,11 +2126,6 @@ class PersistedCollectionRuntime<
1125
2126
 
1126
2127
  const key = this.collection.getKeyFromItem(message.value)
1127
2128
  return {
1128
- forwardMessage: {
1129
- type: `update`,
1130
- value: message.value,
1131
- metadata: message.metadata,
1132
- },
1133
2129
  operation: {
1134
2130
  type: `update`,
1135
2131
  key,
@@ -1141,32 +2137,55 @@ class PersistedCollectionRuntime<
1141
2137
 
1142
2138
  async persistAndConfirmCollectionMutations(
1143
2139
  mutations: Array<PendingMutation<T>>,
2140
+ lifecycleGeneration = this.lifecycleGeneration,
1144
2141
  ): Promise<void> {
2142
+ this.throwIfTerminal()
2143
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
1145
2144
  if (mutations.length === 0) {
1146
2145
  return
1147
2146
  }
1148
2147
 
1149
- await this.applyMutex.run(async () => {
1150
- const acceptedMutationIds =
1151
- await this.persistCollectionMutationsUnsafe(mutations)
1152
- const acceptedMutationIdSet = new Set(acceptedMutationIds)
1153
- const acceptedMutations = mutations.filter((mutation) =>
1154
- acceptedMutationIdSet.has(mutation.mutationId),
1155
- )
1156
-
1157
- if (acceptedMutations.length !== mutations.length) {
1158
- throw new Error(
1159
- `persistence coordinator accepted ${acceptedMutations.length} of ${mutations.length} mutations; partial acceptance is not supported`,
2148
+ try {
2149
+ await this.applyMutex.run(async () => {
2150
+ // Startup metadata establishes the durable term/sequence boundary. A
2151
+ // mutation admitted before it resolves must wait rather than allocate a
2152
+ // default position that can collide with an already-applied transaction.
2153
+ await this.ensureStartupMetadataLoaded()
2154
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
2155
+ const acceptedMutationIds = await this.persistCollectionMutationsUnsafe(
2156
+ mutations,
2157
+ lifecycleGeneration,
2158
+ )
2159
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
2160
+ const acceptedMutationIdSet = new Set(acceptedMutationIds)
2161
+ const acceptedMutations = mutations.filter((mutation) =>
2162
+ acceptedMutationIdSet.has(mutation.mutationId),
1160
2163
  )
1161
- }
1162
2164
 
1163
- this.confirmMutationsSyncUnsafe(acceptedMutations)
1164
- })
2165
+ if (acceptedMutations.length !== mutations.length) {
2166
+ throw new Error(
2167
+ `persistence coordinator accepted ${acceptedMutations.length} of ${mutations.length} mutations; partial acceptance is not supported`,
2168
+ )
2169
+ }
2170
+
2171
+ try {
2172
+ await this.confirmMutationsSyncUnsafe(acceptedMutations)
2173
+ } catch (error) {
2174
+ throw this.markTerminalFailure(error, lifecycleGeneration)
2175
+ }
2176
+ })
2177
+ } catch (error) {
2178
+ if (error instanceof PersistedCollectionDurabilityError) {
2179
+ throw this.markTerminalFailure(error, lifecycleGeneration)
2180
+ }
2181
+ throw error
2182
+ }
1165
2183
  }
1166
2184
 
1167
2185
  async acceptTransactionMutations(transaction: {
1168
2186
  mutations: Array<PendingMutation<Record<string, unknown>>>
1169
2187
  }): Promise<void> {
2188
+ this.throwIfTerminal()
1170
2189
  const collectionMutations = this.filterMutationsForCollection(
1171
2190
  transaction.mutations,
1172
2191
  )
@@ -1178,14 +2197,42 @@ class PersistedCollectionRuntime<
1178
2197
  await this.persistAndConfirmCollectionMutations(collectionMutations)
1179
2198
  }
1180
2199
 
1181
- cleanup(): void {
1182
- this.coordinatorUnsubscribe?.()
2200
+ cleanup(): Promise<void> {
2201
+ this.advanceLifecycle()
2202
+ const remoteReleases: Array<Promise<void>> = []
2203
+ const failures: Array<unknown> = []
2204
+ const attempt = (callback: () => void): void => {
2205
+ try {
2206
+ callback()
2207
+ } catch (error) {
2208
+ failures.push(error)
2209
+ }
2210
+ }
2211
+
2212
+ if (this.mode === `sync-present`) {
2213
+ for (const options of this.activeSubsets.values()) {
2214
+ attempt(() => {
2215
+ remoteReleases.push(
2216
+ this.persistence.coordinator
2217
+ .requestReleaseRemoteSubset(this.collectionId, options)
2218
+ .catch((error) => {
2219
+ this.reportSyncError(error)
2220
+ }),
2221
+ )
2222
+ })
2223
+ }
2224
+ }
2225
+
2226
+ attempt(() => this.coordinatorUnsubscribe?.())
1183
2227
  this.coordinatorUnsubscribe = null
1184
2228
 
1185
- this.indexAddedUnsubscribe?.()
2229
+ attempt(() => this.remoteSubsetOwnerUnsubscribe?.())
2230
+ this.remoteSubsetOwnerUnsubscribe = null
2231
+
2232
+ attempt(() => this.indexAddedUnsubscribe?.())
1186
2233
  this.indexAddedUnsubscribe = null
1187
2234
 
1188
- this.indexRemovedUnsubscribe?.()
2235
+ attempt(() => this.indexRemovedUnsubscribe?.())
1189
2236
  this.indexRemovedUnsubscribe = null
1190
2237
 
1191
2238
  if (this.remoteEnsureRetryTimer !== null) {
@@ -1195,12 +2242,37 @@ class PersistedCollectionRuntime<
1195
2242
 
1196
2243
  this.pendingRemoteSubsetEnsures.clear()
1197
2244
  this.activeSubsets.clear()
2245
+ this.hydratedDemands.clear()
1198
2246
  for (const transaction of this.queuedHydrationTransactions) {
1199
2247
  transaction.rejectApplied?.(new SyncTransactionAbortedError())
1200
2248
  }
1201
2249
  this.queuedHydrationTransactions.length = 0
1202
2250
  this.queuedTxCommitted.length = 0
1203
2251
  this.clearSyncControls()
2252
+ this.collection = null
2253
+ return Promise.all(remoteReleases).then(() => {
2254
+ if (failures.length === 1) throw failures[0]
2255
+ if (failures.length > 1) {
2256
+ throw new AggregateError(failures, `Persistence cleanup failed`, {
2257
+ cause: failures[0],
2258
+ })
2259
+ }
2260
+ })
2261
+ }
2262
+
2263
+ private advanceLifecycle(): void {
2264
+ this.lifecycleGeneration++
2265
+ this.persistedReadiness?.set({ status: `loading` })
2266
+ this.startupSettled = false
2267
+ this.hydratedDemands.clear()
2268
+ this.startupMetadataPromise = null
2269
+ this.startPromise = null
2270
+ this.resumeBaselinePromise = null
2271
+ this.terminalFailure = undefined
2272
+ this.resumeCertificationPromise = null
2273
+ this.persistedKeySetEvidence = undefined
2274
+ this.persistedResumeGeneration = undefined
2275
+ this.resumeGenerationOwner = Symbol(`persisted resume generation owner`)
1204
2276
  }
1205
2277
 
1206
2278
  private withInternalApply<TResult>(task: () => TResult): TResult {
@@ -1224,8 +2296,9 @@ class PersistedCollectionRuntime<
1224
2296
 
1225
2297
  private loadSubsetRowsUnsafe(
1226
2298
  options: LoadSubsetOptions,
2299
+ adapter: HydrationPersistenceAdapter,
1227
2300
  ): Promise<Array<{ key: TKey; value: T; metadata?: unknown }>> {
1228
- return this.persistence.adapter.loadSubset(this.collectionId, options, {
2301
+ return adapter.loadSubset(this.collectionId, options, {
1229
2302
  requiredIndexSignatures: this.getRequiredIndexSignatures(),
1230
2303
  }) as Promise<Array<{ key: TKey; value: T; metadata?: unknown }>>
1231
2304
  }
@@ -1246,6 +2319,7 @@ class PersistedCollectionRuntime<
1246
2319
  async scanPersistedRows(
1247
2320
  options?: PersistedRowScanOptions,
1248
2321
  ): Promise<Array<PersistedScannedRow<T, TKey>>> {
2322
+ this.throwIfTerminal()
1249
2323
  return this.applyMutex.run(() => this.scanPersistedRowsUnsafe(options))
1250
2324
  }
1251
2325
 
@@ -1253,37 +2327,249 @@ class PersistedCollectionRuntime<
1253
2327
  options: LoadSubsetOptions,
1254
2328
  config: {
1255
2329
  requestRemoteEnsure: boolean
2330
+ lifecycleGeneration: number
2331
+ bindKeySetEvidence?: boolean
2332
+ requestLocalLoadFailure?: boolean
2333
+ rejectBufferedReplayFailure?: boolean
1256
2334
  },
2335
+ adapter: HydrationPersistenceAdapter,
1257
2336
  ): Promise<void> {
1258
- this.isHydrating = true
2337
+ let rowsLoaded = false
2338
+ let replayFailure: { reason: unknown } | undefined
2339
+ const resetSequence = this.resetSequence
2340
+ this.hydrationSequence++
2341
+ const hydrationContext = { suppliedRowKeys: new Set<TKey>() }
2342
+ this.activeHydrationContext = hydrationContext
1259
2343
  try {
1260
- const rows = await this.loadSubsetRowsUnsafe(options)
2344
+ this.throwIfTerminal()
2345
+ this.hydratingGeneration = config.lifecycleGeneration
2346
+ try {
2347
+ let rows: Array<{ key: TKey; value: T; metadata?: unknown }>
2348
+ if (config.bindKeySetEvidence) {
2349
+ const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
2350
+ requiredIndexSignatures: this.getRequiredIndexSignatures(),
2351
+ includeRows: true,
2352
+ })
2353
+ rows = snapshot.rows as Array<{
2354
+ key: TKey
2355
+ value: T
2356
+ metadata?: unknown
2357
+ }>
2358
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2359
+ if (resetSequence !== this.resetSequence) return
2360
+ this.bindResumeSnapshotEvidence(snapshot)
2361
+ } else {
2362
+ rows = await this.loadSubsetRowsUnsafe(options, adapter)
2363
+ }
2364
+ rowsLoaded = true
2365
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2366
+ if (resetSequence !== this.resetSequence) return
2367
+
2368
+ if (
2369
+ !config.bindKeySetEvidence ||
2370
+ this.persistedKeySetEvidence?.status !== `incompatible`
2371
+ ) {
2372
+ for (const row of rows) {
2373
+ hydrationContext.suppliedRowKeys.add(row.key)
2374
+ }
2375
+ const applied = this.applyRowsToCollection(rows)
2376
+ if (applied !== true) await applied
2377
+ }
2378
+ } finally {
2379
+ if (
2380
+ this.activeHydrationContext === hydrationContext &&
2381
+ this.hydratingGeneration === config.lifecycleGeneration
2382
+ ) {
2383
+ this.hydratingGeneration = null
2384
+ }
2385
+ }
1261
2386
 
1262
- this.applyRowsToCollection(rows)
2387
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2388
+ replayFailure = await this.flushQueuedHydrationTransactionsUnsafe(adapter)
2389
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2390
+
2391
+ if (config.requestRemoteEnsure && !replayFailure) {
2392
+ this.queueRemoteSubsetEnsure(options)
2393
+ }
2394
+ } catch (error) {
2395
+ if (config.requestLocalLoadFailure && !rowsLoaded) {
2396
+ // Keep admitting source work to the hydration queue while recovery
2397
+ // reconstructs any persisted baseline required by partial updates.
2398
+ // The failed subset itself owns no rows, so recovery must evaluate
2399
+ // each queued transaction against the durable snapshot at its exact
2400
+ // FIFO cut instead of treating the snapshot as a successful load.
2401
+ this.hydratingGeneration = config.lifecycleGeneration
2402
+ try {
2403
+ await this.recoverBufferedTransactionsAfterLocalLoadFailureUnsafe(
2404
+ config.lifecycleGeneration,
2405
+ adapter,
2406
+ )
2407
+ } catch (baselineError) {
2408
+ const terminalError = this.markTerminalFailure(
2409
+ baselineError,
2410
+ config.lifecycleGeneration,
2411
+ )
2412
+ throw terminalError
2413
+ } finally {
2414
+ if (this.hydratingGeneration === config.lifecycleGeneration) {
2415
+ this.hydratingGeneration = null
2416
+ }
2417
+ }
2418
+ throw error
2419
+ }
2420
+ throw this.markTerminalFailure(error, config.lifecycleGeneration)
1263
2421
  } finally {
1264
- this.isHydrating = false
2422
+ if (this.activeHydrationContext === hydrationContext) {
2423
+ this.activeHydrationContext = undefined
2424
+ }
2425
+ }
2426
+ if (replayFailure && config.rejectBufferedReplayFailure) {
2427
+ throw replayFailure.reason
1265
2428
  }
2429
+ }
2430
+
2431
+ private async recoverBufferedTransactionsAfterLocalLoadFailureUnsafe(
2432
+ lifecycleGeneration: number,
2433
+ adapter: HydrationPersistenceAdapter,
2434
+ ): Promise<void> {
2435
+ let snapshotRows:
2436
+ | Map<TKey, { key: TKey; value: T; metadata?: unknown }>
2437
+ | undefined
2438
+ type RecoveryPresence = `present` | `absent` | `unknown`
2439
+ const recoveredPresence = new Map<TKey, RecoveryPresence>()
2440
+ let snapshotInvalidatedByTruncate = false
2441
+
2442
+ while (this.queuedHydrationTransactions.length > 0) {
2443
+ const transaction = this.queuedHydrationTransactions.shift()
2444
+ if (!transaction) continue
2445
+
2446
+ try {
2447
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
2448
+ const baselineRows = new Map<
2449
+ TKey,
2450
+ { key: TKey; value: T; metadata?: unknown }
2451
+ >()
2452
+ const transactionPresence = new Map<TKey, RecoveryPresence>()
2453
+ const transactionStartsWithTruncate = transaction.truncate
2454
+ const getPresence = (key: TKey): RecoveryPresence => {
2455
+ const known = transactionPresence.get(key)
2456
+ if (known !== undefined) return known
2457
+ if (transactionStartsWithTruncate) {
2458
+ transactionPresence.set(key, `absent`)
2459
+ return `absent`
2460
+ }
2461
+ const recovered = recoveredPresence.get(key)
2462
+ if (recovered !== undefined) {
2463
+ transactionPresence.set(key, recovered)
2464
+ return recovered
2465
+ }
2466
+ const present =
2467
+ this.collection?._hasHydratedKey(key) === true
2468
+ ? `present`
2469
+ : snapshotInvalidatedByTruncate
2470
+ ? `absent`
2471
+ : `unknown`
2472
+ transactionPresence.set(key, present)
2473
+ return present
2474
+ }
2475
+
2476
+ if (!transaction.signal?.aborted) {
2477
+ for (const [index, operation] of transaction.operations.entries()) {
2478
+ const key = operation.key
2479
+ if (
2480
+ operation.type === `update` &&
2481
+ transaction.partialUpdateOperationIndexes.has(index) &&
2482
+ getPresence(key) === `unknown`
2483
+ ) {
2484
+ if (snapshotRows === undefined) {
2485
+ const snapshot = await adapter.loadResumeSnapshot(
2486
+ this.collectionId,
2487
+ {
2488
+ requiredIndexSignatures: this.getRequiredIndexSignatures(),
2489
+ includeRows: true,
2490
+ },
2491
+ )
2492
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
2493
+ if (transaction.signal?.aborted) break
2494
+ snapshotRows = new Map(
2495
+ (
2496
+ snapshot.rows as Array<{
2497
+ key: TKey
2498
+ value: T
2499
+ metadata?: unknown
2500
+ }>
2501
+ ).map((row) => [row.key, row]),
2502
+ )
2503
+ }
2504
+ const baseline = snapshotRows.get(key)
2505
+ if (baseline === undefined) {
2506
+ transactionPresence.set(key, `absent`)
2507
+ } else {
2508
+ baselineRows.set(key, baseline)
2509
+ transactionPresence.set(key, `present`)
2510
+ }
2511
+ }
2512
+
2513
+ transactionPresence.set(
2514
+ key,
2515
+ operation.type === `delete` ? `absent` : `present`,
2516
+ )
2517
+ }
2518
+
2519
+ if (baselineRows.size > 0) {
2520
+ // Fold the durable baseline into the dependent reservation. It
2521
+ // must never become a separately observable publication: the
2522
+ // partial source value and its unseen fields are one atomic row.
2523
+ if (!transaction.signal?.aborted) {
2524
+ transaction.prependHydrationRows([...baselineRows.values()])
2525
+ }
2526
+ }
2527
+ }
1266
2528
 
1267
- await this.flushQueuedHydrationTransactionsUnsafe()
1268
- await this.flushQueuedTxCommittedUnsafe()
2529
+ const transactionOutcome =
2530
+ await this.applyBufferedSyncTransactionUnsafe(transaction, adapter)
2531
+ if (transactionOutcome.applied) {
2532
+ if (transaction.truncate) {
2533
+ snapshotInvalidatedByTruncate = true
2534
+ recoveredPresence.clear()
2535
+ }
2536
+ for (const operation of transaction.operations) {
2537
+ recoveredPresence.set(
2538
+ operation.key,
2539
+ operation.type === `delete` ? `absent` : `present`,
2540
+ )
2541
+ }
2542
+ }
2543
+ } catch (error) {
2544
+ transaction.rejectApplied?.(error)
2545
+ for (const abandoned of this.queuedHydrationTransactions) {
2546
+ abandoned.rejectApplied?.(error)
2547
+ }
2548
+ this.queuedHydrationTransactions.length = 0
2549
+ throw error
2550
+ }
2551
+ }
2552
+ }
1269
2553
 
1270
- if (config.requestRemoteEnsure) {
1271
- this.queueRemoteSubsetEnsure(options)
2554
+ private rejectQueuedHydrationTransactions(error: unknown): void {
2555
+ for (const transaction of this.queuedHydrationTransactions) {
2556
+ transaction.rejectApplied?.(error)
1272
2557
  }
2558
+ this.queuedHydrationTransactions.length = 0
1273
2559
  }
1274
2560
 
1275
2561
  private applyRowsToCollection(
1276
2562
  rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
1277
- ): void {
2563
+ ): SyncAppliedReceipt {
1278
2564
  if (
1279
2565
  !this.syncControls.begin ||
1280
2566
  !this.syncControls.write ||
1281
2567
  !this.syncControls.commit
1282
2568
  ) {
1283
- return
2569
+ return true
1284
2570
  }
1285
2571
 
1286
- this.withInternalApply(() => {
2572
+ return this.withInternalApply(() => {
1287
2573
  this.syncControls.begin?.({ immediate: true })
1288
2574
 
1289
2575
  for (const row of rows) {
@@ -1297,21 +2583,81 @@ class PersistedCollectionRuntime<
1297
2583
  })
1298
2584
  }
1299
2585
 
1300
- this.syncControls.commit?.()
2586
+ return this.syncControls.commit?.() ?? true
1301
2587
  })
1302
2588
  }
1303
2589
 
2590
+ private getResumeSnapshotGeneration(snapshot: {
2591
+ latestTerm: number
2592
+ latestSeq: number
2593
+ latestRowVersion: number
2594
+ resetEpoch: number
2595
+ }): PersistedResumeGeneration {
2596
+ return {
2597
+ latestTerm: snapshot.latestTerm,
2598
+ latestSeq: snapshot.latestSeq,
2599
+ latestRowVersion: snapshot.latestRowVersion,
2600
+ resetEpoch: snapshot.resetEpoch,
2601
+ }
2602
+ }
2603
+
2604
+ private isExpectedResumeGeneration(
2605
+ generation: PersistedResumeGeneration,
2606
+ ): boolean {
2607
+ const expected = this.persistedResumeGeneration
2608
+ return (
2609
+ expected !== undefined &&
2610
+ expected.latestTerm === generation.latestTerm &&
2611
+ expected.latestSeq === generation.latestSeq &&
2612
+ expected.latestRowVersion === generation.latestRowVersion &&
2613
+ expected.resetEpoch === generation.resetEpoch
2614
+ )
2615
+ }
2616
+
2617
+ private bindResumeSnapshotEvidence(snapshot: {
2618
+ keySet?: PersistedKeySetEvidence
2619
+ latestTerm: number
2620
+ latestSeq: number
2621
+ latestRowVersion: number
2622
+ resetEpoch: number
2623
+ }): void {
2624
+ const generation = this.getResumeSnapshotGeneration(snapshot)
2625
+ const previousEvidenceStatus = this.persistedKeySetEvidence?.status
2626
+ // An uncertified sync baseline never authorized a persisted resume cursor,
2627
+ // so a later atomic snapshot may replace its evidence while the source
2628
+ // performs the already-required fresh snapshot. Local-only collections
2629
+ // have no remote cursor to fence; a managed write may advance a still-
2630
+ // consistent SQLite baseline during startup. Incompatible evidence remains
2631
+ // fail-closed in both modes.
2632
+ const mayAcceptUnownedGeneration =
2633
+ (this.mode === `sync-present` &&
2634
+ previousEvidenceStatus !== `consistent` &&
2635
+ previousEvidenceStatus !== `incompatible`) ||
2636
+ (this.mode === `sync-absent` &&
2637
+ previousEvidenceStatus === `consistent` &&
2638
+ snapshot.keySet?.status === `consistent`)
2639
+ this.observeStreamPosition(
2640
+ snapshot.latestTerm,
2641
+ snapshot.latestSeq,
2642
+ snapshot.latestRowVersion,
2643
+ )
2644
+ this.persistedKeySetEvidence =
2645
+ this.isExpectedResumeGeneration(generation) || mayAcceptUnownedGeneration
2646
+ ? snapshot.keySet
2647
+ : { status: `incompatible` }
2648
+ }
2649
+
1304
2650
  private replaceCollectionSnapshot(
1305
2651
  rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
1306
2652
  collectionMetadata: Array<{ key: string; value: unknown }>,
1307
- ): void {
2653
+ ): SyncAppliedReceipt {
1308
2654
  if (
1309
2655
  !this.syncControls.begin ||
1310
2656
  !this.syncControls.write ||
1311
2657
  !this.syncControls.commit ||
1312
2658
  !this.syncControls.metadata
1313
2659
  ) {
1314
- return
2660
+ return true
1315
2661
  }
1316
2662
 
1317
2663
  const nextMetadata = new Map(
@@ -1321,7 +2667,7 @@ class PersistedCollectionRuntime<
1321
2667
  .list()
1322
2668
  .map(({ key }) => key)
1323
2669
 
1324
- this.withInternalApply(() => {
2670
+ return this.withInternalApply(() => {
1325
2671
  this.syncControls.begin?.({ immediate: true })
1326
2672
  this.syncControls.truncate?.()
1327
2673
 
@@ -1343,18 +2689,25 @@ class PersistedCollectionRuntime<
1343
2689
  this.syncControls.metadata?.collection.set(key, value)
1344
2690
  })
1345
2691
 
1346
- this.syncControls.commit?.()
2692
+ return this.syncControls.commit?.() ?? true
1347
2693
  })
1348
2694
  }
1349
2695
 
1350
- private async flushQueuedHydrationTransactionsUnsafe(): Promise<void> {
2696
+ private async flushQueuedHydrationTransactionsUnsafe(
2697
+ adapter: HydrationPersistenceAdapter,
2698
+ ): Promise<{ reason: unknown } | undefined> {
2699
+ let operationFailure: { reason: unknown } | undefined
1351
2700
  while (this.queuedHydrationTransactions.length > 0) {
1352
2701
  const transaction = this.queuedHydrationTransactions.shift()
1353
2702
  if (!transaction) {
1354
2703
  continue
1355
2704
  }
1356
2705
  try {
1357
- await this.applyBufferedSyncTransactionUnsafe(transaction)
2706
+ const outcome = await this.applyBufferedSyncTransactionUnsafe(
2707
+ transaction,
2708
+ adapter,
2709
+ )
2710
+ operationFailure ??= outcome.hydrationFailure
1358
2711
  } catch (error) {
1359
2712
  transaction.rejectApplied?.(error)
1360
2713
  for (const abandoned of this.queuedHydrationTransactions) {
@@ -1364,133 +2717,168 @@ class PersistedCollectionRuntime<
1364
2717
  throw error
1365
2718
  }
1366
2719
  }
2720
+ return operationFailure
1367
2721
  }
1368
2722
 
1369
2723
  private async applyBufferedSyncTransactionUnsafe(
1370
2724
  transaction: BufferedSyncTransaction<T, TKey>,
1371
- ): Promise<void> {
1372
- if (transaction.signal?.aborted) {
1373
- transaction.rejectApplied?.(new SyncTransactionAbortedError())
1374
- return
1375
- }
1376
-
1377
- const { begin, write, commit, truncate, metadata } = this.syncControls
1378
- if (!begin || !write || !commit) {
1379
- transaction.rejectApplied?.(new SyncTransactionAbortedError())
1380
- return
1381
- }
1382
-
1383
- const applyToCollection = (): SyncAppliedReceipt => {
1384
- begin()
1385
-
1386
- if (transaction.truncate) {
1387
- truncate?.()
1388
- }
1389
-
1390
- for (const operation of transaction.operations) {
1391
- if (operation.type === `delete`) {
1392
- write({
1393
- type: `delete`,
1394
- key: operation.key,
1395
- })
1396
- } else {
1397
- write({
1398
- type: `update`,
1399
- value: operation.value,
1400
- metadata: operation.metadata,
1401
- })
1402
- }
1403
- }
1404
-
1405
- for (const [key, metadataWrite] of transaction.rowMetadataWrites) {
1406
- if (metadataWrite.type === `delete`) {
1407
- metadata?.row.delete(key)
1408
- } else {
1409
- metadata?.row.set(key, metadataWrite.value)
1410
- }
1411
- }
1412
-
1413
- for (const [key, metadataWrite] of transaction.collectionMetadataWrites) {
1414
- if (metadataWrite.type === `delete`) {
1415
- metadata?.collection.delete(key)
1416
- } else {
1417
- metadata?.collection.set(key, metadataWrite.value)
1418
- }
1419
- }
1420
-
1421
- return commit(transaction.signal)
1422
- }
2725
+ scopedAdapter?: HydrationPersistenceAdapter,
2726
+ ): Promise<{
2727
+ applied: boolean
2728
+ hydrationFailure?: { reason: unknown }
2729
+ }> {
2730
+ this.throwIfTerminal()
2731
+ this.throwIfLifecycleReplaced(transaction.lifecycleGeneration)
2732
+ const abortedBeforeApplication = transaction.signal?.aborted === true
2733
+ let abortedDuringApplication = false
2734
+ let applicationReturned = false
1423
2735
 
1424
2736
  try {
1425
2737
  const applied = transaction.internal
1426
- ? this.withInternalApply(applyToCollection)
1427
- : applyToCollection()
2738
+ ? this.withInternalApply(transaction.applyToCollection)
2739
+ : transaction.applyToCollection()
2740
+ applicationReturned = true
2741
+ abortedDuringApplication = transaction.signal?.aborted === true
1428
2742
  if (applied !== true) {
1429
- await applied
2743
+ this.sourcePublicationWaitDepth++
2744
+ try {
2745
+ await applied
2746
+ } finally {
2747
+ this.sourcePublicationWaitDepth--
2748
+ }
1430
2749
  }
2750
+ this.throwIfLifecycleReplaced(transaction.lifecycleGeneration)
1431
2751
 
1432
2752
  if (!transaction.internal) {
1433
- await this.persistAndBroadcastExternalSyncTransactionUnsafe(transaction)
2753
+ await this.persistAndBroadcastExternalSyncTransactionUnsafe(
2754
+ transaction,
2755
+ scopedAdapter,
2756
+ )
1434
2757
  }
1435
2758
  transaction.resolveApplied?.()
2759
+ return { applied: true }
1436
2760
  } catch (error) {
1437
- transaction.rejectApplied?.(error)
1438
- throw error
2761
+ if (!applicationReturned) {
2762
+ abortedDuringApplication = transaction.signal?.aborted === true
2763
+ }
2764
+ const aborted =
2765
+ transaction.signal?.aborted ||
2766
+ error instanceof SyncTransactionAbortedError
2767
+ const shouldFailStop =
2768
+ aborted && transaction.shouldFailStopOnAbort?.() === true
2769
+ const terminalError = this.classifyBufferedTransactionFailure(
2770
+ transaction,
2771
+ error,
2772
+ )
2773
+ transaction.rejectApplied?.(terminalError)
2774
+ if (aborted && !shouldFailStop && transaction.rejectApplied) {
2775
+ return {
2776
+ applied: false,
2777
+ hydrationFailure:
2778
+ !abortedBeforeApplication && abortedDuringApplication
2779
+ ? { reason: terminalError }
2780
+ : undefined,
2781
+ }
2782
+ }
2783
+ throw terminalError
1439
2784
  }
1440
2785
  }
1441
2786
 
2787
+ private classifyBufferedTransactionFailure(
2788
+ transaction: BufferedSyncTransaction<T, TKey>,
2789
+ error: unknown,
2790
+ ): unknown {
2791
+ if (error instanceof PersistedCollectionDurabilityError) {
2792
+ return this.markTerminalFailure(error, transaction.lifecycleGeneration)
2793
+ }
2794
+ const aborted =
2795
+ transaction.signal?.aborted ||
2796
+ error instanceof SyncTransactionAbortedError
2797
+ if (aborted && transaction.shouldFailStopOnAbort?.() !== true) {
2798
+ return error
2799
+ }
2800
+ return this.markTerminalFailure(error, transaction.lifecycleGeneration)
2801
+ }
2802
+
1442
2803
  private async persistAndBroadcastExternalSyncTransactionUnsafe(
1443
2804
  transaction: BufferedSyncTransaction<T, TKey>,
2805
+ scopedAdapter?: HydrationPersistenceAdapter,
1444
2806
  ): Promise<void> {
1445
2807
  if (transaction.internal) {
1446
2808
  return
1447
2809
  }
1448
2810
 
1449
- const streamPosition = this.nextLocalStreamPosition()
1450
-
2811
+ this.throwIfLifecycleReplaced(transaction.lifecycleGeneration)
1451
2812
  if (
1452
2813
  !transaction.truncate &&
1453
2814
  transaction.operations.length === 0 &&
1454
2815
  transaction.rowMetadataWrites.size === 0 &&
1455
2816
  transaction.collectionMetadataWrites.size === 0
1456
2817
  ) {
1457
- this.publishTxCommittedEvent(
1458
- this.createTxCommittedPayload({
1459
- term: streamPosition.term,
1460
- seq: streamPosition.seq,
1461
- txId: safeRandomUUID(),
1462
- latestRowVersion: streamPosition.rowVersion,
1463
- changedRows: [],
1464
- deletedKeys: [],
1465
- requiresFullReload: true,
1466
- }),
1467
- )
1468
2818
  return
1469
2819
  }
1470
2820
 
2821
+ const streamPosition = this.nextLocalStreamPosition()
1471
2822
  const tx = this.createPersistedTxFromOperations(transaction, streamPosition)
1472
-
1473
- await this.persistence.adapter.applyCommittedTx(this.collectionId, tx)
1474
- this.publishTxCommittedEvent(
1475
- this.createTxCommittedPayload({
1476
- term: tx.term,
1477
- seq: tx.seq,
1478
- txId: tx.txId,
1479
- latestRowVersion: tx.rowVersion,
1480
- requiresFullReload: transaction.truncate,
1481
- changedRows: transaction.operations
1482
- .filter((operation) => operation.type === `update`)
1483
- .map((operation) => ({
1484
- key: operation.key,
1485
- value: operation.value as Record<string, unknown>,
1486
- })),
1487
- deletedKeys: transaction.operations
1488
- .filter((operation) => operation.type === `delete`)
1489
- .map((operation) => operation.key),
1490
- rowMetadataMutations: tx.rowMetadataMutations,
1491
- collectionMetadataMutations: tx.collectionMetadataMutations,
1492
- }),
2823
+ const response = await this.persistence.coordinator.requestApplyCommittedTx(
2824
+ this.collectionId,
2825
+ tx,
2826
+ // A hydration-scoped adapter is already inside the shared scheduler.
2827
+ // Ordinary source commits pass no scoped adapter so the coordinator
2828
+ // enters that scheduler before taking the database-wide writer lock.
2829
+ scopedAdapter,
2830
+ )
2831
+ if (!response.ok) {
2832
+ if (response.code === `PERSISTENCE_ERROR`) {
2833
+ const error = new PersistedCollectionDurabilityError(
2834
+ `Failed to durably persist collection "${this.collectionId}": ${response.error}`,
2835
+ {
2836
+ cause: response,
2837
+ code: response.sourceCode ?? response.code,
2838
+ path: response.path,
2839
+ },
2840
+ )
2841
+ throw error
2842
+ }
2843
+ throw new Error(
2844
+ `failed to apply external sync transaction through coordinator: ${response.error}`,
2845
+ )
2846
+ }
2847
+ if (transaction.lifecycleGeneration !== this.lifecycleGeneration) return
2848
+ this.observeStreamPosition(
2849
+ response.term,
2850
+ response.seq,
2851
+ response.latestRowVersion,
1493
2852
  )
2853
+ if (
2854
+ transaction.expectedResumeGenerationOwner ===
2855
+ this.resumeGenerationOwner &&
2856
+ this.persistedResumeGeneration !== undefined
2857
+ ) {
2858
+ this.persistedResumeGeneration = {
2859
+ ...this.persistedResumeGeneration,
2860
+ latestTerm: response.term,
2861
+ latestSeq: response.seq,
2862
+ latestRowVersion: response.latestRowVersion,
2863
+ }
2864
+ }
2865
+ }
2866
+
2867
+ private async applyCommittedTx(
2868
+ tx: PersistedTx,
2869
+ lifecycleGeneration: number,
2870
+ ): Promise<void> {
2871
+ this.throwIfTerminal()
2872
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
2873
+ try {
2874
+ await this.persistence.adapter.applyCommittedTx(this.collectionId, tx)
2875
+ } catch (error) {
2876
+ throw this.markTerminalFailure(
2877
+ toPersistedCollectionDurabilityError(this.collectionId, error),
2878
+ lifecycleGeneration,
2879
+ )
2880
+ }
2881
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
1494
2882
  }
1495
2883
 
1496
2884
  private createPersistedTxFromOperations(
@@ -1572,9 +2960,9 @@ class PersistedCollectionRuntime<
1572
2960
  }
1573
2961
  }
1574
2962
 
1575
- private confirmMutationsSyncUnsafe(
2963
+ private async confirmMutationsSyncUnsafe(
1576
2964
  mutations: Array<PendingMutation<T>>,
1577
- ): void {
2965
+ ): Promise<void> {
1578
2966
  if (
1579
2967
  !this.syncControls.begin ||
1580
2968
  !this.syncControls.write ||
@@ -1583,7 +2971,7 @@ class PersistedCollectionRuntime<
1583
2971
  return
1584
2972
  }
1585
2973
 
1586
- this.withInternalApply(() => {
2974
+ const applied = this.withInternalApply(() => {
1587
2975
  this.syncControls.begin?.({ immediate: true })
1588
2976
 
1589
2977
  for (const mutation of mutations) {
@@ -1600,8 +2988,12 @@ class PersistedCollectionRuntime<
1600
2988
  }
1601
2989
  }
1602
2990
 
1603
- this.syncControls.commit?.()
2991
+ return this.syncControls.commit?.() ?? true
1604
2992
  })
2993
+
2994
+ if (applied !== true) {
2995
+ await applied
2996
+ }
1605
2997
  }
1606
2998
 
1607
2999
  private filterMutationsForCollection(
@@ -1618,7 +3010,9 @@ class PersistedCollectionRuntime<
1618
3010
 
1619
3011
  private async persistCollectionMutationsUnsafe(
1620
3012
  mutations: Array<PendingMutation<T>>,
3013
+ lifecycleGeneration: number,
1621
3014
  ): Promise<Array<string>> {
3015
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
1622
3016
  // When a coordinator with requestApplyLocalMutations is available, always
1623
3017
  // route through it — even on the leader tab. This ensures the coordinator's
1624
3018
  // seq/rowVersion counters stay in sync with actual writes. Without this,
@@ -1638,7 +3032,20 @@ class PersistedCollectionRuntime<
1638
3032
  envelopeMutations,
1639
3033
  )
1640
3034
 
3035
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
3036
+
1641
3037
  if (!response.ok) {
3038
+ if (response.code === `PERSISTENCE_ERROR`) {
3039
+ const error = new PersistedCollectionDurabilityError(
3040
+ `Failed to durably persist collection "${this.collectionId}": ${response.error}`,
3041
+ {
3042
+ cause: response,
3043
+ code: response.sourceCode ?? response.code,
3044
+ path: response.path,
3045
+ },
3046
+ )
3047
+ throw error
3048
+ }
1642
3049
  throw new Error(
1643
3050
  `failed to apply local mutations through coordinator: ${response.error}`,
1644
3051
  )
@@ -1673,7 +3080,9 @@ class PersistedCollectionRuntime<
1673
3080
  // SingleProcessCoordinator). Apply directly and broadcast.
1674
3081
  const streamPosition = this.nextLocalStreamPosition()
1675
3082
  const tx = this.createPersistedTxFromMutations(mutations, streamPosition)
1676
- await this.persistence.adapter.applyCommittedTx(this.collectionId, tx)
3083
+ await this.applyCommittedTx(tx, lifecycleGeneration)
3084
+
3085
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
1677
3086
 
1678
3087
  this.publishTxCommittedEvent(
1679
3088
  this.createTxCommittedPayload({
@@ -1808,39 +3217,54 @@ class PersistedCollectionRuntime<
1808
3217
  }
1809
3218
 
1810
3219
  private getSubsetKey(options: LoadSubsetOptions): string {
1811
- const subscription = options.subscription as object | undefined
1812
- if (subscription && typeof subscription === `object`) {
1813
- const existingId = this.subscriptionIds.get(subscription)
1814
- if (existingId) {
1815
- return existingId
1816
- }
3220
+ // A subscription can own several independent acquisitions, including
3221
+ // identical requests. Only releasing this options object ends its lease.
3222
+ let id = this.requestIds.get(options)
3223
+ if (id === undefined) {
3224
+ id = `request:${++this.nextRequestId}`
3225
+ this.requestIds.set(options, id)
3226
+ }
3227
+ return id
3228
+ }
1817
3229
 
1818
- this.nextSubscriptionId++
1819
- const id = `sub:${this.nextSubscriptionId}`
1820
- this.subscriptionIds.set(subscription, id)
1821
- return id
3230
+ private canRouteRemoteDemandThroughCoordinator(): boolean {
3231
+ if (
3232
+ this.getCurrentTerminalFailure() ||
3233
+ this.mode !== `sync-present` ||
3234
+ this.persistence.coordinator instanceof SingleProcessCoordinator
3235
+ ) {
3236
+ return false
1822
3237
  }
1823
3238
 
1824
- return `opts:${stableSerialize(normalizeSubsetOptionsForKey(options))}`
3239
+ // A follower routes demand to the elected owner even when its own source
3240
+ // cannot own acquisitions. Only an elected node needs a local owner.
3241
+ return (
3242
+ !this.persistence.coordinator.isLeader(this.collectionId) ||
3243
+ this.remoteSubsetOwnerUnsubscribe !== null
3244
+ )
1825
3245
  }
1826
3246
 
1827
3247
  private queueRemoteSubsetEnsure(options: LoadSubsetOptions): void {
1828
3248
  if (
1829
- this.mode !== `sync-present` ||
1830
- !this.persistence.coordinator.requestEnsureRemoteSubset ||
3249
+ options.signal?.aborted ||
3250
+ !this.canRouteRemoteDemandThroughCoordinator() ||
1831
3251
  this.activeSubsets.get(this.getSubsetKey(options)) !== options
1832
3252
  ) {
1833
3253
  return
1834
3254
  }
1835
3255
 
1836
- this.pendingRemoteSubsetEnsures.set(this.getSubsetKey(options), options)
3256
+ const subsetKey = this.getSubsetKey(options)
3257
+ if (this.activeSubsets.get(subsetKey) !== options) return
3258
+
3259
+ this.pendingRemoteSubsetEnsures.set(subsetKey, options)
1837
3260
  void this.flushPendingRemoteSubsetEnsures()
1838
3261
  }
1839
3262
 
1840
3263
  private scheduleRemoteEnsureRetry(): void {
1841
3264
  if (
3265
+ this.getCurrentTerminalFailure() ||
1842
3266
  this.mode !== `sync-present` ||
1843
- !this.persistence.coordinator.requestEnsureRemoteSubset
3267
+ this.persistence.coordinator instanceof SingleProcessCoordinator
1844
3268
  ) {
1845
3269
  return
1846
3270
  }
@@ -1860,8 +3284,9 @@ class PersistedCollectionRuntime<
1860
3284
 
1861
3285
  private async flushPendingRemoteSubsetEnsures(): Promise<void> {
1862
3286
  if (
3287
+ this.getCurrentTerminalFailure() ||
1863
3288
  this.mode !== `sync-present` ||
1864
- !this.persistence.coordinator.requestEnsureRemoteSubset
3289
+ this.persistence.coordinator instanceof SingleProcessCoordinator
1865
3290
  ) {
1866
3291
  return
1867
3292
  }
@@ -1872,6 +3297,13 @@ class PersistedCollectionRuntime<
1872
3297
  }
1873
3298
 
1874
3299
  for (const [subsetKey, options] of this.pendingRemoteSubsetEnsures) {
3300
+ if (
3301
+ options.signal?.aborted ||
3302
+ this.activeSubsets.get(subsetKey) !== options
3303
+ ) {
3304
+ this.pendingRemoteSubsetEnsures.delete(subsetKey)
3305
+ continue
3306
+ }
1875
3307
  try {
1876
3308
  await this.persistence.coordinator.requestEnsureRemoteSubset(
1877
3309
  this.collectionId,
@@ -1879,7 +3311,14 @@ class PersistedCollectionRuntime<
1879
3311
  )
1880
3312
  this.pendingRemoteSubsetEnsures.delete(subsetKey)
1881
3313
  } catch (error) {
1882
- console.warn(`Failed to ensure remote subset:`, error)
3314
+ if (
3315
+ options.signal?.aborted ||
3316
+ this.activeSubsets.get(subsetKey) !== options
3317
+ ) {
3318
+ this.pendingRemoteSubsetEnsures.delete(subsetKey)
3319
+ } else {
3320
+ console.warn(`Failed to ensure remote subset:`, error)
3321
+ }
1883
3322
  }
1884
3323
  }
1885
3324
 
@@ -1891,6 +3330,10 @@ class PersistedCollectionRuntime<
1891
3330
  return
1892
3331
  }
1893
3332
 
3333
+ this.persistence.coordinator.setAdapterForCollection?.(
3334
+ this.collectionId,
3335
+ this.persistence.adapter,
3336
+ )
1894
3337
  this.coordinatorUnsubscribe = this.persistence.coordinator.subscribe(
1895
3338
  this.collectionId,
1896
3339
  (message) => {
@@ -1903,23 +3346,30 @@ class PersistedCollectionRuntime<
1903
3346
  if (message.collectionId !== this.collectionId) {
1904
3347
  return
1905
3348
  }
3349
+ if (this.getCurrentTerminalFailure()) {
3350
+ return
3351
+ }
1906
3352
 
1907
3353
  const { payload } = message
3354
+ const lifecycleGeneration = this.lifecycleGeneration
1908
3355
  const isSelf = message.senderId === this.persistence.coordinator.getNodeId()
1909
3356
 
1910
3357
  // Allow tx:committed from self — the coordinator produces these on behalf
1911
3358
  // of both local and remote mutations. The seq dedup in
1912
3359
  // processCommittedTxUnsafe prevents double-processing of our own writes.
1913
3360
  if (isTxCommittedPayload(payload)) {
1914
- if (this.isHydrating) {
1915
- this.queuedTxCommitted.push(payload)
3361
+ if (this.isHydratingNow()) {
3362
+ this.queuedTxCommitted.push({
3363
+ txCommitted: payload,
3364
+ lifecycleGeneration,
3365
+ })
1916
3366
  return
1917
3367
  }
1918
3368
 
1919
3369
  void this.applyMutex
1920
- .run(() => this.processCommittedTxUnsafe(payload))
3370
+ .run(() => this.processCommittedTxUnsafe(payload, lifecycleGeneration))
1921
3371
  .catch((error) => {
1922
- console.warn(`Failed to process tx:committed message:`, error)
3372
+ this.markTerminalFailure(error, lifecycleGeneration)
1923
3373
  })
1924
3374
  return
1925
3375
  }
@@ -1931,9 +3381,16 @@ class PersistedCollectionRuntime<
1931
3381
 
1932
3382
  if (isCollectionResetPayload(payload)) {
1933
3383
  void this.applyMutex
1934
- .run(() => this.truncateAndReloadUnsafe())
3384
+ .run(async () => {
3385
+ await this.runInHydrationScope((adapter) =>
3386
+ this.truncateAndReloadUnsafe(adapter, lifecycleGeneration),
3387
+ )
3388
+ if (lifecycleGeneration === this.lifecycleGeneration) {
3389
+ await this.flushQueuedTxCommittedUnsafe()
3390
+ }
3391
+ })
1935
3392
  .catch((error) => {
1936
- console.warn(`Failed to process collection reset message:`, error)
3393
+ this.markTerminalFailure(error, lifecycleGeneration)
1937
3394
  })
1938
3395
  }
1939
3396
  }
@@ -1944,13 +3401,18 @@ class PersistedCollectionRuntime<
1944
3401
  if (!queued) {
1945
3402
  continue
1946
3403
  }
1947
- await this.processCommittedTxUnsafe(queued)
3404
+ await this.processCommittedTxUnsafe(
3405
+ queued.txCommitted,
3406
+ queued.lifecycleGeneration,
3407
+ )
1948
3408
  }
1949
3409
  }
1950
3410
 
1951
3411
  private async processCommittedTxUnsafe(
1952
3412
  txCommitted: TxCommitted,
3413
+ lifecycleGeneration = this.lifecycleGeneration,
1953
3414
  ): Promise<void> {
3415
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1954
3416
  if (txCommitted.term < this.latestTerm) {
1955
3417
  return
1956
3418
  }
@@ -1970,7 +3432,8 @@ class PersistedCollectionRuntime<
1970
3432
  const hasGap = hasGapInCurrentTerm || hasGapAcrossTerms
1971
3433
 
1972
3434
  if (hasGap) {
1973
- await this.recoverFromSeqGapUnsafe()
3435
+ await this.recoverFromSeqGapUnsafe(lifecycleGeneration)
3436
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1974
3437
  if (
1975
3438
  txCommitted.term < this.latestTerm ||
1976
3439
  (txCommitted.term === this.latestTerm &&
@@ -1986,16 +3449,32 @@ class PersistedCollectionRuntime<
1986
3449
  txCommitted.latestRowVersion,
1987
3450
  )
1988
3451
 
1989
- await this.invalidateFromCommittedTxUnsafe(txCommitted)
3452
+ await this.invalidateFromCommittedTxUnsafe(
3453
+ txCommitted,
3454
+ this.persistence.adapter,
3455
+ )
3456
+ if (lifecycleGeneration === this.lifecycleGeneration) {
3457
+ await this.flushQueuedTxCommittedUnsafe()
3458
+ }
1990
3459
  }
1991
3460
 
1992
- private async recoverFromSeqGapUnsafe(): Promise<void> {
3461
+ private async recoverFromSeqGapUnsafe(
3462
+ lifecycleGeneration: number,
3463
+ ): Promise<void> {
3464
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1993
3465
  if (this.persistence.coordinator.pullSince && this.latestRowVersion >= 0) {
3466
+ let pullResponse: PullSinceResponse | undefined
1994
3467
  try {
1995
- const pullResponse = await this.persistence.coordinator.pullSince(
3468
+ pullResponse = await this.persistence.coordinator.pullSince(
1996
3469
  this.collectionId,
1997
3470
  this.latestRowVersion,
1998
3471
  )
3472
+ } catch (error) {
3473
+ console.warn(`Failed pullSince recovery attempt:`, error)
3474
+ }
3475
+
3476
+ if (pullResponse) {
3477
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1999
3478
 
2000
3479
  if (pullResponse.ok) {
2001
3480
  this.observeStreamPosition(
@@ -2003,33 +3482,51 @@ class PersistedCollectionRuntime<
2003
3482
  pullResponse.latestSeq,
2004
3483
  pullResponse.latestRowVersion,
2005
3484
  )
2006
- if (pullResponse.requiresFullReload || !pullResponse.deltas) {
2007
- await this.reloadActiveSubsetsUnsafe()
3485
+ if (pullResponse.requiresFullReload) {
3486
+ await this.runInHydrationScope((adapter) =>
3487
+ this.reloadActiveSubsetsUnsafe(adapter),
3488
+ )
2008
3489
  return
2009
3490
  }
2010
-
2011
- for (const delta of pullResponse.deltas) {
2012
- await this.invalidateFromCommittedTxUnsafe({
2013
- type: `tx:committed`,
2014
- term: pullResponse.latestTerm,
2015
- seq: pullResponse.latestSeq,
2016
- txId: delta.txId,
2017
- latestRowVersion: delta.latestRowVersion,
2018
- requiresFullReload: false,
2019
- changedRows: delta.changedRows,
2020
- deletedKeys: delta.deletedKeys,
2021
- rowMetadataMutations: delta.rowMetadataMutations,
2022
- collectionMetadataMutations: delta.collectionMetadataMutations,
2023
- })
3491
+ const deltas = pullResponse.deltas
3492
+ if (!deltas) {
3493
+ await this.runInHydrationScope((adapter) =>
3494
+ this.reloadActiveSubsetsUnsafe(adapter),
3495
+ )
3496
+ return
2024
3497
  }
3498
+
3499
+ await this.runInHydrationScope(async (adapter) => {
3500
+ for (const delta of deltas) {
3501
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3502
+ await this.invalidateFromCommittedTxUnsafe(
3503
+ {
3504
+ type: `tx:committed`,
3505
+ term: pullResponse.latestTerm,
3506
+ seq: pullResponse.latestSeq,
3507
+ txId: delta.txId,
3508
+ latestRowVersion: delta.latestRowVersion,
3509
+ requiresFullReload: false,
3510
+ changedRows: delta.changedRows,
3511
+ deletedKeys: delta.deletedKeys,
3512
+ rowMetadataMutations: delta.rowMetadataMutations,
3513
+ collectionMetadataMutations:
3514
+ delta.collectionMetadataMutations,
3515
+ },
3516
+ adapter,
3517
+ )
3518
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3519
+ }
3520
+ })
2025
3521
  return
2026
3522
  }
2027
- } catch (error) {
2028
- console.warn(`Failed pullSince recovery attempt:`, error)
2029
3523
  }
2030
3524
  }
2031
3525
 
2032
- await this.truncateAndReloadUnsafe()
3526
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3527
+ await this.runInHydrationScope((adapter) =>
3528
+ this.truncateAndReloadUnsafe(adapter, lifecycleGeneration),
3529
+ )
2033
3530
 
2034
3531
  if (this.mode === `sync-present`) {
2035
3532
  for (const options of this.activeSubsets.values()) {
@@ -2038,30 +3535,46 @@ class PersistedCollectionRuntime<
2038
3535
  }
2039
3536
  }
2040
3537
 
2041
- private async truncateAndReloadUnsafe(): Promise<void> {
3538
+ private async truncateAndReloadUnsafe(
3539
+ adapter: HydrationPersistenceAdapter,
3540
+ lifecycleGeneration = this.lifecycleGeneration,
3541
+ ): Promise<void> {
3542
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3543
+ // A subscriber may reacquire reentrantly from the truncate commit.
3544
+ this.hydratedDemands.clear()
3545
+ this.resetSequence++
2042
3546
  if (this.syncControls.begin && this.syncControls.commit) {
2043
- this.withInternalApply(() => {
3547
+ const applied = this.withInternalApply(() => {
2044
3548
  this.syncControls.begin?.({ immediate: true })
2045
3549
  this.syncControls.truncate?.()
2046
- this.syncControls.commit?.()
3550
+ return this.syncControls.commit?.() ?? true
2047
3551
  })
3552
+ if (applied !== true) await applied
2048
3553
  }
2049
3554
 
2050
- await this.reloadActiveSubsetsUnsafe()
3555
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3556
+ await this.reloadActiveSubsetsUnsafe(adapter)
2051
3557
  }
2052
3558
 
2053
3559
  private async invalidateFromCommittedTxUnsafe(
2054
3560
  txCommitted: TxCommitted,
3561
+ adapter: HydrationPersistenceAdapter,
2055
3562
  ): Promise<void> {
3563
+ const reloadActiveSubsets = () =>
3564
+ this.runInHydrationScope(
3565
+ (scopedAdapter) => this.reloadActiveSubsetsUnsafe(scopedAdapter),
3566
+ adapter,
3567
+ )
3568
+
2056
3569
  if (txCommitted.requiresFullReload) {
2057
- await this.reloadActiveSubsetsUnsafe()
3570
+ await reloadActiveSubsets()
2058
3571
  return
2059
3572
  }
2060
3573
 
2061
3574
  const changedKeyCount =
2062
3575
  txCommitted.changedRows.length + txCommitted.deletedKeys.length
2063
3576
  if (changedKeyCount > TARGETED_INVALIDATION_KEY_LIMIT) {
2064
- await this.reloadActiveSubsetsUnsafe()
3577
+ await reloadActiveSubsets()
2065
3578
  return
2066
3579
  }
2067
3580
 
@@ -2076,7 +3589,7 @@ class PersistedCollectionRuntime<
2076
3589
 
2077
3590
  // Has paginated subsets — fall back to full reload.
2078
3591
  // Targeted invalidation for paginated subsets is deferred to a future iteration.
2079
- await this.reloadActiveSubsetsUnsafe()
3592
+ await reloadActiveSubsets()
2080
3593
  }
2081
3594
 
2082
3595
  private async applyTargetedInvalidationUnsafe(
@@ -2086,7 +3599,7 @@ class PersistedCollectionRuntime<
2086
3599
  (opt) => (opt.where ? compileSingleRowExpression(opt.where) : null),
2087
3600
  )
2088
3601
 
2089
- this.withInternalApply(() => {
3602
+ const applied = this.withInternalApply(() => {
2090
3603
  this.syncControls.begin?.({ immediate: true })
2091
3604
 
2092
3605
  for (const {
@@ -2131,22 +3644,34 @@ class PersistedCollectionRuntime<
2131
3644
  }
2132
3645
  })
2133
3646
 
2134
- this.syncControls.commit?.()
3647
+ return this.syncControls.commit?.() ?? true
2135
3648
  })
3649
+ if (applied !== true) await applied
2136
3650
  }
2137
3651
 
2138
- private async reloadActiveSubsetsUnsafe(): Promise<void> {
3652
+ private async reloadActiveSubsetsUnsafe(
3653
+ adapter: HydrationPersistenceAdapter,
3654
+ ): Promise<void> {
3655
+ const lifecycleGeneration = this.lifecycleGeneration
3656
+ const truncateGeneration = this.sourceTruncateGeneration
2139
3657
  const activeSubsetOptions =
2140
3658
  this.activeSubsets.size > 0
2141
3659
  ? Array.from(this.activeSubsets.values())
2142
3660
  : [{}]
2143
3661
 
2144
- this.isHydrating = true
3662
+ this.hydratedDemands.clear()
3663
+ this.hydrationSequence++
3664
+ const hydrationContext = { suppliedRowKeys: new Set<TKey>() }
3665
+ this.activeHydrationContext = hydrationContext
3666
+ this.hydratingGeneration = lifecycleGeneration
2145
3667
  try {
2146
3668
  const mergedRows = new Map<TKey, { value: T; metadata?: unknown }>()
2147
- const collectionMetadata = await this.loadCollectionMetadataSnapshot()
3669
+ const collectionMetadata =
3670
+ await this.loadCollectionMetadataSnapshot(adapter)
3671
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2148
3672
  for (const options of activeSubsetOptions) {
2149
- const subsetRows = await this.loadSubsetRowsUnsafe(options)
3673
+ const subsetRows = await this.loadSubsetRowsUnsafe(options, adapter)
3674
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2150
3675
  for (const row of subsetRows) {
2151
3676
  mergedRows.set(row.key, {
2152
3677
  value: row.value,
@@ -2155,7 +3680,11 @@ class PersistedCollectionRuntime<
2155
3680
  }
2156
3681
  }
2157
3682
 
2158
- this.replaceCollectionSnapshot(
3683
+ for (const key of mergedRows.keys()) {
3684
+ hydrationContext.suppliedRowKeys.add(key)
3685
+ }
3686
+
3687
+ const applied = this.replaceCollectionSnapshot(
2159
3688
  Array.from(mergedRows.entries()).map(([key, row]) => ({
2160
3689
  key,
2161
3690
  value: row.value,
@@ -2163,12 +3692,34 @@ class PersistedCollectionRuntime<
2163
3692
  })),
2164
3693
  collectionMetadata,
2165
3694
  )
3695
+ if (applied !== true) await applied
2166
3696
  } finally {
2167
- this.isHydrating = false
3697
+ if (
3698
+ this.activeHydrationContext === hydrationContext &&
3699
+ this.hydratingGeneration === lifecycleGeneration
3700
+ ) {
3701
+ this.hydratingGeneration = null
3702
+ }
3703
+ if (this.activeHydrationContext === hydrationContext) {
3704
+ this.activeHydrationContext = undefined
3705
+ }
3706
+ }
3707
+ if (lifecycleGeneration === this.lifecycleGeneration) {
3708
+ await this.flushQueuedHydrationTransactionsUnsafe(adapter)
3709
+ // Coordinator commits queued during this hydrate are processed outside
3710
+ // its scope. Leave the synchronous fast path closed until a later load
3711
+ // if any such commit still needs to be applied.
3712
+ if (
3713
+ truncateGeneration === this.sourceTruncateGeneration &&
3714
+ this.queuedTxCommitted.length === 0
3715
+ ) {
3716
+ for (const options of activeSubsetOptions) {
3717
+ if (this.activeSubsets.get(this.getSubsetKey(options)) === options) {
3718
+ this.hydratedDemands.add(getLoadSubsetDemandKey(options))
3719
+ }
3720
+ }
3721
+ }
2168
3722
  }
2169
-
2170
- await this.flushQueuedHydrationTransactionsUnsafe()
2171
- await this.flushQueuedTxCommittedUnsafe()
2172
3723
  }
2173
3724
 
2174
3725
  private attachIndexLifecycleListeners(): void {
@@ -2193,16 +3744,33 @@ class PersistedCollectionRuntime<
2193
3744
 
2194
3745
  private async bootstrapPersistedIndexes(
2195
3746
  indexMetadataSnapshot?: Array<CollectionIndexMetadata>,
2196
- ): Promise<void> {
3747
+ adapter: HydrationPersistenceAdapter = this.persistence.adapter,
3748
+ ): Promise<Set<string>> {
2197
3749
  const collection = this.collection
2198
3750
  if (!collection && !indexMetadataSnapshot) {
2199
- return
3751
+ return new Set()
2200
3752
  }
2201
3753
 
2202
3754
  const indexMetadata =
2203
3755
  indexMetadataSnapshot ?? collection?.getIndexMetadata() ?? []
3756
+ const completedLocalIndexSignatures = new Set<string>()
3757
+ for (const metadata of indexMetadata) {
3758
+ if (await this.ensureLocalPersistedIndex(metadata, adapter)) {
3759
+ completedLocalIndexSignatures.add(metadata.signature)
3760
+ }
3761
+ }
3762
+ return completedLocalIndexSignatures
3763
+ }
3764
+
3765
+ private async requestCoordinatorPersistedIndexes(
3766
+ indexMetadata: Array<CollectionIndexMetadata>,
3767
+ completedLocalIndexSignatures: ReadonlySet<string>,
3768
+ ): Promise<void> {
2204
3769
  for (const metadata of indexMetadata) {
2205
- await this.ensurePersistedIndex(metadata)
3770
+ await this.requestCoordinatorPersistedIndex(
3771
+ metadata,
3772
+ completedLocalIndexSignatures.has(metadata.signature),
3773
+ )
2206
3774
  }
2207
3775
  }
2208
3776
 
@@ -2221,24 +3789,47 @@ class PersistedCollectionRuntime<
2221
3789
 
2222
3790
  private async ensurePersistedIndex(
2223
3791
  indexMetadata: CollectionIndexMetadata,
3792
+ adapter: HydrationPersistenceAdapter = this.persistence.adapter,
2224
3793
  ): Promise<void> {
3794
+ const completedLocally = await this.ensureLocalPersistedIndex(
3795
+ indexMetadata,
3796
+ adapter,
3797
+ )
3798
+ await this.requestCoordinatorPersistedIndex(indexMetadata, completedLocally)
3799
+ }
3800
+
3801
+ private async ensureLocalPersistedIndex(
3802
+ indexMetadata: CollectionIndexMetadata,
3803
+ adapter: HydrationPersistenceAdapter,
3804
+ ): Promise<boolean> {
2225
3805
  const spec = this.buildPersistedIndexSpec(indexMetadata)
2226
3806
 
2227
3807
  try {
2228
- await this.persistence.adapter.ensureIndex(
3808
+ await adapter.ensureIndex(
2229
3809
  this.collectionId,
2230
3810
  indexMetadata.signature,
2231
3811
  spec,
2232
3812
  )
3813
+ return true
2233
3814
  } catch (error) {
2234
3815
  console.warn(`Failed to ensure persisted index in adapter:`, error)
3816
+ return false
2235
3817
  }
3818
+ }
3819
+
3820
+ private async requestCoordinatorPersistedIndex(
3821
+ indexMetadata: CollectionIndexMetadata,
3822
+ completedLocally: boolean,
3823
+ ): Promise<void> {
3824
+ const spec = this.buildPersistedIndexSpec(indexMetadata)
2236
3825
 
2237
3826
  try {
2238
3827
  await this.persistence.coordinator.requestEnsurePersistedIndex(
2239
3828
  this.collectionId,
2240
3829
  indexMetadata.signature,
2241
3830
  spec,
3831
+ completedLocally ? this.persistence.adapter : undefined,
3832
+ completedLocally,
2242
3833
  )
2243
3834
  } catch (error) {
2244
3835
  console.warn(
@@ -2272,67 +3863,406 @@ function createWrappedSyncConfig<
2272
3863
  >(
2273
3864
  sourceSyncConfig: SyncConfig<T, TKey>,
2274
3865
  runtime: PersistedCollectionRuntime<T, TKey>,
3866
+ persistedReadiness?: PersistedReadinessTracker,
2275
3867
  ): SyncConfig<T, TKey> {
2276
3868
  return {
2277
3869
  ...sourceSyncConfig,
2278
3870
  sync: (params) => {
2279
3871
  const transactionStack: Array<OpenSyncTransaction<T, TKey>> = []
3872
+ const pendingPublicationTransactions: Array<
3873
+ OpenSyncTransaction<T, TKey>
3874
+ > = []
2280
3875
  const getOpenTransaction = () =>
2281
3876
  transactionStack[transactionStack.length - 1]
3877
+ const settlePublicationAdmissionWaiters = (
3878
+ transaction: OpenSyncTransaction<T, TKey>,
3879
+ error?: unknown,
3880
+ ) => {
3881
+ const waiters = transaction.publicationAdmissionWaiters
3882
+ transaction.publicationAdmissionWaiters = undefined
3883
+ if (!waiters) return
3884
+ for (const waiter of waiters) {
3885
+ if (error === undefined) waiter.resolve()
3886
+ else waiter.reject(error)
3887
+ }
3888
+ }
3889
+ const bindToCurrentHydration = (
3890
+ transaction: OpenSyncTransaction<T, TKey>,
3891
+ ) => {
3892
+ if (transaction.internal || !runtime.isHydratingNow()) return
3893
+ const hydrationSequence = runtime.getHydrationSequence()
3894
+ if (
3895
+ transaction.hydrationSequence !== undefined &&
3896
+ transaction.hydrationSequence !== hydrationSequence
3897
+ ) {
3898
+ throw new InvalidPersistedCollectionConfigError(
3899
+ `a persisted sync transaction cannot cross a hydration cycle`,
3900
+ )
3901
+ }
3902
+ transaction.queuedBecauseHydrating = true
3903
+ transaction.hydrationSequence = hydrationSequence
3904
+ transaction.hydrationContext = runtime.getActiveHydrationContext()
3905
+ }
3906
+ const assertHydrationSequenceCurrent = (
3907
+ transaction: OpenSyncTransaction<T, TKey>,
3908
+ ) => {
3909
+ if (
3910
+ transaction.hydrationSequence !== undefined &&
3911
+ transaction.hydrationSequence !== runtime.getHydrationSequence()
3912
+ ) {
3913
+ throw new InvalidPersistedCollectionConfigError(
3914
+ `a persisted sync transaction cannot cross a hydration cycle`,
3915
+ )
3916
+ }
3917
+ }
3918
+ const markPendingMetadataDependency = (
3919
+ transaction: OpenSyncTransaction<T, TKey>,
3920
+ ) => {
3921
+ const openTransaction = getOpenTransaction()
3922
+ if (openTransaction && !openTransaction.internal) {
3923
+ transaction.hasDependentSuccessor = true
3924
+ }
3925
+ }
3926
+ const removePendingPublicationTransaction = (
3927
+ transaction: OpenSyncTransaction<T, TKey>,
3928
+ ) => {
3929
+ const index = pendingPublicationTransactions.indexOf(transaction)
3930
+ if (index !== -1) pendingPublicationTransactions.splice(index, 1)
3931
+ }
3932
+ const settlePendingTransaction = (
3933
+ transaction: OpenSyncTransaction<T, TKey>,
3934
+ ) => {
3935
+ removePendingPublicationTransaction(transaction)
3936
+ }
3937
+ const settleRuntimeTransaction = (
3938
+ transaction: OpenSyncTransaction<T, TKey>,
3939
+ applied: Promise<void>,
3940
+ ) => {
3941
+ void applied.then(
3942
+ () => settlePendingTransaction(transaction),
3943
+ () => {
3944
+ settlePendingTransaction(transaction)
3945
+ },
3946
+ )
3947
+ }
3948
+ const getPendingRowMetadataWrite = (key: TKey) => {
3949
+ for (
3950
+ let index = pendingPublicationTransactions.length - 1;
3951
+ index >= 0;
3952
+ index--
3953
+ ) {
3954
+ const transaction = pendingPublicationTransactions[index]!
3955
+ const write = transaction.rowMetadataWrites.get(key)
3956
+ if (write) {
3957
+ markPendingMetadataDependency(transaction)
3958
+ return { found: true, write }
3959
+ }
3960
+ if (transaction.truncate) {
3961
+ markPendingMetadataDependency(transaction)
3962
+ return {
3963
+ found: true,
3964
+ write: { type: `delete` as const },
3965
+ }
3966
+ }
3967
+ }
3968
+ return { found: false as const }
3969
+ }
3970
+ const getPendingCollectionMetadataWrite = (key: string) => {
3971
+ for (
3972
+ let index = pendingPublicationTransactions.length - 1;
3973
+ index >= 0;
3974
+ index--
3975
+ ) {
3976
+ const transaction = pendingPublicationTransactions[index]!
3977
+ const write = transaction.collectionMetadataWrites.get(key)
3978
+ if (write) {
3979
+ markPendingMetadataDependency(transaction)
3980
+ return { found: true, write }
3981
+ }
3982
+ }
3983
+ return { found: false as const }
3984
+ }
2282
3985
  let fullStartPromise: Promise<void> | null = null
2283
- const acquisitions = new Map<LoadSubsetOptions, { forwarded: boolean }>()
3986
+ let sourceResultPromise: Promise<SyncConfigRes> | null = null
3987
+ let resolveSourceResultAssigned!: () => void
3988
+ const sourceResultAssigned = new Promise<void>((resolve) => {
3989
+ resolveSourceResultAssigned = resolve
3990
+ })
3991
+ const startupState = { cleanedUp: false }
3992
+ const isCleanedUp = () => startupState.cleanedUp
3993
+ type SubsetAcquisition = {
3994
+ forwarded: boolean
3995
+ cancelAdmissionWait?: () => void
3996
+ }
3997
+ const acquisitions = new Map<LoadSubsetOptions, SubsetAcquisition>()
3998
+ const waitForPublicationAdmission = (
3999
+ transaction: OpenSyncTransaction<T, TKey>,
4000
+ options: LoadSubsetOptions,
4001
+ acquisition: SubsetAcquisition,
4002
+ ): Promise<void> => {
4003
+ if (options.signal?.aborted) {
4004
+ return Promise.reject(
4005
+ options.signal.reason ?? new SyncTransactionAbortedError(),
4006
+ )
4007
+ }
4008
+ return new Promise<void>((resolve, reject) => {
4009
+ const waiters =
4010
+ transaction.publicationAdmissionWaiters ??
4011
+ (transaction.publicationAdmissionWaiters = new Set())
4012
+ let settled = false
4013
+ const settle = (action: () => void) => {
4014
+ if (settled) return
4015
+ settled = true
4016
+ waiters.delete(waiter)
4017
+ options.signal?.removeEventListener(`abort`, abort)
4018
+ acquisition.cancelAdmissionWait = undefined
4019
+ action()
4020
+ }
4021
+ const waiter = {
4022
+ resolve: () => settle(resolve),
4023
+ reject: (error: unknown) => settle(() => reject(error)),
4024
+ }
4025
+ const abort = () =>
4026
+ waiter.reject(
4027
+ options.signal?.reason ?? new SyncTransactionAbortedError(),
4028
+ )
4029
+ waiters.add(waiter)
4030
+ options.signal?.addEventListener(`abort`, abort, { once: true })
4031
+ acquisition.cancelAdmissionWait = waiter.resolve
4032
+ })
4033
+ }
4034
+ const getTerminalFailure = () => runtime.getCurrentTerminalFailure()
4035
+ const reportUpstreamError = (error: unknown) => {
4036
+ if (persistedReadiness?.getSnapshot().status !== `loading`) {
4037
+ runtime.reportSyncError(error)
4038
+ return
4039
+ }
4040
+ // An early network error must not stop the live-query graph before
4041
+ // the persisted baseline has published its rows.
4042
+ void (fullStartPromise ?? runtime.ensureStarted()).then(
4043
+ () => {
4044
+ if (!isCleanedUp() && !getTerminalFailure()) {
4045
+ runtime.reportSyncError(error)
4046
+ }
4047
+ },
4048
+ () => undefined,
4049
+ )
4050
+ }
4051
+ const createHandledRejection = (error: unknown): Promise<never> => {
4052
+ const rejected = Promise.reject(error)
4053
+ void rejected.catch(() => undefined)
4054
+ return rejected
4055
+ }
4056
+ const applyTransactionToCollection = (
4057
+ transaction: OpenSyncTransaction<T, TKey>,
4058
+ signal?: AbortSignal,
4059
+ ): SyncAppliedReceipt => {
4060
+ if (transaction.applicationReceipt !== undefined) {
4061
+ return transaction.applicationReceipt
4062
+ }
4063
+ try {
4064
+ assertHydrationSequenceCurrent(transaction)
4065
+ for (const key of transaction.deferredHydrationMetadataDeleteKeys) {
4066
+ if (
4067
+ !transaction.hydrationContext?.suppliedRowKeys.has(key) &&
4068
+ !transaction.rowMetadataWrites.has(key)
4069
+ ) {
4070
+ transaction.rowMetadataWrites.set(key, { type: `delete` })
4071
+ }
4072
+ }
4073
+ transaction.deferredHydrationMetadataDeleteKeys.clear()
4074
+
4075
+ // A buffered source replay is part of the hydrate that owns it.
4076
+ // It must not wait for a mutation whose persistence is queued
4077
+ // behind that hydrate's apply mutex.
4078
+ params.begin(
4079
+ transaction.queuedBecauseHydrating
4080
+ ? { immediate: true }
4081
+ : transaction.beginOptions,
4082
+ )
4083
+ if (transaction.truncate) params.truncate()
4084
+ for (const operation of transaction.operations) {
4085
+ if (operation.type === `delete`) {
4086
+ params.write({ type: `delete`, key: operation.key })
4087
+ } else {
4088
+ params.write({
4089
+ type: `update`,
4090
+ value: operation.value,
4091
+ metadata: operation.metadata,
4092
+ })
4093
+ }
4094
+ }
4095
+ if (params.metadata) {
4096
+ for (const [key, write] of transaction.rowMetadataWrites) {
4097
+ if (write.type === `delete`) params.metadata.row.delete(key)
4098
+ else params.metadata.row.set(key, write.value)
4099
+ }
4100
+ for (const [key, write] of transaction.collectionMetadataWrites) {
4101
+ if (write.type === `delete`)
4102
+ params.metadata.collection.delete(key)
4103
+ else params.metadata.collection.set(key, write.value)
4104
+ }
4105
+ }
4106
+
4107
+ const applied = params.commit(signal)
4108
+ transaction.applicationReceipt = applied
4109
+ if (applied === true) {
4110
+ removePendingPublicationTransaction(transaction)
4111
+ } else {
4112
+ void applied.then(
4113
+ () => removePendingPublicationTransaction(transaction),
4114
+ () => removePendingPublicationTransaction(transaction),
4115
+ )
4116
+ }
4117
+ return applied
4118
+ } catch (error) {
4119
+ removePendingPublicationTransaction(transaction)
4120
+ throw error
4121
+ }
4122
+ }
2284
4123
  runtime.setSyncControls({
2285
4124
  begin: params.begin,
2286
4125
  write: params.write as SyncControlFns<T, TKey>[`write`],
2287
4126
  commit: params.commit,
2288
4127
  truncate: params.truncate,
2289
4128
  metadata: params.metadata ?? null,
4129
+ markError: params.markError,
2290
4130
  })
2291
4131
  runtime.setCollection(
2292
4132
  params.collection as Collection<T, TKey, PersistedCollectionUtils>,
2293
4133
  )
2294
4134
 
4135
+ const persistenceCapability: SyncPersistenceCapabilityV1<TKey> = {
4136
+ protocol: SYNC_PERSISTENCE_PROTOCOL,
4137
+ version: SYNC_PERSISTENCE_VERSION,
4138
+ hydrateBaseline: async () => {
4139
+ if (startupState.cleanedUp) return
4140
+ try {
4141
+ await runtime.ensureResumeBaselineHydrated()
4142
+ } catch (error) {
4143
+ throw runtime.reportSyncError(error)
4144
+ }
4145
+ },
4146
+ scanPersistedRows: (options) =>
4147
+ startupState.cleanedUp
4148
+ ? Promise.resolve([])
4149
+ : runtime.scanPersistedRows(options),
4150
+ resumeSnapshot: {
4151
+ certify: async () => {
4152
+ if (startupState.cleanedUp) return
4153
+ try {
4154
+ await runtime.ensureResumeBaselineCertified()
4155
+ } catch (error) {
4156
+ throw runtime.reportSyncError(error)
4157
+ }
4158
+ },
4159
+ getKeySetEvidence: () =>
4160
+ startupState.cleanedUp ? undefined : runtime.getKeySetEvidence(),
4161
+ expectCurrentCommit: () => {
4162
+ if (startupState.cleanedUp) return
4163
+ const openTransaction = getOpenTransaction()
4164
+ if (!openTransaction) {
4165
+ throw new InvalidPersistedCollectionConfigError(
4166
+ `resumeSnapshot.expectCurrentCommit must be called within an open sync transaction`,
4167
+ )
4168
+ }
4169
+ openTransaction.expectedResumeGenerationOwner =
4170
+ runtime.getResumeGenerationOwner()
4171
+ },
4172
+ },
4173
+ }
4174
+
2295
4175
  const wrappedParams = {
2296
4176
  ...params,
4177
+ markError: reportUpstreamError,
2297
4178
  markReady: () => {
4179
+ if (startupState.cleanedUp || getTerminalFailure()) return
2298
4180
  void (fullStartPromise ?? runtime.ensureStarted())
2299
- .then(() => {
2300
- params.markReady()
2301
- })
2302
- .catch((error) => {
2303
- console.warn(
2304
- `Failed persisted sync startup before markReady:`,
2305
- error,
2306
- )
4181
+ .then(async () => {
4182
+ if (isCleanedUp() || getTerminalFailure()) return
4183
+ await sourceResultAssigned
4184
+ try {
4185
+ await sourceResultPromise
4186
+ } catch (error) {
4187
+ reportUpstreamError(error)
4188
+ return
4189
+ }
4190
+ if (isCleanedUp() || getTerminalFailure()) return
2307
4191
  params.markReady()
2308
4192
  })
4193
+ .catch(() => undefined)
2309
4194
  },
2310
4195
  begin: (options?: { immediate?: boolean }) => {
4196
+ if (startupState.cleanedUp) return undefined
4197
+ const terminalFailure = getTerminalFailure()
4198
+ const internal = runtime.isApplyingInternally()
2311
4199
  const transaction: OpenSyncTransaction<T, TKey> = {
2312
4200
  operations: [],
4201
+ partialUpdateOperationIndexes: new Set(),
2313
4202
  rowMetadataWrites: new Map(),
2314
4203
  collectionMetadataWrites: new Map(),
4204
+ deferredHydrationMetadataDeleteKeys: new Set(),
2315
4205
  truncate: false,
2316
- internal: runtime.isApplyingInternally(),
4206
+ internal,
4207
+ lifecycleGeneration: runtime.getLifecycleGeneration(),
4208
+ beginOptions: options,
4209
+ operationKeys: new Set(),
4210
+ hasDependentSuccessor: false,
2317
4211
  queuedBecauseHydrating:
2318
- !runtime.isApplyingInternally() && runtime.isHydratingNow(),
4212
+ terminalFailure === undefined &&
4213
+ !internal &&
4214
+ runtime.isHydratingNow(),
4215
+ hydrationSequence:
4216
+ terminalFailure === undefined && !internal
4217
+ ? runtime.getHydrationSequence()
4218
+ : undefined,
4219
+ hydrationContext:
4220
+ terminalFailure === undefined &&
4221
+ !internal &&
4222
+ runtime.isHydratingNow()
4223
+ ? runtime.getActiveHydrationContext()
4224
+ : undefined,
4225
+ ...(terminalFailure === undefined ? {} : { terminalFailure }),
2319
4226
  }
2320
4227
  transactionStack.push(transaction)
2321
-
2322
- if (!transaction.queuedBecauseHydrating) {
2323
- params.begin(options)
2324
- }
2325
4228
  },
2326
4229
  write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => {
2327
- const normalization = runtime.normalizeSyncWriteMessage(message)
4230
+ if (startupState.cleanedUp) return
2328
4231
  const openTransaction = getOpenTransaction()
2329
-
2330
- if (!openTransaction) {
2331
- params.write(normalization.forwardMessage)
4232
+ const terminalFailure =
4233
+ openTransaction?.terminalFailure ?? getTerminalFailure()
4234
+ if (terminalFailure) {
4235
+ if (openTransaction)
4236
+ openTransaction.terminalFailure = terminalFailure
2332
4237
  return
2333
4238
  }
4239
+ if (!openTransaction) {
4240
+ throw new NoPendingSyncTransactionWriteError()
4241
+ }
4242
+ bindToCurrentHydration(openTransaction)
4243
+ const normalization = runtime.normalizeSyncWriteMessage(message)
4244
+
4245
+ if (
4246
+ message.type === `update` &&
4247
+ sourceSyncConfig.rowUpdateMode !== `full`
4248
+ ) {
4249
+ if (!openTransaction.internal) {
4250
+ openTransaction.partialUpdateOperationIndexes.add(
4251
+ openTransaction.operations.length,
4252
+ )
4253
+ }
4254
+ for (const pending of pendingPublicationTransactions) {
4255
+ if (
4256
+ pending.truncate ||
4257
+ pending.operationKeys.has(normalization.operation.key)
4258
+ ) {
4259
+ markPendingMetadataDependency(pending)
4260
+ }
4261
+ }
4262
+ }
2334
4263
 
2335
4264
  openTransaction.operations.push(normalization.operation)
4265
+ openTransaction.operationKeys.add(normalization.operation.key)
2336
4266
  if (normalization.operation.type === `delete`) {
2337
4267
  openTransaction.rowMetadataWrites.set(normalization.operation.key, {
2338
4268
  type: `delete`,
@@ -2344,7 +4274,11 @@ function createWrappedSyncConfig<
2344
4274
  // Reset stale metadata for a fresh insert, but don't clobber an
2345
4275
  // explicit metadata write already queued for this key in the same
2346
4276
  // transaction (e.g. query reconcile stamps owners, then inserts).
2347
- if (
4277
+ if (openTransaction.queuedBecauseHydrating) {
4278
+ openTransaction.deferredHydrationMetadataDeleteKeys.add(
4279
+ normalization.operation.key,
4280
+ )
4281
+ } else if (
2348
4282
  !openTransaction.rowMetadataWrites.has(
2349
4283
  normalization.operation.key,
2350
4284
  )
@@ -2362,14 +4296,13 @@ function createWrappedSyncConfig<
2362
4296
  value: normalization.operation.metadata,
2363
4297
  })
2364
4298
  }
2365
- if (!openTransaction.queuedBecauseHydrating) {
2366
- params.write(normalization.forwardMessage)
2367
- }
2368
4299
  },
2369
4300
  metadata: params.metadata
2370
4301
  ? {
4302
+ persistence: persistenceCapability,
2371
4303
  row: {
2372
4304
  get: (key: TKey) => {
4305
+ if (startupState.cleanedUp) return undefined
2373
4306
  const openTransaction = getOpenTransaction()
2374
4307
  const pendingWrite =
2375
4308
  openTransaction?.rowMetadataWrites.get(key)
@@ -2381,12 +4314,19 @@ function createWrappedSyncConfig<
2381
4314
  if (openTransaction?.truncate) {
2382
4315
  return undefined
2383
4316
  }
4317
+ const pending = getPendingRowMetadataWrite(key)
4318
+ if (pending.found) {
4319
+ return pending.write.type === `delete`
4320
+ ? undefined
4321
+ : pending.write.value
4322
+ }
2384
4323
  return params.metadata!.row.get(key)
2385
4324
  },
2386
- scanPersisted: (options?: PersistedRowScanOptions) =>
2387
- runtime.scanPersistedRows(options),
2388
4325
  set: (key: TKey, value: unknown) => {
4326
+ if (startupState.cleanedUp) return
2389
4327
  const openTransaction = getOpenTransaction()
4328
+ if (openTransaction?.terminalFailure ?? getTerminalFailure())
4329
+ return
2390
4330
  if (!openTransaction) {
2391
4331
  throw new InvalidPersistedCollectionConfigError(
2392
4332
  `metadata.row.set must be called within an open sync transaction`,
@@ -2396,12 +4336,12 @@ function createWrappedSyncConfig<
2396
4336
  type: `set`,
2397
4337
  value,
2398
4338
  })
2399
- if (!openTransaction.queuedBecauseHydrating) {
2400
- params.metadata!.row.set(key, value)
2401
- }
2402
4339
  },
2403
4340
  delete: (key: TKey) => {
4341
+ if (startupState.cleanedUp) return
2404
4342
  const openTransaction = getOpenTransaction()
4343
+ if (openTransaction?.terminalFailure ?? getTerminalFailure())
4344
+ return
2405
4345
  if (!openTransaction) {
2406
4346
  throw new InvalidPersistedCollectionConfigError(
2407
4347
  `metadata.row.delete must be called within an open sync transaction`,
@@ -2410,13 +4350,11 @@ function createWrappedSyncConfig<
2410
4350
  openTransaction.rowMetadataWrites.set(key, {
2411
4351
  type: `delete`,
2412
4352
  })
2413
- if (!openTransaction.queuedBecauseHydrating) {
2414
- params.metadata!.row.delete(key)
2415
- }
2416
4353
  },
2417
4354
  },
2418
4355
  collection: {
2419
4356
  get: (key: string) => {
4357
+ if (startupState.cleanedUp) return undefined
2420
4358
  const openTransaction = getOpenTransaction()
2421
4359
  const pendingWrite =
2422
4360
  openTransaction?.collectionMetadataWrites.get(key)
@@ -2425,10 +4363,19 @@ function createWrappedSyncConfig<
2425
4363
  ? undefined
2426
4364
  : pendingWrite.value
2427
4365
  }
4366
+ const pending = getPendingCollectionMetadataWrite(key)
4367
+ if (pending.found) {
4368
+ return pending.write.type === `delete`
4369
+ ? undefined
4370
+ : pending.write.value
4371
+ }
2428
4372
  return params.metadata!.collection.get(key)
2429
4373
  },
2430
4374
  set: (key: string, value: unknown) => {
4375
+ if (startupState.cleanedUp) return
2431
4376
  const openTransaction = getOpenTransaction()
4377
+ if (openTransaction?.terminalFailure ?? getTerminalFailure())
4378
+ return
2432
4379
  if (!openTransaction) {
2433
4380
  throw new InvalidPersistedCollectionConfigError(
2434
4381
  `metadata.collection.set must be called within an open sync transaction`,
@@ -2438,12 +4385,12 @@ function createWrappedSyncConfig<
2438
4385
  type: `set`,
2439
4386
  value,
2440
4387
  })
2441
- if (!openTransaction.queuedBecauseHydrating) {
2442
- params.metadata!.collection.set(key, value)
2443
- }
2444
4388
  },
2445
4389
  delete: (key: string) => {
4390
+ if (startupState.cleanedUp) return
2446
4391
  const openTransaction = getOpenTransaction()
4392
+ if (openTransaction?.terminalFailure ?? getTerminalFailure())
4393
+ return
2447
4394
  if (!openTransaction) {
2448
4395
  throw new InvalidPersistedCollectionConfigError(
2449
4396
  `metadata.collection.delete must be called within an open sync transaction`,
@@ -2452,16 +4399,36 @@ function createWrappedSyncConfig<
2452
4399
  openTransaction.collectionMetadataWrites.set(key, {
2453
4400
  type: `delete`,
2454
4401
  })
2455
- if (!openTransaction.queuedBecauseHydrating) {
2456
- params.metadata!.collection.delete(key)
2457
- }
2458
4402
  },
2459
4403
  list: (prefix?: string) => {
4404
+ if (startupState.cleanedUp) return []
2460
4405
  const merged = new Map(
2461
4406
  params
2462
4407
  .metadata!.collection.list()
2463
4408
  .map(({ key, value }) => [key, value]),
2464
4409
  )
4410
+ const pendingOwners = new Map<
4411
+ string,
4412
+ OpenSyncTransaction<T, TKey>
4413
+ >()
4414
+ for (const transaction of pendingPublicationTransactions) {
4415
+ for (const [
4416
+ key,
4417
+ metadataWrite,
4418
+ ] of transaction.collectionMetadataWrites) {
4419
+ if (!prefix || key.startsWith(prefix)) {
4420
+ pendingOwners.set(key, transaction)
4421
+ }
4422
+ if (metadataWrite.type === `delete`) {
4423
+ merged.delete(key)
4424
+ } else {
4425
+ merged.set(key, metadataWrite.value)
4426
+ }
4427
+ }
4428
+ }
4429
+ for (const owner of new Set(pendingOwners.values())) {
4430
+ markPendingMetadataDependency(owner)
4431
+ }
2465
4432
  const openTransaction = getOpenTransaction()
2466
4433
  if (openTransaction) {
2467
4434
  for (const [
@@ -2487,35 +4454,114 @@ function createWrappedSyncConfig<
2487
4454
  }
2488
4455
  : undefined,
2489
4456
  truncate: () => {
4457
+ if (startupState.cleanedUp) return
4458
+ runtime.noteSourceTruncate()
2490
4459
  const openTransaction = getOpenTransaction()
4460
+ if (openTransaction?.terminalFailure ?? getTerminalFailure()) return
2491
4461
  if (!openTransaction) {
2492
- params.truncate()
2493
- return
4462
+ throw new NoPendingSyncTransactionWriteError()
2494
4463
  }
2495
4464
 
2496
4465
  openTransaction.operations = []
4466
+ openTransaction.operationKeys.clear()
4467
+ openTransaction.partialUpdateOperationIndexes.clear()
2497
4468
  openTransaction.rowMetadataWrites.clear()
4469
+ openTransaction.deferredHydrationMetadataDeleteKeys.clear()
2498
4470
  // Intentionally preserve collectionMetadataWrites across truncate.
2499
4471
  // Callers (for example electric resume/reset handling) may stage
2500
4472
  // collection-scoped metadata before truncating row data, and those
2501
4473
  // writes must commit atomically with the truncate transaction.
2502
4474
  openTransaction.truncate = true
2503
- if (!openTransaction.queuedBecauseHydrating) {
2504
- params.truncate()
2505
- }
2506
4475
  },
2507
4476
  commit: (signal?: AbortSignal) => {
4477
+ if (startupState.cleanedUp) return true
2508
4478
  const openTransaction = transactionStack.pop()
4479
+ const terminalFailure =
4480
+ openTransaction?.terminalFailure ?? getTerminalFailure()
4481
+ if (terminalFailure) {
4482
+ if (openTransaction) {
4483
+ settlePendingTransaction(openTransaction)
4484
+ settlePublicationAdmissionWaiters(
4485
+ openTransaction,
4486
+ terminalFailure.error,
4487
+ )
4488
+ }
4489
+ return createHandledRejection(terminalFailure.error)
4490
+ }
2509
4491
  if (!openTransaction) {
2510
- return params.commit(signal)
4492
+ throw new NoPendingSyncTransactionCommitError()
2511
4493
  }
2512
4494
 
2513
- if (openTransaction.queuedBecauseHydrating) {
2514
- if (signal?.aborted) {
2515
- const aborted = Promise.reject(new SyncTransactionAbortedError())
2516
- void aborted.catch(() => undefined)
2517
- return aborted
2518
- }
4495
+ if (openTransaction.internal) {
4496
+ return applyTransactionToCollection(openTransaction, signal)
4497
+ }
4498
+
4499
+ if (signal?.aborted) {
4500
+ settlePendingTransaction(openTransaction)
4501
+ const error = new SyncTransactionAbortedError()
4502
+ settlePublicationAdmissionWaiters(openTransaction, error)
4503
+ return createHandledRejection(error)
4504
+ }
4505
+ try {
4506
+ bindToCurrentHydration(openTransaction)
4507
+ assertHydrationSequenceCurrent(openTransaction)
4508
+ } catch (error) {
4509
+ settlePublicationAdmissionWaiters(openTransaction, error)
4510
+ throw error
4511
+ }
4512
+ const transaction = {
4513
+ operations: openTransaction.operations,
4514
+ partialUpdateOperationIndexes:
4515
+ openTransaction.partialUpdateOperationIndexes,
4516
+ rowMetadataWrites: openTransaction.rowMetadataWrites,
4517
+ collectionMetadataWrites: openTransaction.collectionMetadataWrites,
4518
+ deferredHydrationMetadataDeleteKeys:
4519
+ openTransaction.deferredHydrationMetadataDeleteKeys,
4520
+ hydrationContext: openTransaction.hydrationContext,
4521
+ hydrationSequence: openTransaction.hydrationSequence,
4522
+ truncate: openTransaction.truncate,
4523
+ internal: false,
4524
+ lifecycleGeneration: openTransaction.lifecycleGeneration,
4525
+ beginOptions: openTransaction.beginOptions,
4526
+ expectedResumeGenerationOwner:
4527
+ openTransaction.expectedResumeGenerationOwner,
4528
+ signal,
4529
+ prependHydrationRows: (
4530
+ rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
4531
+ ) => {
4532
+ const baselineOperations: Array<
4533
+ NormalizedSyncOperation<T, TKey>
4534
+ > = rows.map(({ key, value, metadata }) => ({
4535
+ type: `update`,
4536
+ key,
4537
+ value,
4538
+ metadata: metadata as Record<string, unknown> | undefined,
4539
+ }))
4540
+ openTransaction.operations = baselineOperations.concat(
4541
+ openTransaction.operations,
4542
+ )
4543
+ for (const { key, metadata } of rows) {
4544
+ if (
4545
+ metadata !== undefined &&
4546
+ !openTransaction.rowMetadataWrites.has(key)
4547
+ ) {
4548
+ openTransaction.rowMetadataWrites.set(key, {
4549
+ type: `set`,
4550
+ value: metadata,
4551
+ })
4552
+ }
4553
+ }
4554
+ },
4555
+ applyToCollection: () =>
4556
+ applyTransactionToCollection(openTransaction, signal),
4557
+ shouldFailStopOnAbort: () => openTransaction.hasDependentSuccessor,
4558
+ }
4559
+ openTransaction.signal = signal
4560
+ pendingPublicationTransactions.push(openTransaction)
4561
+ if (
4562
+ openTransaction.queuedBecauseHydrating &&
4563
+ runtime.isHydratingNow()
4564
+ ) {
2519
4565
  let resolveApplied!: () => void
2520
4566
  let rejectApplied!: (error: unknown) => void
2521
4567
  const applied = new Promise<void>((resolve, reject) => {
@@ -2524,76 +4570,142 @@ function createWrappedSyncConfig<
2524
4570
  })
2525
4571
  void applied.catch(() => undefined)
2526
4572
  runtime.queueHydrationBufferedTransaction({
2527
- operations: openTransaction.operations,
2528
- rowMetadataWrites: openTransaction.rowMetadataWrites,
2529
- collectionMetadataWrites:
2530
- openTransaction.collectionMetadataWrites,
2531
- truncate: openTransaction.truncate,
2532
- internal: openTransaction.internal,
2533
- signal,
4573
+ ...transaction,
2534
4574
  resolveApplied,
2535
4575
  rejectApplied,
2536
4576
  })
4577
+ settlePublicationAdmissionWaiters(openTransaction)
4578
+ settleRuntimeTransaction(openTransaction, applied)
2537
4579
  return applied
2538
4580
  }
2539
4581
 
2540
- const applied = params.commit(signal)
2541
- if (!openTransaction.internal) {
2542
- const persistAfterApplication = async () => {
2543
- if (applied !== true) await applied
2544
- await runtime.persistAndBroadcastExternalSyncTransaction({
2545
- operations: openTransaction.operations,
2546
- rowMetadataWrites: openTransaction.rowMetadataWrites,
2547
- collectionMetadataWrites:
2548
- openTransaction.collectionMetadataWrites,
2549
- truncate: openTransaction.truncate,
2550
- internal: false,
2551
- })
2552
- }
2553
- const persisted = persistAfterApplication()
2554
- void persisted.catch(() => undefined)
2555
- return persisted
4582
+ let applied: Promise<void>
4583
+ try {
4584
+ applied = runtime.applyHydrationBufferedTransaction(transaction)
4585
+ } catch (error) {
4586
+ settlePublicationAdmissionWaiters(openTransaction, error)
4587
+ throw error
2556
4588
  }
4589
+ settlePublicationAdmissionWaiters(openTransaction)
4590
+ settleRuntimeTransaction(openTransaction, applied)
2557
4591
  return applied
2558
4592
  },
2559
4593
  }
2560
4594
 
2561
4595
  let sourceResult: SyncConfigRes = {}
2562
- const startupState = { cleanedUp: false }
4596
+ let sourceResultSettled = false
4597
+ let activeSourceSyncEntry: Promise<void> | undefined
2563
4598
  fullStartPromise = runtime.ensureStarted()
2564
- const sourceResultPromise = (async () => {
4599
+ // Startup can fail before the source reaches markReady or loadSubset,
4600
+ // which are the two eventual consumers of this outer adopting promise.
4601
+ // The runtime-owned inner promise still installs and reports the exact
4602
+ // terminal failure; this observer only prevents the unused outer wrapper
4603
+ // from becoming an unhandled rejection on that fail-stop path.
4604
+ void fullStartPromise.catch(() => undefined)
4605
+ sourceResultPromise = (async () => {
2565
4606
  await runtime.ensureStartupMetadataLoaded()
2566
4607
 
2567
4608
  if (startupState.cleanedUp) {
4609
+ sourceResultSettled = true
2568
4610
  return sourceResult
2569
4611
  }
2570
4612
 
2571
- sourceResult = normalizeSyncFnResult(
2572
- sourceSyncConfig.sync(wrappedParams),
2573
- )
4613
+ let settleSourceSyncEntry!: () => void
4614
+ activeSourceSyncEntry = new Promise<void>((resolve) => {
4615
+ settleSourceSyncEntry = resolve
4616
+ })
4617
+ try {
4618
+ sourceResult = normalizeSyncFnResult(
4619
+ sourceSyncConfig.sync(wrappedParams),
4620
+ )
4621
+ } finally {
4622
+ activeSourceSyncEntry = undefined
4623
+ settleSourceSyncEntry()
4624
+ }
4625
+ // Cleanup can reenter while the source sync function is still active.
4626
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
4627
+ if (!startupState.cleanedUp && sourceResult.loadSubset) {
4628
+ const loadSubset = async (options: TransportedLoadSubsetOptions) => {
4629
+ if (startupState.cleanedUp) {
4630
+ throw new Error(`persisted sync source is no longer active`)
4631
+ }
4632
+ await sourceResult.loadSubset?.(
4633
+ options as unknown as LoadSubsetOptions,
4634
+ )
4635
+ }
4636
+ runtime.registerRemoteSubsetOwner(
4637
+ Object.assign(loadSubset, {
4638
+ unloadSubset: (options: TransportedLoadSubsetOptions) =>
4639
+ sourceResult.unloadSubset?.(
4640
+ options as unknown as LoadSubsetOptions,
4641
+ ),
4642
+ onError: reportUpstreamError,
4643
+ }),
4644
+ )
4645
+ }
4646
+ sourceResultSettled = true
2574
4647
  return sourceResult
2575
4648
  })()
4649
+ resolveSourceResultAssigned()
4650
+ void sourceResultPromise.catch((error) => {
4651
+ reportUpstreamError(error)
4652
+ })
2576
4653
 
2577
4654
  return {
2578
- cleanup: () => {
4655
+ cleanup: async () => {
2579
4656
  startupState.cleanedUp = true
4657
+ // The optional source starts behind persistence metadata. If it
4658
+ // reenters cleanup before returning, wait for that exact synchronous
4659
+ // entry to publish its cleanup callback before sampling the result.
4660
+ const sourceSyncEntry = activeSourceSyncEntry
4661
+ const cleanupError = new SyncTransactionAbortedError()
4662
+ for (const acquisition of acquisitions.values()) {
4663
+ acquisition.cancelAdmissionWait?.()
4664
+ }
2580
4665
  acquisitions.clear()
2581
- sourceResult.cleanup?.()
2582
- runtime.cleanup()
2583
- runtime.clearSyncControls()
2584
- },
2585
- loadSubset: async (options: LoadSubsetOptions) => {
2586
- const acquisition = { forwarded: false }
2587
- acquisitions.set(options, acquisition)
2588
- await fullStartPromise
2589
- const resolvedSourceResult = await sourceResultPromise
4666
+ pendingPublicationTransactions.length = 0
4667
+ for (const transaction of transactionStack) {
4668
+ settlePublicationAdmissionWaiters(transaction, cleanupError)
4669
+ }
4670
+ transactionStack.length = 0
4671
+ let sourceCleanup: Promise<void>
4672
+ try {
4673
+ sourceCleanup = sourceSyncEntry
4674
+ ? sourceSyncEntry.then(() => sourceResult.cleanup?.())
4675
+ : Promise.resolve(sourceResult.cleanup?.())
4676
+ } catch (error) {
4677
+ sourceCleanup = Promise.reject(error)
4678
+ }
4679
+ let runtimeCleanup: Promise<void>
4680
+ try {
4681
+ runtimeCleanup = runtime.cleanup()
4682
+ } catch (error) {
4683
+ runtimeCleanup = Promise.reject(error)
4684
+ }
4685
+ const [sourceOutcome, runtimeOutcome] = await Promise.allSettled([
4686
+ sourceCleanup,
4687
+ runtimeCleanup,
4688
+ ])
2590
4689
  if (
2591
- startupState.cleanedUp ||
2592
- acquisitions.get(options) !== acquisition
4690
+ sourceOutcome.status === `rejected` &&
4691
+ runtimeOutcome.status === `rejected`
2593
4692
  ) {
2594
- return
4693
+ throw new AggregateError(
4694
+ [sourceOutcome.reason, runtimeOutcome.reason],
4695
+ `Source cleanup and persistence runtime teardown both failed`,
4696
+ { cause: sourceOutcome.reason },
4697
+ )
2595
4698
  }
2596
- return runtime.loadSubset(options, (loadOptions) => {
4699
+ if (sourceOutcome.status === `rejected`) throw sourceOutcome.reason
4700
+ if (runtimeOutcome.status === `rejected`) throw runtimeOutcome.reason
4701
+ },
4702
+ loadSubset: (options: LoadSubsetOptions): true | Promise<void> => {
4703
+ const acquisition = { forwarded: false }
4704
+ acquisitions.set(options, acquisition)
4705
+ const forwardUpstream = (
4706
+ resolvedSourceResult: SyncConfigRes,
4707
+ loadOptions: LoadSubsetOptions,
4708
+ ): true | Promise<void> => {
2597
4709
  // Hydration is another async boundary. A release before this
2598
4710
  // point owns no upstream lease and must not start one later.
2599
4711
  if (
@@ -2605,15 +4717,67 @@ function createWrappedSyncConfig<
2605
4717
  if (!resolvedSourceResult.loadSubset) return true
2606
4718
  acquisition.forwarded = true
2607
4719
  try {
4720
+ // Returning a promise transfers its lease even if it rejects.
4721
+ // Only a synchronous throw leaves no upstream lease to release.
2608
4722
  return resolvedSourceResult.loadSubset(loadOptions)
2609
4723
  } catch (error) {
2610
4724
  acquisition.forwarded = false
2611
4725
  throw error
2612
4726
  }
2613
- })
4727
+ }
4728
+
4729
+ const openTransaction = getOpenTransaction()
4730
+ const needsPublicationAdmission =
4731
+ openTransaction &&
4732
+ !openTransaction.internal &&
4733
+ openTransaction.beginOptions?.immediate
4734
+ const hydrated =
4735
+ sourceResultSettled && !needsPublicationAdmission
4736
+ ? runtime.loadHydratedSubset(options, (loadOptions) =>
4737
+ forwardUpstream(sourceResult, loadOptions),
4738
+ )
4739
+ : undefined
4740
+ if (hydrated !== undefined) {
4741
+ return hydrated
4742
+ }
4743
+
4744
+ return (async () => {
4745
+ await fullStartPromise
4746
+ const resolvedSourceResult = await sourceResultPromise
4747
+ if (
4748
+ startupState.cleanedUp ||
4749
+ acquisitions.get(options) !== acquisition
4750
+ ) {
4751
+ return
4752
+ }
4753
+ const currentTransaction = getOpenTransaction()
4754
+ // An immediate source transaction reserves its FIFO turn before
4755
+ // subset hydration advances the generation it was built against.
4756
+ if (
4757
+ currentTransaction &&
4758
+ !currentTransaction.internal &&
4759
+ currentTransaction.beginOptions?.immediate
4760
+ ) {
4761
+ await waitForPublicationAdmission(
4762
+ currentTransaction,
4763
+ options,
4764
+ acquisition,
4765
+ )
4766
+ }
4767
+ if (
4768
+ options.signal?.aborted ||
4769
+ acquisitions.get(options) !== acquisition
4770
+ ) {
4771
+ return
4772
+ }
4773
+ return runtime.loadSubset(options, (loadOptions) =>
4774
+ forwardUpstream(resolvedSourceResult, loadOptions),
4775
+ )
4776
+ })()
2614
4777
  },
2615
4778
  unloadSubset: (options: LoadSubsetOptions) => {
2616
4779
  const acquisition = acquisitions.get(options)
4780
+ acquisition?.cancelAdmissionWait?.()
2617
4781
  acquisitions.delete(options)
2618
4782
  runtime.unloadSubset(
2619
4783
  options,
@@ -2637,6 +4801,7 @@ function createLoopbackSyncConfig<
2637
4801
  commit: params.commit,
2638
4802
  truncate: params.truncate,
2639
4803
  metadata: params.metadata ?? null,
4804
+ markError: params.markError,
2640
4805
  })
2641
4806
  runtime.setCollection(
2642
4807
  params.collection as Collection<T, TKey, PersistedCollectionUtils>,
@@ -2645,19 +4810,15 @@ function createLoopbackSyncConfig<
2645
4810
  void runtime
2646
4811
  .ensureStarted()
2647
4812
  .then(() => {
4813
+ if (runtime.getCurrentTerminalFailure()) return
2648
4814
  params.markReady()
2649
4815
  })
2650
- .catch((error) => {
2651
- console.warn(`Failed persisted loopback startup:`, error)
2652
- params.markReady()
2653
- })
4816
+ .catch(() => undefined)
2654
4817
 
2655
4818
  return {
2656
- cleanup: () => {
2657
- runtime.cleanup()
2658
- runtime.clearSyncControls()
2659
- },
2660
- loadSubset: (options: LoadSubsetOptions) => runtime.loadSubset(options),
4819
+ loadSubset: (options: LoadSubsetOptions): true | Promise<void> =>
4820
+ runtime.loadHydratedSubset(options) ?? runtime.loadSubset(options),
4821
+ cleanup: () => runtime.cleanup(),
2661
4822
  unloadSubset: (options: LoadSubsetOptions) =>
2662
4823
  runtime.unloadSubset(options),
2663
4824
  }
@@ -2668,13 +4829,59 @@ function createLoopbackSyncConfig<
2668
4829
  }
2669
4830
  }
2670
4831
 
4832
+ export function persistedCollectionOptions<
4833
+ TSchema extends StandardSchemaV1,
4834
+ TKey extends string | number,
4835
+ TUtils extends UtilsRecord = UtilsRecord,
4836
+ >(
4837
+ options: PersistedSyncWrappedOptions<
4838
+ InferSchemaOutput<TSchema>,
4839
+ TKey,
4840
+ TSchema,
4841
+ TUtils
4842
+ > & {
4843
+ schema: TSchema
4844
+ },
4845
+ ): PersistedSyncOptionsResult<
4846
+ InferSchemaOutput<TSchema>,
4847
+ TKey,
4848
+ TSchema,
4849
+ TUtils
4850
+ > & {
4851
+ schema: TSchema
4852
+ }
4853
+
4854
+ export function persistedCollectionOptions<
4855
+ TSchema extends StandardSchemaV1,
4856
+ TKey extends string | number,
4857
+ TUtils extends UtilsRecord = UtilsRecord,
4858
+ >(
4859
+ options: PersistedLocalOnlyOptions<
4860
+ InferSchemaOutput<TSchema>,
4861
+ TKey,
4862
+ TSchema,
4863
+ TUtils
4864
+ > & {
4865
+ schema: TSchema
4866
+ },
4867
+ ): PersistedLocalOnlyOptionsResult<
4868
+ InferSchemaOutput<TSchema>,
4869
+ TKey,
4870
+ TSchema,
4871
+ TUtils
4872
+ > & {
4873
+ schema: TSchema
4874
+ }
4875
+
2671
4876
  export function persistedCollectionOptions<
2672
4877
  T extends object,
2673
4878
  TKey extends string | number,
2674
4879
  TSchema extends StandardSchemaV1 = never,
2675
4880
  TUtils extends UtilsRecord = UtilsRecord,
2676
4881
  >(
2677
- options: PersistedSyncWrappedOptions<T, TKey, TSchema, TUtils>,
4882
+ options: PersistedSyncWrappedOptions<T, TKey, TSchema, TUtils> & {
4883
+ schema?: never
4884
+ },
2678
4885
  ): PersistedSyncOptionsResult<T, TKey, TSchema, TUtils>
2679
4886
 
2680
4887
  export function persistedCollectionOptions<
@@ -2683,7 +4890,9 @@ export function persistedCollectionOptions<
2683
4890
  TSchema extends StandardSchemaV1 = never,
2684
4891
  TUtils extends UtilsRecord = UtilsRecord,
2685
4892
  >(
2686
- options: PersistedLocalOnlyOptions<T, TKey, TSchema, TUtils>,
4893
+ options: PersistedLocalOnlyOptions<T, TKey, TSchema, TUtils> & {
4894
+ schema?: never
4895
+ },
2687
4896
  ): PersistedLocalOnlyOptionsResult<T, TKey, TSchema, TUtils>
2688
4897
 
2689
4898
  export function persistedCollectionOptions<
@@ -2704,6 +4913,17 @@ export function persistedCollectionOptions<
2704
4913
  )
2705
4914
  }
2706
4915
 
4916
+ const networkTimeoutMs = getNetworkTimeoutMs(options.initialRender)
4917
+ if (networkTimeoutMs !== undefined && options.syncMode === `on-demand`) {
4918
+ throw new InvalidPersistedCollectionConfigError(
4919
+ `network-first initial rendering requires eager persistence syncMode`,
4920
+ )
4921
+ }
4922
+ const persistedReadinessTracker =
4923
+ networkTimeoutMs !== undefined
4924
+ ? new PersistedReadinessTracker(networkTimeoutMs)
4925
+ : undefined
4926
+
2707
4927
  if (hasOwnSyncKey(options)) {
2708
4928
  if (!isValidSyncConfig(options.sync)) {
2709
4929
  throw new InvalidSyncConfigError(
@@ -2711,7 +4931,11 @@ export function persistedCollectionOptions<
2711
4931
  )
2712
4932
  }
2713
4933
 
2714
- const { schemaVersion, ...syncOptions } = options
4934
+ const {
4935
+ schemaVersion,
4936
+ initialRender: _initialRender,
4937
+ ...syncOptions
4938
+ } = options
2715
4939
  const collectionId =
2716
4940
  syncOptions.id ?? `persisted-collection:${safeRandomUUID()}`
2717
4941
  const persistence = resolvePersistenceForCollection(
@@ -2729,13 +4953,21 @@ export function persistedCollectionOptions<
2729
4953
  persistence,
2730
4954
  syncOptions.syncMode ?? `eager`,
2731
4955
  collectionId,
4956
+ persistedReadinessTracker,
2732
4957
  )
2733
4958
 
2734
4959
  const result = {
2735
4960
  ...syncOptions,
2736
4961
  id: collectionId,
2737
- sync: createWrappedSyncConfig<T, TKey>(syncOptions.sync, runtime),
4962
+ sync: createWrappedSyncConfig<T, TKey>(
4963
+ syncOptions.sync,
4964
+ runtime,
4965
+ persistedReadinessTracker,
4966
+ ),
2738
4967
  persistence,
4968
+ ...(persistedReadinessTracker
4969
+ ? { [PERSISTED_READINESS]: persistedReadinessTracker }
4970
+ : {}),
2739
4971
  }
2740
4972
 
2741
4973
  return withCollectionConfigFactory(
@@ -2744,11 +4976,15 @@ export function persistedCollectionOptions<
2744
4976
  persistedCollectionOptions({
2745
4977
  ...options,
2746
4978
  id: collectionId,
2747
- }) as typeof result,
4979
+ } as never) as typeof result,
2748
4980
  )
2749
4981
  }
2750
4982
 
2751
- const { schemaVersion, ...localOnlyOptions } = options
4983
+ const {
4984
+ schemaVersion,
4985
+ initialRender: _initialRender,
4986
+ ...localOnlyOptions
4987
+ } = options
2752
4988
  const collectionId =
2753
4989
  localOnlyOptions.id ?? `persisted-collection:${safeRandomUUID()}`
2754
4990
  const persistence = resolvePersistenceForCollection(
@@ -2765,11 +5001,13 @@ export function persistedCollectionOptions<
2765
5001
  persistence,
2766
5002
  localOnlyOptions.syncMode ?? `eager`,
2767
5003
  localOnlyOptions.id ?? DEFAULT_DB_NAME,
5004
+ persistedReadinessTracker,
2768
5005
  )
2769
5006
 
2770
5007
  const wrappedOnInsert = async (
2771
5008
  params: InsertMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2772
5009
  ) => {
5010
+ const lifecycleGeneration = runtime.getLifecycleGeneration()
2773
5011
  const handlerResult = localOnlyOptions.onInsert
2774
5012
  ? await localOnlyOptions.onInsert(
2775
5013
  params as unknown as InsertMutationFnParams<T, TKey, TUtils>,
@@ -2778,6 +5016,7 @@ export function persistedCollectionOptions<
2778
5016
 
2779
5017
  await runtime.persistAndConfirmCollectionMutations(
2780
5018
  params.transaction.mutations as Array<PendingMutation<T>>,
5019
+ lifecycleGeneration,
2781
5020
  )
2782
5021
 
2783
5022
  return handlerResult ?? {}
@@ -2786,6 +5025,7 @@ export function persistedCollectionOptions<
2786
5025
  const wrappedOnUpdate = async (
2787
5026
  params: UpdateMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2788
5027
  ) => {
5028
+ const lifecycleGeneration = runtime.getLifecycleGeneration()
2789
5029
  const handlerResult = localOnlyOptions.onUpdate
2790
5030
  ? await localOnlyOptions.onUpdate(
2791
5031
  params as unknown as UpdateMutationFnParams<T, TKey, TUtils>,
@@ -2794,6 +5034,7 @@ export function persistedCollectionOptions<
2794
5034
 
2795
5035
  await runtime.persistAndConfirmCollectionMutations(
2796
5036
  params.transaction.mutations as Array<PendingMutation<T>>,
5037
+ lifecycleGeneration,
2797
5038
  )
2798
5039
 
2799
5040
  return handlerResult ?? {}
@@ -2802,6 +5043,7 @@ export function persistedCollectionOptions<
2802
5043
  const wrappedOnDelete = async (
2803
5044
  params: DeleteMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2804
5045
  ) => {
5046
+ const lifecycleGeneration = runtime.getLifecycleGeneration()
2805
5047
  const handlerResult = localOnlyOptions.onDelete
2806
5048
  ? await localOnlyOptions.onDelete(
2807
5049
  params as unknown as DeleteMutationFnParams<T, TKey, TUtils>,
@@ -2810,6 +5052,7 @@ export function persistedCollectionOptions<
2810
5052
 
2811
5053
  await runtime.persistAndConfirmCollectionMutations(
2812
5054
  params.transaction.mutations as Array<PendingMutation<T>>,
5055
+ lifecycleGeneration,
2813
5056
  )
2814
5057
 
2815
5058
  return handlerResult ?? {}
@@ -2837,6 +5080,9 @@ export function persistedCollectionOptions<
2837
5080
  ...localOnlyOptions,
2838
5081
  id: collectionId,
2839
5082
  persistence,
5083
+ ...(persistedReadinessTracker
5084
+ ? { [PERSISTED_READINESS]: persistedReadinessTracker }
5085
+ : {}),
2840
5086
  sync: createLoopbackSyncConfig(runtime),
2841
5087
  onInsert: wrappedOnInsert,
2842
5088
  onUpdate: wrappedOnUpdate,
@@ -2852,7 +5098,7 @@ export function persistedCollectionOptions<
2852
5098
  persistedCollectionOptions({
2853
5099
  ...options,
2854
5100
  id: collectionId,
2855
- }) as typeof result,
5101
+ } as never) as typeof result,
2856
5102
  )
2857
5103
  }
2858
5104