@tanstack/db-sqlite-persistence-core 0.2.23 → 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 +1782 -394
  12. package/dist/cjs/persisted.cjs.map +1 -1
  13. package/dist/cjs/persisted.d.cts +176 -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 +176 -11
  36. package/dist/esm/persisted.js +1784 -396
  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 +2617 -442
  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
 
@@ -356,6 +586,74 @@ export interface PersistedCollectionUtils extends UtilsRecord {
356
586
  forceReloadSubset?: (options: LoadSubsetOptions) => Promise<void> | void
357
587
  }
358
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
+
359
657
  export type PersistedSyncWrappedOptions<
360
658
  T extends object,
361
659
  TKey extends string | number,
@@ -365,6 +663,8 @@ export type PersistedSyncWrappedOptions<
365
663
  sync: SyncConfig<T, TKey>
366
664
  persistence: PersistedCollectionPersistence
367
665
  schemaVersion?: number
666
+ /** Opt into network-first rendering with a persisted fallback. */
667
+ initialRender?: PersistedInitialRenderOptions
368
668
  }
369
669
 
370
670
  export type PersistedLocalOnlyOptions<
@@ -375,6 +675,8 @@ export type PersistedLocalOnlyOptions<
375
675
  > = Omit<CollectionConfig<T, TKey, TSchema, TUtils>, `sync`> & {
376
676
  persistence: PersistedCollectionPersistence
377
677
  schemaVersion?: number
678
+ /** Opt into network-first rendering with a persisted fallback. */
679
+ initialRender?: PersistedInitialRenderOptions
378
680
  }
379
681
 
380
682
  type PersistedSyncOptionsResult<
@@ -405,7 +707,11 @@ const REQUIRED_COORDINATOR_METHODS: ReadonlyArray<
405
707
  | `publish`
406
708
  | `isLeader`
407
709
  | `ensureLeadership`
710
+ | `requestEnsureRemoteSubset`
711
+ | `requestReleaseRemoteSubset`
712
+ | `registerRemoteSubsetOwner`
408
713
  | `requestEnsurePersistedIndex`
714
+ | `requestApplyCommittedTx`
409
715
  >
410
716
  > = [
411
717
  `getNodeId`,
@@ -413,15 +719,19 @@ const REQUIRED_COORDINATOR_METHODS: ReadonlyArray<
413
719
  `publish`,
414
720
  `isLeader`,
415
721
  `ensureLeadership`,
722
+ `requestEnsureRemoteSubset`,
723
+ `requestReleaseRemoteSubset`,
724
+ `registerRemoteSubsetOwner`,
416
725
  `requestEnsurePersistedIndex`,
726
+ `requestApplyCommittedTx`,
417
727
  ]
418
728
 
419
729
  const REQUIRED_ADAPTER_METHODS: ReadonlyArray<
420
730
  keyof Pick<
421
731
  PersistenceAdapter,
422
- `loadSubset` | `applyCommittedTx` | `ensureIndex`
732
+ `loadSubset` | `loadResumeSnapshot` | `applyCommittedTx` | `ensureIndex`
423
733
  >
424
- > = [`loadSubset`, `applyCommittedTx`, `ensureIndex`]
734
+ > = [`loadSubset`, `loadResumeSnapshot`, `applyCommittedTx`, `ensureIndex`]
425
735
 
426
736
  const TARGETED_INVALIDATION_KEY_LIMIT = 128
427
737
  const DEFAULT_DB_NAME = `tanstack-db`
@@ -440,6 +750,7 @@ type SyncControlFns<T extends object, TKey extends string | number> = {
440
750
  commit: ((signal?: AbortSignal) => SyncAppliedReceipt) | null
441
751
  truncate: (() => void) | null
442
752
  metadata: SyncMetadataApi<TKey> | null
753
+ markError: ((error: unknown) => void) | null
443
754
  }
444
755
 
445
756
  /**
@@ -448,6 +759,12 @@ type SyncControlFns<T extends object, TKey extends string | number> = {
448
759
  */
449
760
  export class SingleProcessCoordinator implements PersistedCollectionCoordinator {
450
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
+ >()
451
768
 
452
769
  constructor(nodeId: string = safeRandomUUID()) {
453
770
  this.nodeId = nodeId
@@ -469,10 +786,129 @@ export class SingleProcessCoordinator implements PersistedCollectionCoordinator
469
786
 
470
787
  public async ensureLeadership(): Promise<void> {}
471
788
 
472
- 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
+ }
473
876
 
474
877
  public async requestEnsurePersistedIndex(): Promise<void> {}
475
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
+
476
912
  public pullSince(): Promise<PullSinceResponse> {
477
913
  return Promise.resolve({
478
914
  type: `rpc:pullSince:res`,
@@ -509,10 +945,14 @@ function validatePersistenceAdapter(adapter: PersistenceAdapter): void {
509
945
 
510
946
  function resolvePersistence(
511
947
  persistence: PersistedCollectionPersistence,
948
+ collectionId: string,
512
949
  ): PersistedResolvedPersistence {
513
950
  validatePersistenceAdapter(persistence.adapter)
514
951
 
515
952
  const coordinator = persistence.coordinator ?? new SingleProcessCoordinator()
953
+ if (coordinator instanceof SingleProcessCoordinator) {
954
+ coordinator.setAdapterForCollection(collectionId, persistence.adapter)
955
+ }
516
956
  validatePersistedCollectionCoordinator(coordinator)
517
957
 
518
958
  return {
@@ -524,9 +964,13 @@ function resolvePersistence(
524
964
  function resolvePersistenceForMode(
525
965
  persistence: PersistedCollectionPersistence,
526
966
  mode: PersistedCollectionMode,
967
+ collectionId: string,
527
968
  ): PersistedResolvedPersistence {
528
969
  const modeSpecificPersistence = persistence.resolvePersistenceForMode?.(mode)
529
- return resolvePersistence(modeSpecificPersistence ?? persistence)
970
+ return resolvePersistence(
971
+ modeSpecificPersistence ?? persistence,
972
+ collectionId,
973
+ )
530
974
  }
531
975
 
532
976
  function resolvePersistenceForCollection(
@@ -540,10 +984,17 @@ function resolvePersistenceForCollection(
540
984
  const collectionSpecificPersistence =
541
985
  persistence.resolvePersistenceForCollection?.(options)
542
986
  if (collectionSpecificPersistence) {
543
- return resolvePersistence(collectionSpecificPersistence)
987
+ return resolvePersistence(
988
+ collectionSpecificPersistence,
989
+ options.collectionId,
990
+ )
544
991
  }
545
992
 
546
- return resolvePersistenceForMode(persistence, options.mode)
993
+ return resolvePersistenceForMode(
994
+ persistence,
995
+ options.mode,
996
+ options.collectionId,
997
+ )
547
998
  }
548
999
 
549
1000
  function hasOwnSyncKey(options: object): options is { sync: unknown } {
@@ -580,6 +1031,7 @@ type NormalizedSyncOperation<T extends object, TKey extends string | number> =
580
1031
 
581
1032
  type BufferedSyncTransaction<T extends object, TKey extends string | number> = {
582
1033
  operations: Array<NormalizedSyncOperation<T, TKey>>
1034
+ partialUpdateOperationIndexes: Set<number>
583
1035
  rowMetadataWrites: Map<
584
1036
  TKey,
585
1037
  { type: `set`; value: unknown } | { type: `delete` }
@@ -588,44 +1040,95 @@ type BufferedSyncTransaction<T extends object, TKey extends string | number> = {
588
1040
  string,
589
1041
  { type: `set`; value: unknown } | { type: `delete` }
590
1042
  >
1043
+ deferredHydrationMetadataDeleteKeys: Set<TKey>
1044
+ hydrationContext?: { suppliedRowKeys: Set<TKey> }
1045
+ hydrationSequence?: number
591
1046
  truncate: boolean
592
1047
  internal: boolean
1048
+ lifecycleGeneration: number
1049
+ beginOptions?: { immediate?: boolean }
1050
+ expectedResumeGenerationOwner?: symbol
593
1051
  signal?: AbortSignal
1052
+ prependHydrationRows: (
1053
+ rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
1054
+ ) => void
1055
+ applyToCollection: () => SyncAppliedReceipt
1056
+ shouldFailStopOnAbort?: () => boolean
594
1057
  resolveApplied?: () => void
595
1058
  rejectApplied?: (error: unknown) => void
596
1059
  }
597
1060
 
598
- type OpenSyncTransaction<
599
- T extends object,
600
- TKey extends string | number,
601
- > = 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>
602
1068
  queuedBecauseHydrating: boolean
1069
+ hasDependentSuccessor: boolean
1070
+ publicationAdmissionWaiters?: Set<{
1071
+ resolve: () => void
1072
+ reject: (error: unknown) => void
1073
+ }>
1074
+ terminalFailure?: { error: unknown }
603
1075
  }
604
1076
 
605
1077
  type SyncWriteNormalization<T extends object, TKey extends string | number> = {
606
- forwardMessage:
607
- | {
608
- type: `update`
609
- value: T
610
- metadata?: Record<string, unknown>
611
- }
612
- | {
613
- type: `delete`
614
- key: TKey
615
- }
616
1078
  operation: NormalizedSyncOperation<T, TKey>
617
1079
  }
618
1080
 
619
1081
  class ApplyMutex {
620
1082
  private queue: Promise<void> = Promise.resolve()
1083
+ private pending = 0
621
1084
 
622
- async run<T>(task: () => Promise<T>): Promise<T> {
623
- const taskPromise = this.queue.then(() => task())
624
- this.queue = taskPromise.then(
625
- () => undefined,
626
- () => undefined,
627
- )
628
- 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)
629
1132
  }
630
1133
  }
631
1134
 
@@ -640,7 +1143,7 @@ function toStableSerializable(value: unknown): unknown {
640
1143
  case `boolean`:
641
1144
  return value
642
1145
  case `bigint`:
643
- return value.toString()
1146
+ return serializeSQLiteBigInt(value)
644
1147
  case `function`:
645
1148
  case `symbol`:
646
1149
  case `undefined`:
@@ -769,6 +1272,8 @@ function toPersistedMutationEnvelope(
769
1272
  ? (mutation.original as Record<string, unknown>)
770
1273
  : mutation.modified
771
1274
 
1275
+ // PendingMutation.metadata belongs to the optimistic transaction and is
1276
+ // consumed by mutation handlers. It must not overwrite persisted row metadata.
772
1277
  return {
773
1278
  mutationId: mutation.mutationId,
774
1279
  type: mutation.type,
@@ -783,6 +1288,10 @@ class PersistedCollectionRuntime<
783
1288
  > {
784
1289
  private readonly applyMutex = new ApplyMutex()
785
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
786
1295
  private readonly pendingRemoteSubsetEnsures = new Map<
787
1296
  string,
788
1297
  LoadSubsetOptions
@@ -790,8 +1299,12 @@ class PersistedCollectionRuntime<
790
1299
  private readonly queuedHydrationTransactions: Array<
791
1300
  BufferedSyncTransaction<T, TKey>
792
1301
  > = []
793
- private readonly queuedTxCommitted: Array<TxCommitted> = []
1302
+ private readonly queuedTxCommitted: Array<{
1303
+ txCommitted: TxCommitted
1304
+ lifecycleGeneration: number
1305
+ }> = []
794
1306
  private readonly requestIds = new WeakMap<LoadSubsetOptions, string>()
1307
+ private readonly hydratedDemands = new Set<string | undefined>()
795
1308
 
796
1309
  private collection: Collection<T, TKey, PersistedCollectionUtils> | null =
797
1310
  null
@@ -801,21 +1314,35 @@ class PersistedCollectionRuntime<
801
1314
  commit: null,
802
1315
  truncate: null,
803
1316
  metadata: null,
1317
+ markError: null,
804
1318
  }
805
- private started = false
806
1319
  private startupMetadataPromise: Promise<void> | null = null
807
1320
  private startPromise: Promise<void> | null = null
1321
+ private hasAttemptedStartup = false
808
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`)
809
1327
  private lifecycleGeneration = 0
810
1328
  private internalApplyDepth = 0
1329
+ private sourcePublicationWaitDepth = 0
811
1330
  private appliedReceiptSequence = 0
1331
+ private syncErrorReported = false
1332
+ private reportedSyncError: unknown
812
1333
  private readonly pendingAppliedReceipts = new Map<number, Promise<void>>()
813
1334
  private hydratingGeneration: number | null = null
1335
+ private terminalFailure:
1336
+ | { lifecycleGeneration: number; error: unknown }
1337
+ | undefined
814
1338
  private coordinatorUnsubscribe: (() => void) | null = null
1339
+ private remoteSubsetOwnerUnsubscribe: (() => void) | null = null
815
1340
  private indexAddedUnsubscribe: (() => void) | null = null
816
1341
  private indexRemovedUnsubscribe: (() => void) | null = null
817
1342
  private remoteEnsureRetryTimer: ReturnType<typeof setTimeout> | null = null
818
1343
  private nextRequestId = 0
1344
+ private startupSettled = false
1345
+ private sourceTruncateGeneration = 0
819
1346
 
820
1347
  private latestTerm = 0
821
1348
  private latestSeq = 0
@@ -830,10 +1357,13 @@ class PersistedCollectionRuntime<
830
1357
  private readonly persistence: PersistedResolvedPersistence,
831
1358
  private readonly syncMode: `eager` | `on-demand`,
832
1359
  private readonly dbName: string,
1360
+ private readonly persistedReadiness?: PersistedReadinessTracker,
833
1361
  ) {}
834
1362
 
835
1363
  setSyncControls(syncControls: SyncControlFns<T, TKey>): void {
836
1364
  this.advanceLifecycle()
1365
+ this.syncErrorReported = false
1366
+ this.reportedSyncError = undefined
837
1367
 
838
1368
  const commit = syncControls.commit
839
1369
  this.syncControls = {
@@ -844,6 +1374,30 @@ class PersistedCollectionRuntime<
844
1374
  }
845
1375
  }
846
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
+
847
1401
  private trackAppliedReceipt(receipt: SyncAppliedReceipt): SyncAppliedReceipt {
848
1402
  const sequence = ++this.appliedReceiptSequence
849
1403
  if (receipt === true) {
@@ -863,13 +1417,14 @@ class PersistedCollectionRuntime<
863
1417
  )
864
1418
  }
865
1419
 
866
- clearSyncControls(): void {
1420
+ private clearSyncControls(): void {
867
1421
  this.syncControls = {
868
1422
  begin: null,
869
1423
  write: null,
870
1424
  commit: null,
871
1425
  truncate: null,
872
1426
  metadata: null,
1427
+ markError: null,
873
1428
  }
874
1429
  }
875
1430
 
@@ -877,6 +1432,62 @@ class PersistedCollectionRuntime<
877
1432
  return this.hydratingGeneration === this.lifecycleGeneration
878
1433
  }
879
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
1489
+ }
1490
+
880
1491
  isApplyingInternally(): boolean {
881
1492
  return this.internalApplyDepth > 0
882
1493
  }
@@ -899,17 +1510,137 @@ class PersistedCollectionRuntime<
899
1510
  }
900
1511
  }
901
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
+
902
1523
  async ensureStarted(): Promise<void> {
1524
+ this.throwIfTerminal()
903
1525
  if (this.startPromise) {
904
1526
  return this.startPromise
905
1527
  }
906
1528
 
907
1529
  const lifecycleGeneration = this.lifecycleGeneration
908
- this.startPromise = this.startInternal(lifecycleGeneration)
909
- return this.startPromise
910
- }
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)
1639
+ return this.startPromise
1640
+ }
911
1641
 
912
1642
  ensureResumeBaselineHydrated(): Promise<void> {
1643
+ this.throwIfTerminal()
913
1644
  if (this.resumeBaselinePromise) {
914
1645
  return this.resumeBaselinePromise
915
1646
  }
@@ -920,100 +1651,172 @@ class PersistedCollectionRuntime<
920
1651
  if (lifecycleGeneration !== this.lifecycleGeneration) return
921
1652
  if (this.syncMode !== `on-demand`) return
922
1653
 
923
- await this.hydrateBaseline(lifecycleGeneration)
924
- })()
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
+ })
925
1672
  return this.resumeBaselinePromise
926
1673
  }
927
1674
 
928
- private async hydrateBaseline(lifecycleGeneration: number): Promise<void> {
929
- if (lifecycleGeneration !== this.lifecycleGeneration) return
1675
+ ensureResumeBaselineCertified(): Promise<void> {
1676
+ if (this.resumeCertificationPromise) {
1677
+ return this.resumeCertificationPromise
1678
+ }
930
1679
 
1680
+ const lifecycleGeneration = this.lifecycleGeneration
1681
+ this.resumeCertificationPromise = (async () => {
1682
+ await this.ensureStarted()
1683
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1684
+
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
+ }
1697
+
1698
+ getKeySetEvidence(): PersistedKeySetEvidence | undefined {
1699
+ return this.persistedKeySetEvidence
1700
+ }
1701
+
1702
+ getResumeGenerationOwner(): symbol {
1703
+ return this.resumeGenerationOwner
1704
+ }
1705
+
1706
+ private async hydrateBaseline(
1707
+ lifecycleGeneration: number,
1708
+ adapter: HydrationPersistenceAdapter,
1709
+ ): Promise<number | undefined> {
1710
+ if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
1711
+
1712
+ // The baseline shares the unconstrained demand key. Its lease is never
1713
+ // released, and every reload rereads it, so that coverage stays valid.
931
1714
  const baseline = {}
932
1715
  this.activeSubsets.set(this.getSubsetKey(baseline), baseline)
933
1716
  const appliedCursor = this.appliedReceiptSequence
934
- await this.applyMutex.run(async () => {
935
- if (lifecycleGeneration !== this.lifecycleGeneration) return
936
- await this.hydrateSubsetUnsafe(baseline, {
1717
+ await this.hydrateSubsetUnsafe(
1718
+ baseline,
1719
+ {
937
1720
  requestRemoteEnsure: false,
938
1721
  lifecycleGeneration,
939
- })
940
- })
941
- if (lifecycleGeneration !== this.lifecycleGeneration) return
942
- await this.waitForAppliedReceiptsAfter(appliedCursor)
1722
+ bindKeySetEvidence: true,
1723
+ },
1724
+ adapter,
1725
+ )
1726
+ return lifecycleGeneration === this.lifecycleGeneration
1727
+ ? appliedCursor
1728
+ : undefined
943
1729
  }
944
1730
 
945
1731
  async ensureStartupMetadataLoaded(): Promise<void> {
1732
+ this.throwIfTerminal()
946
1733
  if (this.startupMetadataPromise) {
947
1734
  return this.startupMetadataPromise
948
1735
  }
949
1736
 
950
- const lifecycleGeneration = this.lifecycleGeneration
951
- this.startupMetadataPromise =
952
- this.loadStartupMetadataInternal(lifecycleGeneration)
953
- return this.startupMetadataPromise
1737
+ void this.ensureStarted()
1738
+ return this.startupMetadataPromise!
954
1739
  }
955
1740
 
956
- private async startInternal(lifecycleGeneration: number): Promise<void> {
957
- if (this.started) {
958
- return
959
- }
960
-
961
- this.started = true
962
-
963
- await this.ensureStartupMetadataLoaded()
964
- if (lifecycleGeneration !== this.lifecycleGeneration) return
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
1752
+ > {
1753
+ if (lifecycleGeneration !== this.lifecycleGeneration) return undefined
965
1754
 
966
1755
  const indexBootstrapSnapshot = this.collection?.getIndexMetadata() ?? []
967
1756
  this.attachIndexLifecycleListeners()
968
- await this.bootstrapPersistedIndexes(indexBootstrapSnapshot)
969
- if (lifecycleGeneration !== this.lifecycleGeneration) return
970
-
971
- if (this.syncMode !== `on-demand`) {
972
- await this.hydrateBaseline(lifecycleGeneration)
973
- }
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
974
1778
  }
975
1779
 
976
1780
  private async loadStartupMetadataInternal(
977
1781
  lifecycleGeneration: number,
1782
+ adapter: HydrationPersistenceAdapter,
978
1783
  ): Promise<void> {
979
- // Restore stream position from the database so that new mutations
980
- // don't collide with previously applied transactions.
981
- if (this.persistence.adapter.getStreamPosition) {
982
- const position = await this.persistence.adapter.getStreamPosition(
983
- this.collectionId,
984
- )
985
- if (lifecycleGeneration !== this.lifecycleGeneration) return
986
- this.observeStreamPosition(
987
- position.latestTerm,
988
- position.latestSeq,
989
- position.latestRowVersion,
990
- )
991
- }
992
-
993
- const collectionMetadata = await this.loadCollectionMetadataSnapshot()
1784
+ const snapshot = await adapter.loadResumeSnapshot(this.collectionId, {
1785
+ includeRows: false,
1786
+ })
994
1787
  if (lifecycleGeneration !== this.lifecycleGeneration) return
995
- this.replaceCollectionMetadataSnapshot(collectionMetadata)
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
996
1799
  }
997
1800
 
998
- private async loadCollectionMetadataSnapshot(): Promise<
999
- Array<{ key: string; value: unknown }>
1000
- > {
1001
- if (!this.persistence.adapter.loadCollectionMetadata) {
1801
+ private async loadCollectionMetadataSnapshot(
1802
+ adapter: HydrationPersistenceAdapter,
1803
+ ): Promise<Array<{ key: string; value: unknown }>> {
1804
+ if (!adapter.loadCollectionMetadata) {
1002
1805
  return []
1003
1806
  }
1004
1807
 
1005
- return this.persistence.adapter.loadCollectionMetadata(this.collectionId)
1808
+ return adapter.loadCollectionMetadata(this.collectionId)
1006
1809
  }
1007
1810
 
1008
1811
  private replaceCollectionMetadataSnapshot(
1009
1812
  collectionMetadata: Array<{ key: string; value: unknown }>,
1010
- ): void {
1813
+ ): SyncAppliedReceipt {
1011
1814
  if (
1012
1815
  !this.syncControls.begin ||
1013
1816
  !this.syncControls.commit ||
1014
1817
  !this.syncControls.metadata
1015
1818
  ) {
1016
- return
1819
+ return true
1017
1820
  }
1018
1821
 
1019
1822
  const nextMetadata = new Map(
@@ -1023,7 +1826,7 @@ class PersistedCollectionRuntime<
1023
1826
  .list()
1024
1827
  .map(({ key }) => key)
1025
1828
 
1026
- this.withInternalApply(() => {
1829
+ return this.withInternalApply(() => {
1027
1830
  this.syncControls.begin?.({ immediate: true })
1028
1831
 
1029
1832
  currentKeys.forEach((key) => {
@@ -1036,7 +1839,7 @@ class PersistedCollectionRuntime<
1036
1839
  this.syncControls.metadata?.collection.set(key, value)
1037
1840
  })
1038
1841
 
1039
- this.syncControls.commit?.()
1842
+ return this.syncControls.commit?.() ?? true
1040
1843
  })
1041
1844
  }
1042
1845
 
@@ -1044,22 +1847,62 @@ class PersistedCollectionRuntime<
1044
1847
  options: LoadSubsetOptions,
1045
1848
  upstreamLoadSubset?: LoadSubsetFn,
1046
1849
  ): Promise<void> {
1850
+ this.throwIfTerminal()
1047
1851
  const lifecycleGeneration = this.lifecycleGeneration
1048
- this.activeSubsets.set(this.getSubsetKey(options), options)
1049
-
1852
+ const subsetKey = this.getSubsetKey(options)
1853
+ const truncateGeneration = this.sourceTruncateGeneration
1854
+ const routeRemoteDemandDuringHydration =
1855
+ this.canRouteRemoteDemandThroughCoordinator()
1856
+ this.activeSubsets.set(subsetKey, options)
1050
1857
  const appliedCursor = this.appliedReceiptSequence
1051
- await this.applyMutex.run(() =>
1052
- this.hydrateSubsetUnsafe(options, {
1053
- requestRemoteEnsure: this.mode === `sync-present`,
1054
- lifecycleGeneration,
1055
- }),
1056
- )
1057
- if (lifecycleGeneration !== this.lifecycleGeneration) return
1058
- 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
+ }
1059
1892
 
1060
- 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()) {
1061
1901
  try {
1062
- await upstreamLoadSubset(options)
1902
+ await this.persistence.coordinator.requestEnsureRemoteSubset(
1903
+ this.collectionId,
1904
+ options,
1905
+ )
1063
1906
  } catch (error) {
1064
1907
  if (
1065
1908
  options.signal?.aborted ||
@@ -1071,46 +1914,177 @@ class PersistedCollectionRuntime<
1071
1914
  this.pendingRemoteSubsetEnsures.delete(this.getSubsetKey(options))
1072
1915
  throw error
1073
1916
  }
1074
- console.warn(`Failed to trigger remote subset load:`, error)
1075
1917
  this.queueRemoteSubsetEnsure(options)
1076
- // Hydration remains readable, but it does not satisfy remote demand.
1077
1918
  throw error
1078
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
1936
+ }
1937
+ }
1938
+ }
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)
1079
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()
1080
2000
  }
1081
2001
 
1082
2002
  unloadSubset(
1083
2003
  options: LoadSubsetOptions,
1084
2004
  upstreamUnloadSubset?: (options: LoadSubsetOptions) => void,
1085
2005
  ): void {
1086
- this.activeSubsets.delete(this.getSubsetKey(options))
1087
- this.pendingRemoteSubsetEnsures.delete(this.getSubsetKey(options))
1088
- 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
+ }
1089
2035
  }
1090
2036
 
1091
2037
  async forceReloadSubset(options: LoadSubsetOptions): Promise<void> {
2038
+ this.throwIfTerminal()
1092
2039
  const lifecycleGeneration = this.lifecycleGeneration
1093
2040
  // A one-shot refresh does not acquire an enduring subscription lease.
1094
- await this.applyMutex.run(() =>
1095
- this.hydrateSubsetUnsafe(options, {
1096
- requestRemoteEnsure: false,
1097
- lifecycleGeneration,
1098
- }),
1099
- )
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
+ })
1100
2057
  }
1101
2058
 
1102
2059
  queueHydrationBufferedTransaction(
1103
2060
  transaction: BufferedSyncTransaction<T, TKey>,
1104
2061
  ): void {
2062
+ const failure = this.getCurrentTerminalFailure()
2063
+ if (failure) {
2064
+ transaction.rejectApplied?.(failure.error)
2065
+ return
2066
+ }
1105
2067
  this.queuedHydrationTransactions.push(transaction)
1106
2068
  }
1107
2069
 
1108
- async persistAndBroadcastExternalSyncTransaction(
2070
+ applyHydrationBufferedTransaction(
1109
2071
  transaction: BufferedSyncTransaction<T, TKey>,
1110
2072
  ): Promise<void> {
1111
- await this.applyMutex.run(() =>
1112
- this.persistAndBroadcastExternalSyncTransactionUnsafe(transaction),
1113
- )
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
+ })
1114
2088
  }
1115
2089
 
1116
2090
  normalizeSyncWriteMessage(
@@ -1127,10 +2101,6 @@ class PersistedCollectionRuntime<
1127
2101
  const previousValue = this.collection.get(key) ?? ({} as T)
1128
2102
 
1129
2103
  return {
1130
- forwardMessage: {
1131
- type: `delete`,
1132
- key,
1133
- },
1134
2104
  operation: {
1135
2105
  type: `delete`,
1136
2106
  key,
@@ -1146,10 +2116,6 @@ class PersistedCollectionRuntime<
1146
2116
  const previousValue = this.collection.get(key) ?? message.value
1147
2117
 
1148
2118
  return {
1149
- forwardMessage: {
1150
- type: `delete`,
1151
- key,
1152
- },
1153
2119
  operation: {
1154
2120
  type: `delete`,
1155
2121
  key,
@@ -1160,11 +2126,6 @@ class PersistedCollectionRuntime<
1160
2126
 
1161
2127
  const key = this.collection.getKeyFromItem(message.value)
1162
2128
  return {
1163
- forwardMessage: {
1164
- type: `update`,
1165
- value: message.value,
1166
- metadata: message.metadata,
1167
- },
1168
2129
  operation: {
1169
2130
  type: `update`,
1170
2131
  key,
@@ -1176,32 +2137,55 @@ class PersistedCollectionRuntime<
1176
2137
 
1177
2138
  async persistAndConfirmCollectionMutations(
1178
2139
  mutations: Array<PendingMutation<T>>,
2140
+ lifecycleGeneration = this.lifecycleGeneration,
1179
2141
  ): Promise<void> {
2142
+ this.throwIfTerminal()
2143
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
1180
2144
  if (mutations.length === 0) {
1181
2145
  return
1182
2146
  }
1183
2147
 
1184
- await this.applyMutex.run(async () => {
1185
- const acceptedMutationIds =
1186
- await this.persistCollectionMutationsUnsafe(mutations)
1187
- const acceptedMutationIdSet = new Set(acceptedMutationIds)
1188
- const acceptedMutations = mutations.filter((mutation) =>
1189
- acceptedMutationIdSet.has(mutation.mutationId),
1190
- )
1191
-
1192
- if (acceptedMutations.length !== mutations.length) {
1193
- throw new Error(
1194
- `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),
1195
2163
  )
1196
- }
1197
2164
 
1198
- this.confirmMutationsSyncUnsafe(acceptedMutations)
1199
- })
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
+ }
1200
2183
  }
1201
2184
 
1202
2185
  async acceptTransactionMutations(transaction: {
1203
2186
  mutations: Array<PendingMutation<Record<string, unknown>>>
1204
2187
  }): Promise<void> {
2188
+ this.throwIfTerminal()
1205
2189
  const collectionMutations = this.filterMutationsForCollection(
1206
2190
  transaction.mutations,
1207
2191
  )
@@ -1213,16 +2197,42 @@ class PersistedCollectionRuntime<
1213
2197
  await this.persistAndConfirmCollectionMutations(collectionMutations)
1214
2198
  }
1215
2199
 
1216
- cleanup(): void {
2200
+ cleanup(): Promise<void> {
1217
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
+ }
1218
2211
 
1219
- this.coordinatorUnsubscribe?.()
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?.())
1220
2227
  this.coordinatorUnsubscribe = null
1221
2228
 
1222
- this.indexAddedUnsubscribe?.()
2229
+ attempt(() => this.remoteSubsetOwnerUnsubscribe?.())
2230
+ this.remoteSubsetOwnerUnsubscribe = null
2231
+
2232
+ attempt(() => this.indexAddedUnsubscribe?.())
1223
2233
  this.indexAddedUnsubscribe = null
1224
2234
 
1225
- this.indexRemovedUnsubscribe?.()
2235
+ attempt(() => this.indexRemovedUnsubscribe?.())
1226
2236
  this.indexRemovedUnsubscribe = null
1227
2237
 
1228
2238
  if (this.remoteEnsureRetryTimer !== null) {
@@ -1232,6 +2242,7 @@ class PersistedCollectionRuntime<
1232
2242
 
1233
2243
  this.pendingRemoteSubsetEnsures.clear()
1234
2244
  this.activeSubsets.clear()
2245
+ this.hydratedDemands.clear()
1235
2246
  for (const transaction of this.queuedHydrationTransactions) {
1236
2247
  transaction.rejectApplied?.(new SyncTransactionAbortedError())
1237
2248
  }
@@ -1239,14 +2250,29 @@ class PersistedCollectionRuntime<
1239
2250
  this.queuedTxCommitted.length = 0
1240
2251
  this.clearSyncControls()
1241
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
+ })
1242
2261
  }
1243
2262
 
1244
2263
  private advanceLifecycle(): void {
1245
2264
  this.lifecycleGeneration++
1246
- this.started = false
2265
+ this.persistedReadiness?.set({ status: `loading` })
2266
+ this.startupSettled = false
2267
+ this.hydratedDemands.clear()
1247
2268
  this.startupMetadataPromise = null
1248
2269
  this.startPromise = null
1249
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`)
1250
2276
  }
1251
2277
 
1252
2278
  private withInternalApply<TResult>(task: () => TResult): TResult {
@@ -1270,8 +2296,9 @@ class PersistedCollectionRuntime<
1270
2296
 
1271
2297
  private loadSubsetRowsUnsafe(
1272
2298
  options: LoadSubsetOptions,
2299
+ adapter: HydrationPersistenceAdapter,
1273
2300
  ): Promise<Array<{ key: TKey; value: T; metadata?: unknown }>> {
1274
- return this.persistence.adapter.loadSubset(this.collectionId, options, {
2301
+ return adapter.loadSubset(this.collectionId, options, {
1275
2302
  requiredIndexSignatures: this.getRequiredIndexSignatures(),
1276
2303
  }) as Promise<Array<{ key: TKey; value: T; metadata?: unknown }>>
1277
2304
  }
@@ -1292,6 +2319,7 @@ class PersistedCollectionRuntime<
1292
2319
  async scanPersistedRows(
1293
2320
  options?: PersistedRowScanOptions,
1294
2321
  ): Promise<Array<PersistedScannedRow<T, TKey>>> {
2322
+ this.throwIfTerminal()
1295
2323
  return this.applyMutex.run(() => this.scanPersistedRowsUnsafe(options))
1296
2324
  }
1297
2325
 
@@ -1300,40 +2328,248 @@ class PersistedCollectionRuntime<
1300
2328
  config: {
1301
2329
  requestRemoteEnsure: boolean
1302
2330
  lifecycleGeneration: number
2331
+ bindKeySetEvidence?: boolean
2332
+ requestLocalLoadFailure?: boolean
2333
+ rejectBufferedReplayFailure?: boolean
1303
2334
  },
2335
+ adapter: HydrationPersistenceAdapter,
1304
2336
  ): Promise<void> {
1305
- this.hydratingGeneration = config.lifecycleGeneration
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
1306
2343
  try {
1307
- 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
+ }
2386
+
2387
+ if (config.lifecycleGeneration !== this.lifecycleGeneration) return
2388
+ replayFailure = await this.flushQueuedHydrationTransactionsUnsafe(adapter)
1308
2389
  if (config.lifecycleGeneration !== this.lifecycleGeneration) return
1309
2390
 
1310
- this.applyRowsToCollection(rows)
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)
1311
2421
  } finally {
1312
- if (this.hydratingGeneration === config.lifecycleGeneration) {
1313
- this.hydratingGeneration = null
2422
+ if (this.activeHydrationContext === hydrationContext) {
2423
+ this.activeHydrationContext = undefined
1314
2424
  }
1315
2425
  }
2426
+ if (replayFailure && config.rejectBufferedReplayFailure) {
2427
+ throw replayFailure.reason
2428
+ }
2429
+ }
1316
2430
 
1317
- await this.flushQueuedHydrationTransactionsUnsafe()
1318
- await this.flushQueuedTxCommittedUnsafe()
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
+ }
2528
+
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
+ }
1319
2553
 
1320
- if (config.requestRemoteEnsure) {
1321
- this.queueRemoteSubsetEnsure(options)
2554
+ private rejectQueuedHydrationTransactions(error: unknown): void {
2555
+ for (const transaction of this.queuedHydrationTransactions) {
2556
+ transaction.rejectApplied?.(error)
1322
2557
  }
2558
+ this.queuedHydrationTransactions.length = 0
1323
2559
  }
1324
2560
 
1325
2561
  private applyRowsToCollection(
1326
2562
  rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
1327
- ): void {
2563
+ ): SyncAppliedReceipt {
1328
2564
  if (
1329
2565
  !this.syncControls.begin ||
1330
2566
  !this.syncControls.write ||
1331
2567
  !this.syncControls.commit
1332
2568
  ) {
1333
- return
2569
+ return true
1334
2570
  }
1335
2571
 
1336
- this.withInternalApply(() => {
2572
+ return this.withInternalApply(() => {
1337
2573
  this.syncControls.begin?.({ immediate: true })
1338
2574
 
1339
2575
  for (const row of rows) {
@@ -1347,21 +2583,81 @@ class PersistedCollectionRuntime<
1347
2583
  })
1348
2584
  }
1349
2585
 
1350
- this.syncControls.commit?.()
2586
+ return this.syncControls.commit?.() ?? true
1351
2587
  })
1352
2588
  }
1353
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
+
1354
2650
  private replaceCollectionSnapshot(
1355
2651
  rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
1356
2652
  collectionMetadata: Array<{ key: string; value: unknown }>,
1357
- ): void {
2653
+ ): SyncAppliedReceipt {
1358
2654
  if (
1359
2655
  !this.syncControls.begin ||
1360
2656
  !this.syncControls.write ||
1361
2657
  !this.syncControls.commit ||
1362
2658
  !this.syncControls.metadata
1363
2659
  ) {
1364
- return
2660
+ return true
1365
2661
  }
1366
2662
 
1367
2663
  const nextMetadata = new Map(
@@ -1371,7 +2667,7 @@ class PersistedCollectionRuntime<
1371
2667
  .list()
1372
2668
  .map(({ key }) => key)
1373
2669
 
1374
- this.withInternalApply(() => {
2670
+ return this.withInternalApply(() => {
1375
2671
  this.syncControls.begin?.({ immediate: true })
1376
2672
  this.syncControls.truncate?.()
1377
2673
 
@@ -1393,18 +2689,25 @@ class PersistedCollectionRuntime<
1393
2689
  this.syncControls.metadata?.collection.set(key, value)
1394
2690
  })
1395
2691
 
1396
- this.syncControls.commit?.()
2692
+ return this.syncControls.commit?.() ?? true
1397
2693
  })
1398
2694
  }
1399
2695
 
1400
- private async flushQueuedHydrationTransactionsUnsafe(): Promise<void> {
2696
+ private async flushQueuedHydrationTransactionsUnsafe(
2697
+ adapter: HydrationPersistenceAdapter,
2698
+ ): Promise<{ reason: unknown } | undefined> {
2699
+ let operationFailure: { reason: unknown } | undefined
1401
2700
  while (this.queuedHydrationTransactions.length > 0) {
1402
2701
  const transaction = this.queuedHydrationTransactions.shift()
1403
2702
  if (!transaction) {
1404
2703
  continue
1405
2704
  }
1406
2705
  try {
1407
- await this.applyBufferedSyncTransactionUnsafe(transaction)
2706
+ const outcome = await this.applyBufferedSyncTransactionUnsafe(
2707
+ transaction,
2708
+ adapter,
2709
+ )
2710
+ operationFailure ??= outcome.hydrationFailure
1408
2711
  } catch (error) {
1409
2712
  transaction.rejectApplied?.(error)
1410
2713
  for (const abandoned of this.queuedHydrationTransactions) {
@@ -1414,133 +2717,168 @@ class PersistedCollectionRuntime<
1414
2717
  throw error
1415
2718
  }
1416
2719
  }
2720
+ return operationFailure
1417
2721
  }
1418
2722
 
1419
2723
  private async applyBufferedSyncTransactionUnsafe(
1420
2724
  transaction: BufferedSyncTransaction<T, TKey>,
1421
- ): Promise<void> {
1422
- if (transaction.signal?.aborted) {
1423
- transaction.rejectApplied?.(new SyncTransactionAbortedError())
1424
- return
1425
- }
1426
-
1427
- const { begin, write, commit, truncate, metadata } = this.syncControls
1428
- if (!begin || !write || !commit) {
1429
- transaction.rejectApplied?.(new SyncTransactionAbortedError())
1430
- return
1431
- }
1432
-
1433
- const applyToCollection = (): SyncAppliedReceipt => {
1434
- begin()
1435
-
1436
- if (transaction.truncate) {
1437
- truncate?.()
1438
- }
1439
-
1440
- for (const operation of transaction.operations) {
1441
- if (operation.type === `delete`) {
1442
- write({
1443
- type: `delete`,
1444
- key: operation.key,
1445
- })
1446
- } else {
1447
- write({
1448
- type: `update`,
1449
- value: operation.value,
1450
- metadata: operation.metadata,
1451
- })
1452
- }
1453
- }
1454
-
1455
- for (const [key, metadataWrite] of transaction.rowMetadataWrites) {
1456
- if (metadataWrite.type === `delete`) {
1457
- metadata?.row.delete(key)
1458
- } else {
1459
- metadata?.row.set(key, metadataWrite.value)
1460
- }
1461
- }
1462
-
1463
- for (const [key, metadataWrite] of transaction.collectionMetadataWrites) {
1464
- if (metadataWrite.type === `delete`) {
1465
- metadata?.collection.delete(key)
1466
- } else {
1467
- metadata?.collection.set(key, metadataWrite.value)
1468
- }
1469
- }
1470
-
1471
- return commit(transaction.signal)
1472
- }
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
1473
2735
 
1474
2736
  try {
1475
2737
  const applied = transaction.internal
1476
- ? this.withInternalApply(applyToCollection)
1477
- : applyToCollection()
2738
+ ? this.withInternalApply(transaction.applyToCollection)
2739
+ : transaction.applyToCollection()
2740
+ applicationReturned = true
2741
+ abortedDuringApplication = transaction.signal?.aborted === true
1478
2742
  if (applied !== true) {
1479
- await applied
2743
+ this.sourcePublicationWaitDepth++
2744
+ try {
2745
+ await applied
2746
+ } finally {
2747
+ this.sourcePublicationWaitDepth--
2748
+ }
1480
2749
  }
2750
+ this.throwIfLifecycleReplaced(transaction.lifecycleGeneration)
1481
2751
 
1482
2752
  if (!transaction.internal) {
1483
- await this.persistAndBroadcastExternalSyncTransactionUnsafe(transaction)
2753
+ await this.persistAndBroadcastExternalSyncTransactionUnsafe(
2754
+ transaction,
2755
+ scopedAdapter,
2756
+ )
1484
2757
  }
1485
2758
  transaction.resolveApplied?.()
2759
+ return { applied: true }
1486
2760
  } catch (error) {
1487
- transaction.rejectApplied?.(error)
1488
- 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
2784
+ }
2785
+ }
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)
1489
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)
1490
2801
  }
1491
2802
 
1492
2803
  private async persistAndBroadcastExternalSyncTransactionUnsafe(
1493
2804
  transaction: BufferedSyncTransaction<T, TKey>,
2805
+ scopedAdapter?: HydrationPersistenceAdapter,
1494
2806
  ): Promise<void> {
1495
2807
  if (transaction.internal) {
1496
2808
  return
1497
2809
  }
1498
2810
 
1499
- const streamPosition = this.nextLocalStreamPosition()
1500
-
2811
+ this.throwIfLifecycleReplaced(transaction.lifecycleGeneration)
1501
2812
  if (
1502
2813
  !transaction.truncate &&
1503
2814
  transaction.operations.length === 0 &&
1504
2815
  transaction.rowMetadataWrites.size === 0 &&
1505
2816
  transaction.collectionMetadataWrites.size === 0
1506
2817
  ) {
1507
- this.publishTxCommittedEvent(
1508
- this.createTxCommittedPayload({
1509
- term: streamPosition.term,
1510
- seq: streamPosition.seq,
1511
- txId: safeRandomUUID(),
1512
- latestRowVersion: streamPosition.rowVersion,
1513
- changedRows: [],
1514
- deletedKeys: [],
1515
- requiresFullReload: true,
1516
- }),
1517
- )
1518
2818
  return
1519
2819
  }
1520
2820
 
2821
+ const streamPosition = this.nextLocalStreamPosition()
1521
2822
  const tx = this.createPersistedTxFromOperations(transaction, streamPosition)
1522
-
1523
- await this.persistence.adapter.applyCommittedTx(this.collectionId, tx)
1524
- this.publishTxCommittedEvent(
1525
- this.createTxCommittedPayload({
1526
- term: tx.term,
1527
- seq: tx.seq,
1528
- txId: tx.txId,
1529
- latestRowVersion: tx.rowVersion,
1530
- requiresFullReload: transaction.truncate,
1531
- changedRows: transaction.operations
1532
- .filter((operation) => operation.type === `update`)
1533
- .map((operation) => ({
1534
- key: operation.key,
1535
- value: operation.value as Record<string, unknown>,
1536
- })),
1537
- deletedKeys: transaction.operations
1538
- .filter((operation) => operation.type === `delete`)
1539
- .map((operation) => operation.key),
1540
- rowMetadataMutations: tx.rowMetadataMutations,
1541
- collectionMetadataMutations: tx.collectionMetadataMutations,
1542
- }),
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,
1543
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,
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)
1544
2882
  }
1545
2883
 
1546
2884
  private createPersistedTxFromOperations(
@@ -1622,9 +2960,9 @@ class PersistedCollectionRuntime<
1622
2960
  }
1623
2961
  }
1624
2962
 
1625
- private confirmMutationsSyncUnsafe(
2963
+ private async confirmMutationsSyncUnsafe(
1626
2964
  mutations: Array<PendingMutation<T>>,
1627
- ): void {
2965
+ ): Promise<void> {
1628
2966
  if (
1629
2967
  !this.syncControls.begin ||
1630
2968
  !this.syncControls.write ||
@@ -1633,7 +2971,7 @@ class PersistedCollectionRuntime<
1633
2971
  return
1634
2972
  }
1635
2973
 
1636
- this.withInternalApply(() => {
2974
+ const applied = this.withInternalApply(() => {
1637
2975
  this.syncControls.begin?.({ immediate: true })
1638
2976
 
1639
2977
  for (const mutation of mutations) {
@@ -1650,8 +2988,12 @@ class PersistedCollectionRuntime<
1650
2988
  }
1651
2989
  }
1652
2990
 
1653
- this.syncControls.commit?.()
2991
+ return this.syncControls.commit?.() ?? true
1654
2992
  })
2993
+
2994
+ if (applied !== true) {
2995
+ await applied
2996
+ }
1655
2997
  }
1656
2998
 
1657
2999
  private filterMutationsForCollection(
@@ -1668,7 +3010,9 @@ class PersistedCollectionRuntime<
1668
3010
 
1669
3011
  private async persistCollectionMutationsUnsafe(
1670
3012
  mutations: Array<PendingMutation<T>>,
3013
+ lifecycleGeneration: number,
1671
3014
  ): Promise<Array<string>> {
3015
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
1672
3016
  // When a coordinator with requestApplyLocalMutations is available, always
1673
3017
  // route through it — even on the leader tab. This ensures the coordinator's
1674
3018
  // seq/rowVersion counters stay in sync with actual writes. Without this,
@@ -1688,7 +3032,20 @@ class PersistedCollectionRuntime<
1688
3032
  envelopeMutations,
1689
3033
  )
1690
3034
 
3035
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
3036
+
1691
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
+ }
1692
3049
  throw new Error(
1693
3050
  `failed to apply local mutations through coordinator: ${response.error}`,
1694
3051
  )
@@ -1723,7 +3080,9 @@ class PersistedCollectionRuntime<
1723
3080
  // SingleProcessCoordinator). Apply directly and broadcast.
1724
3081
  const streamPosition = this.nextLocalStreamPosition()
1725
3082
  const tx = this.createPersistedTxFromMutations(mutations, streamPosition)
1726
- await this.persistence.adapter.applyCommittedTx(this.collectionId, tx)
3083
+ await this.applyCommittedTx(tx, lifecycleGeneration)
3084
+
3085
+ this.throwIfLifecycleReplaced(lifecycleGeneration)
1727
3086
 
1728
3087
  this.publishTxCommittedEvent(
1729
3088
  this.createTxCommittedPayload({
@@ -1868,23 +3227,44 @@ class PersistedCollectionRuntime<
1868
3227
  return id
1869
3228
  }
1870
3229
 
1871
- private queueRemoteSubsetEnsure(options: LoadSubsetOptions): void {
3230
+ private canRouteRemoteDemandThroughCoordinator(): boolean {
1872
3231
  if (
3232
+ this.getCurrentTerminalFailure() ||
1873
3233
  this.mode !== `sync-present` ||
1874
- !this.persistence.coordinator.requestEnsureRemoteSubset ||
3234
+ this.persistence.coordinator instanceof SingleProcessCoordinator
3235
+ ) {
3236
+ return false
3237
+ }
3238
+
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
+ )
3245
+ }
3246
+
3247
+ private queueRemoteSubsetEnsure(options: LoadSubsetOptions): void {
3248
+ if (
3249
+ options.signal?.aborted ||
3250
+ !this.canRouteRemoteDemandThroughCoordinator() ||
1875
3251
  this.activeSubsets.get(this.getSubsetKey(options)) !== options
1876
3252
  ) {
1877
3253
  return
1878
3254
  }
1879
3255
 
1880
- 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)
1881
3260
  void this.flushPendingRemoteSubsetEnsures()
1882
3261
  }
1883
3262
 
1884
3263
  private scheduleRemoteEnsureRetry(): void {
1885
3264
  if (
3265
+ this.getCurrentTerminalFailure() ||
1886
3266
  this.mode !== `sync-present` ||
1887
- !this.persistence.coordinator.requestEnsureRemoteSubset
3267
+ this.persistence.coordinator instanceof SingleProcessCoordinator
1888
3268
  ) {
1889
3269
  return
1890
3270
  }
@@ -1904,8 +3284,9 @@ class PersistedCollectionRuntime<
1904
3284
 
1905
3285
  private async flushPendingRemoteSubsetEnsures(): Promise<void> {
1906
3286
  if (
3287
+ this.getCurrentTerminalFailure() ||
1907
3288
  this.mode !== `sync-present` ||
1908
- !this.persistence.coordinator.requestEnsureRemoteSubset
3289
+ this.persistence.coordinator instanceof SingleProcessCoordinator
1909
3290
  ) {
1910
3291
  return
1911
3292
  }
@@ -1916,6 +3297,13 @@ class PersistedCollectionRuntime<
1916
3297
  }
1917
3298
 
1918
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
+ }
1919
3307
  try {
1920
3308
  await this.persistence.coordinator.requestEnsureRemoteSubset(
1921
3309
  this.collectionId,
@@ -1923,7 +3311,14 @@ class PersistedCollectionRuntime<
1923
3311
  )
1924
3312
  this.pendingRemoteSubsetEnsures.delete(subsetKey)
1925
3313
  } catch (error) {
1926
- 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
+ }
1927
3322
  }
1928
3323
  }
1929
3324
 
@@ -1935,6 +3330,10 @@ class PersistedCollectionRuntime<
1935
3330
  return
1936
3331
  }
1937
3332
 
3333
+ this.persistence.coordinator.setAdapterForCollection?.(
3334
+ this.collectionId,
3335
+ this.persistence.adapter,
3336
+ )
1938
3337
  this.coordinatorUnsubscribe = this.persistence.coordinator.subscribe(
1939
3338
  this.collectionId,
1940
3339
  (message) => {
@@ -1947,8 +3346,12 @@ class PersistedCollectionRuntime<
1947
3346
  if (message.collectionId !== this.collectionId) {
1948
3347
  return
1949
3348
  }
3349
+ if (this.getCurrentTerminalFailure()) {
3350
+ return
3351
+ }
1950
3352
 
1951
3353
  const { payload } = message
3354
+ const lifecycleGeneration = this.lifecycleGeneration
1952
3355
  const isSelf = message.senderId === this.persistence.coordinator.getNodeId()
1953
3356
 
1954
3357
  // Allow tx:committed from self — the coordinator produces these on behalf
@@ -1956,14 +3359,17 @@ class PersistedCollectionRuntime<
1956
3359
  // processCommittedTxUnsafe prevents double-processing of our own writes.
1957
3360
  if (isTxCommittedPayload(payload)) {
1958
3361
  if (this.isHydratingNow()) {
1959
- this.queuedTxCommitted.push(payload)
3362
+ this.queuedTxCommitted.push({
3363
+ txCommitted: payload,
3364
+ lifecycleGeneration,
3365
+ })
1960
3366
  return
1961
3367
  }
1962
3368
 
1963
3369
  void this.applyMutex
1964
- .run(() => this.processCommittedTxUnsafe(payload))
3370
+ .run(() => this.processCommittedTxUnsafe(payload, lifecycleGeneration))
1965
3371
  .catch((error) => {
1966
- console.warn(`Failed to process tx:committed message:`, error)
3372
+ this.markTerminalFailure(error, lifecycleGeneration)
1967
3373
  })
1968
3374
  return
1969
3375
  }
@@ -1975,9 +3381,16 @@ class PersistedCollectionRuntime<
1975
3381
 
1976
3382
  if (isCollectionResetPayload(payload)) {
1977
3383
  void this.applyMutex
1978
- .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
+ })
1979
3392
  .catch((error) => {
1980
- console.warn(`Failed to process collection reset message:`, error)
3393
+ this.markTerminalFailure(error, lifecycleGeneration)
1981
3394
  })
1982
3395
  }
1983
3396
  }
@@ -1988,13 +3401,18 @@ class PersistedCollectionRuntime<
1988
3401
  if (!queued) {
1989
3402
  continue
1990
3403
  }
1991
- await this.processCommittedTxUnsafe(queued)
3404
+ await this.processCommittedTxUnsafe(
3405
+ queued.txCommitted,
3406
+ queued.lifecycleGeneration,
3407
+ )
1992
3408
  }
1993
3409
  }
1994
3410
 
1995
3411
  private async processCommittedTxUnsafe(
1996
3412
  txCommitted: TxCommitted,
3413
+ lifecycleGeneration = this.lifecycleGeneration,
1997
3414
  ): Promise<void> {
3415
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
1998
3416
  if (txCommitted.term < this.latestTerm) {
1999
3417
  return
2000
3418
  }
@@ -2014,7 +3432,8 @@ class PersistedCollectionRuntime<
2014
3432
  const hasGap = hasGapInCurrentTerm || hasGapAcrossTerms
2015
3433
 
2016
3434
  if (hasGap) {
2017
- await this.recoverFromSeqGapUnsafe()
3435
+ await this.recoverFromSeqGapUnsafe(lifecycleGeneration)
3436
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2018
3437
  if (
2019
3438
  txCommitted.term < this.latestTerm ||
2020
3439
  (txCommitted.term === this.latestTerm &&
@@ -2030,16 +3449,32 @@ class PersistedCollectionRuntime<
2030
3449
  txCommitted.latestRowVersion,
2031
3450
  )
2032
3451
 
2033
- 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
+ }
2034
3459
  }
2035
3460
 
2036
- private async recoverFromSeqGapUnsafe(): Promise<void> {
3461
+ private async recoverFromSeqGapUnsafe(
3462
+ lifecycleGeneration: number,
3463
+ ): Promise<void> {
3464
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2037
3465
  if (this.persistence.coordinator.pullSince && this.latestRowVersion >= 0) {
3466
+ let pullResponse: PullSinceResponse | undefined
2038
3467
  try {
2039
- const pullResponse = await this.persistence.coordinator.pullSince(
3468
+ pullResponse = await this.persistence.coordinator.pullSince(
2040
3469
  this.collectionId,
2041
3470
  this.latestRowVersion,
2042
3471
  )
3472
+ } catch (error) {
3473
+ console.warn(`Failed pullSince recovery attempt:`, error)
3474
+ }
3475
+
3476
+ if (pullResponse) {
3477
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
2043
3478
 
2044
3479
  if (pullResponse.ok) {
2045
3480
  this.observeStreamPosition(
@@ -2047,33 +3482,51 @@ class PersistedCollectionRuntime<
2047
3482
  pullResponse.latestSeq,
2048
3483
  pullResponse.latestRowVersion,
2049
3484
  )
2050
- if (pullResponse.requiresFullReload || !pullResponse.deltas) {
2051
- await this.reloadActiveSubsetsUnsafe()
3485
+ if (pullResponse.requiresFullReload) {
3486
+ await this.runInHydrationScope((adapter) =>
3487
+ this.reloadActiveSubsetsUnsafe(adapter),
3488
+ )
3489
+ return
3490
+ }
3491
+ const deltas = pullResponse.deltas
3492
+ if (!deltas) {
3493
+ await this.runInHydrationScope((adapter) =>
3494
+ this.reloadActiveSubsetsUnsafe(adapter),
3495
+ )
2052
3496
  return
2053
3497
  }
2054
3498
 
2055
- for (const delta of pullResponse.deltas) {
2056
- await this.invalidateFromCommittedTxUnsafe({
2057
- type: `tx:committed`,
2058
- term: pullResponse.latestTerm,
2059
- seq: pullResponse.latestSeq,
2060
- txId: delta.txId,
2061
- latestRowVersion: delta.latestRowVersion,
2062
- requiresFullReload: false,
2063
- changedRows: delta.changedRows,
2064
- deletedKeys: delta.deletedKeys,
2065
- rowMetadataMutations: delta.rowMetadataMutations,
2066
- collectionMetadataMutations: delta.collectionMetadataMutations,
2067
- })
2068
- }
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
+ })
2069
3521
  return
2070
3522
  }
2071
- } catch (error) {
2072
- console.warn(`Failed pullSince recovery attempt:`, error)
2073
3523
  }
2074
3524
  }
2075
3525
 
2076
- await this.truncateAndReloadUnsafe()
3526
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3527
+ await this.runInHydrationScope((adapter) =>
3528
+ this.truncateAndReloadUnsafe(adapter, lifecycleGeneration),
3529
+ )
2077
3530
 
2078
3531
  if (this.mode === `sync-present`) {
2079
3532
  for (const options of this.activeSubsets.values()) {
@@ -2082,30 +3535,46 @@ class PersistedCollectionRuntime<
2082
3535
  }
2083
3536
  }
2084
3537
 
2085
- 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++
2086
3546
  if (this.syncControls.begin && this.syncControls.commit) {
2087
- this.withInternalApply(() => {
3547
+ const applied = this.withInternalApply(() => {
2088
3548
  this.syncControls.begin?.({ immediate: true })
2089
3549
  this.syncControls.truncate?.()
2090
- this.syncControls.commit?.()
3550
+ return this.syncControls.commit?.() ?? true
2091
3551
  })
3552
+ if (applied !== true) await applied
2092
3553
  }
2093
3554
 
2094
- await this.reloadActiveSubsetsUnsafe()
3555
+ if (lifecycleGeneration !== this.lifecycleGeneration) return
3556
+ await this.reloadActiveSubsetsUnsafe(adapter)
2095
3557
  }
2096
3558
 
2097
3559
  private async invalidateFromCommittedTxUnsafe(
2098
3560
  txCommitted: TxCommitted,
3561
+ adapter: HydrationPersistenceAdapter,
2099
3562
  ): Promise<void> {
3563
+ const reloadActiveSubsets = () =>
3564
+ this.runInHydrationScope(
3565
+ (scopedAdapter) => this.reloadActiveSubsetsUnsafe(scopedAdapter),
3566
+ adapter,
3567
+ )
3568
+
2100
3569
  if (txCommitted.requiresFullReload) {
2101
- await this.reloadActiveSubsetsUnsafe()
3570
+ await reloadActiveSubsets()
2102
3571
  return
2103
3572
  }
2104
3573
 
2105
3574
  const changedKeyCount =
2106
3575
  txCommitted.changedRows.length + txCommitted.deletedKeys.length
2107
3576
  if (changedKeyCount > TARGETED_INVALIDATION_KEY_LIMIT) {
2108
- await this.reloadActiveSubsetsUnsafe()
3577
+ await reloadActiveSubsets()
2109
3578
  return
2110
3579
  }
2111
3580
 
@@ -2120,7 +3589,7 @@ class PersistedCollectionRuntime<
2120
3589
 
2121
3590
  // Has paginated subsets — fall back to full reload.
2122
3591
  // Targeted invalidation for paginated subsets is deferred to a future iteration.
2123
- await this.reloadActiveSubsetsUnsafe()
3592
+ await reloadActiveSubsets()
2124
3593
  }
2125
3594
 
2126
3595
  private async applyTargetedInvalidationUnsafe(
@@ -2130,7 +3599,7 @@ class PersistedCollectionRuntime<
2130
3599
  (opt) => (opt.where ? compileSingleRowExpression(opt.where) : null),
2131
3600
  )
2132
3601
 
2133
- this.withInternalApply(() => {
3602
+ const applied = this.withInternalApply(() => {
2134
3603
  this.syncControls.begin?.({ immediate: true })
2135
3604
 
2136
3605
  for (const {
@@ -2175,24 +3644,33 @@ class PersistedCollectionRuntime<
2175
3644
  }
2176
3645
  })
2177
3646
 
2178
- this.syncControls.commit?.()
3647
+ return this.syncControls.commit?.() ?? true
2179
3648
  })
3649
+ if (applied !== true) await applied
2180
3650
  }
2181
3651
 
2182
- private async reloadActiveSubsetsUnsafe(): Promise<void> {
3652
+ private async reloadActiveSubsetsUnsafe(
3653
+ adapter: HydrationPersistenceAdapter,
3654
+ ): Promise<void> {
2183
3655
  const lifecycleGeneration = this.lifecycleGeneration
3656
+ const truncateGeneration = this.sourceTruncateGeneration
2184
3657
  const activeSubsetOptions =
2185
3658
  this.activeSubsets.size > 0
2186
3659
  ? Array.from(this.activeSubsets.values())
2187
3660
  : [{}]
2188
3661
 
3662
+ this.hydratedDemands.clear()
3663
+ this.hydrationSequence++
3664
+ const hydrationContext = { suppliedRowKeys: new Set<TKey>() }
3665
+ this.activeHydrationContext = hydrationContext
2189
3666
  this.hydratingGeneration = lifecycleGeneration
2190
3667
  try {
2191
3668
  const mergedRows = new Map<TKey, { value: T; metadata?: unknown }>()
2192
- const collectionMetadata = await this.loadCollectionMetadataSnapshot()
3669
+ const collectionMetadata =
3670
+ await this.loadCollectionMetadataSnapshot(adapter)
2193
3671
  if (lifecycleGeneration !== this.lifecycleGeneration) return
2194
3672
  for (const options of activeSubsetOptions) {
2195
- const subsetRows = await this.loadSubsetRowsUnsafe(options)
3673
+ const subsetRows = await this.loadSubsetRowsUnsafe(options, adapter)
2196
3674
  if (lifecycleGeneration !== this.lifecycleGeneration) return
2197
3675
  for (const row of subsetRows) {
2198
3676
  mergedRows.set(row.key, {
@@ -2202,7 +3680,11 @@ class PersistedCollectionRuntime<
2202
3680
  }
2203
3681
  }
2204
3682
 
2205
- this.replaceCollectionSnapshot(
3683
+ for (const key of mergedRows.keys()) {
3684
+ hydrationContext.suppliedRowKeys.add(key)
3685
+ }
3686
+
3687
+ const applied = this.replaceCollectionSnapshot(
2206
3688
  Array.from(mergedRows.entries()).map(([key, row]) => ({
2207
3689
  key,
2208
3690
  value: row.value,
@@ -2210,14 +3692,34 @@ class PersistedCollectionRuntime<
2210
3692
  })),
2211
3693
  collectionMetadata,
2212
3694
  )
3695
+ if (applied !== true) await applied
2213
3696
  } finally {
2214
- if (this.hydratingGeneration === lifecycleGeneration) {
3697
+ if (
3698
+ this.activeHydrationContext === hydrationContext &&
3699
+ this.hydratingGeneration === lifecycleGeneration
3700
+ ) {
2215
3701
  this.hydratingGeneration = null
2216
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
+ }
2217
3722
  }
2218
-
2219
- await this.flushQueuedHydrationTransactionsUnsafe()
2220
- await this.flushQueuedTxCommittedUnsafe()
2221
3723
  }
2222
3724
 
2223
3725
  private attachIndexLifecycleListeners(): void {
@@ -2242,16 +3744,33 @@ class PersistedCollectionRuntime<
2242
3744
 
2243
3745
  private async bootstrapPersistedIndexes(
2244
3746
  indexMetadataSnapshot?: Array<CollectionIndexMetadata>,
2245
- ): Promise<void> {
3747
+ adapter: HydrationPersistenceAdapter = this.persistence.adapter,
3748
+ ): Promise<Set<string>> {
2246
3749
  const collection = this.collection
2247
3750
  if (!collection && !indexMetadataSnapshot) {
2248
- return
3751
+ return new Set()
2249
3752
  }
2250
3753
 
2251
3754
  const indexMetadata =
2252
3755
  indexMetadataSnapshot ?? collection?.getIndexMetadata() ?? []
3756
+ const completedLocalIndexSignatures = new Set<string>()
2253
3757
  for (const metadata of indexMetadata) {
2254
- await this.ensurePersistedIndex(metadata)
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> {
3769
+ for (const metadata of indexMetadata) {
3770
+ await this.requestCoordinatorPersistedIndex(
3771
+ metadata,
3772
+ completedLocalIndexSignatures.has(metadata.signature),
3773
+ )
2255
3774
  }
2256
3775
  }
2257
3776
 
@@ -2270,24 +3789,47 @@ class PersistedCollectionRuntime<
2270
3789
 
2271
3790
  private async ensurePersistedIndex(
2272
3791
  indexMetadata: CollectionIndexMetadata,
3792
+ adapter: HydrationPersistenceAdapter = this.persistence.adapter,
2273
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> {
2274
3805
  const spec = this.buildPersistedIndexSpec(indexMetadata)
2275
3806
 
2276
3807
  try {
2277
- await this.persistence.adapter.ensureIndex(
3808
+ await adapter.ensureIndex(
2278
3809
  this.collectionId,
2279
3810
  indexMetadata.signature,
2280
3811
  spec,
2281
3812
  )
3813
+ return true
2282
3814
  } catch (error) {
2283
3815
  console.warn(`Failed to ensure persisted index in adapter:`, error)
3816
+ return false
2284
3817
  }
3818
+ }
3819
+
3820
+ private async requestCoordinatorPersistedIndex(
3821
+ indexMetadata: CollectionIndexMetadata,
3822
+ completedLocally: boolean,
3823
+ ): Promise<void> {
3824
+ const spec = this.buildPersistedIndexSpec(indexMetadata)
2285
3825
 
2286
3826
  try {
2287
3827
  await this.persistence.coordinator.requestEnsurePersistedIndex(
2288
3828
  this.collectionId,
2289
3829
  indexMetadata.signature,
2290
3830
  spec,
3831
+ completedLocally ? this.persistence.adapter : undefined,
3832
+ completedLocally,
2291
3833
  )
2292
3834
  } catch (error) {
2293
3835
  console.warn(
@@ -2321,73 +3863,406 @@ function createWrappedSyncConfig<
2321
3863
  >(
2322
3864
  sourceSyncConfig: SyncConfig<T, TKey>,
2323
3865
  runtime: PersistedCollectionRuntime<T, TKey>,
3866
+ persistedReadiness?: PersistedReadinessTracker,
2324
3867
  ): SyncConfig<T, TKey> {
2325
3868
  return {
2326
3869
  ...sourceSyncConfig,
2327
3870
  sync: (params) => {
2328
3871
  const transactionStack: Array<OpenSyncTransaction<T, TKey>> = []
3872
+ const pendingPublicationTransactions: Array<
3873
+ OpenSyncTransaction<T, TKey>
3874
+ > = []
2329
3875
  const getOpenTransaction = () =>
2330
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
+ }
2331
3985
  let fullStartPromise: Promise<void> | null = null
3986
+ let sourceResultPromise: Promise<SyncConfigRes> | null = null
3987
+ let resolveSourceResultAssigned!: () => void
3988
+ const sourceResultAssigned = new Promise<void>((resolve) => {
3989
+ resolveSourceResultAssigned = resolve
3990
+ })
2332
3991
  const startupState = { cleanedUp: false }
2333
- const acquisitions = new Map<LoadSubsetOptions, { forwarded: boolean }>()
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
+ }
2334
4123
  runtime.setSyncControls({
2335
4124
  begin: params.begin,
2336
4125
  write: params.write as SyncControlFns<T, TKey>[`write`],
2337
4126
  commit: params.commit,
2338
4127
  truncate: params.truncate,
2339
4128
  metadata: params.metadata ?? null,
4129
+ markError: params.markError,
2340
4130
  })
2341
4131
  runtime.setCollection(
2342
4132
  params.collection as Collection<T, TKey, PersistedCollectionUtils>,
2343
4133
  )
2344
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
+
2345
4175
  const wrappedParams = {
2346
4176
  ...params,
4177
+ markError: reportUpstreamError,
2347
4178
  markReady: () => {
2348
- if (startupState.cleanedUp) return
4179
+ if (startupState.cleanedUp || getTerminalFailure()) return
2349
4180
  void (fullStartPromise ?? runtime.ensureStarted())
2350
- .then(() => {
2351
- if (startupState.cleanedUp) return
2352
- params.markReady()
2353
- })
2354
- .catch((error) => {
2355
- if (startupState.cleanedUp) return
2356
- console.warn(
2357
- `Failed persisted sync startup before markReady:`,
2358
- error,
2359
- )
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
2360
4191
  params.markReady()
2361
4192
  })
4193
+ .catch(() => undefined)
2362
4194
  },
2363
4195
  begin: (options?: { immediate?: boolean }) => {
2364
- if (startupState.cleanedUp) return
4196
+ if (startupState.cleanedUp) return undefined
4197
+ const terminalFailure = getTerminalFailure()
4198
+ const internal = runtime.isApplyingInternally()
2365
4199
  const transaction: OpenSyncTransaction<T, TKey> = {
2366
4200
  operations: [],
4201
+ partialUpdateOperationIndexes: new Set(),
2367
4202
  rowMetadataWrites: new Map(),
2368
4203
  collectionMetadataWrites: new Map(),
4204
+ deferredHydrationMetadataDeleteKeys: new Set(),
2369
4205
  truncate: false,
2370
- internal: runtime.isApplyingInternally(),
4206
+ internal,
4207
+ lifecycleGeneration: runtime.getLifecycleGeneration(),
4208
+ beginOptions: options,
4209
+ operationKeys: new Set(),
4210
+ hasDependentSuccessor: false,
2371
4211
  queuedBecauseHydrating:
2372
- !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 }),
2373
4226
  }
2374
4227
  transactionStack.push(transaction)
2375
-
2376
- if (!transaction.queuedBecauseHydrating) {
2377
- params.begin(options)
2378
- }
2379
4228
  },
2380
4229
  write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => {
2381
4230
  if (startupState.cleanedUp) return
2382
- const normalization = runtime.normalizeSyncWriteMessage(message)
2383
4231
  const openTransaction = getOpenTransaction()
2384
-
2385
- if (!openTransaction) {
2386
- params.write(normalization.forwardMessage)
4232
+ const terminalFailure =
4233
+ openTransaction?.terminalFailure ?? getTerminalFailure()
4234
+ if (terminalFailure) {
4235
+ if (openTransaction)
4236
+ openTransaction.terminalFailure = terminalFailure
2387
4237
  return
2388
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
+ }
2389
4263
 
2390
4264
  openTransaction.operations.push(normalization.operation)
4265
+ openTransaction.operationKeys.add(normalization.operation.key)
2391
4266
  if (normalization.operation.type === `delete`) {
2392
4267
  openTransaction.rowMetadataWrites.set(normalization.operation.key, {
2393
4268
  type: `delete`,
@@ -2399,7 +4274,11 @@ function createWrappedSyncConfig<
2399
4274
  // Reset stale metadata for a fresh insert, but don't clobber an
2400
4275
  // explicit metadata write already queued for this key in the same
2401
4276
  // transaction (e.g. query reconcile stamps owners, then inserts).
2402
- if (
4277
+ if (openTransaction.queuedBecauseHydrating) {
4278
+ openTransaction.deferredHydrationMetadataDeleteKeys.add(
4279
+ normalization.operation.key,
4280
+ )
4281
+ } else if (
2403
4282
  !openTransaction.rowMetadataWrites.has(
2404
4283
  normalization.operation.key,
2405
4284
  )
@@ -2417,17 +4296,11 @@ function createWrappedSyncConfig<
2417
4296
  value: normalization.operation.metadata,
2418
4297
  })
2419
4298
  }
2420
- if (!openTransaction.queuedBecauseHydrating) {
2421
- params.write(normalization.forwardMessage)
2422
- }
2423
4299
  },
2424
4300
  metadata: params.metadata
2425
4301
  ? {
4302
+ persistence: persistenceCapability,
2426
4303
  row: {
2427
- whenHydrated: () =>
2428
- startupState.cleanedUp
2429
- ? Promise.resolve()
2430
- : runtime.ensureResumeBaselineHydrated(),
2431
4304
  get: (key: TKey) => {
2432
4305
  if (startupState.cleanedUp) return undefined
2433
4306
  const openTransaction = getOpenTransaction()
@@ -2441,15 +4314,19 @@ function createWrappedSyncConfig<
2441
4314
  if (openTransaction?.truncate) {
2442
4315
  return undefined
2443
4316
  }
4317
+ const pending = getPendingRowMetadataWrite(key)
4318
+ if (pending.found) {
4319
+ return pending.write.type === `delete`
4320
+ ? undefined
4321
+ : pending.write.value
4322
+ }
2444
4323
  return params.metadata!.row.get(key)
2445
4324
  },
2446
- scanPersisted: (options?: PersistedRowScanOptions) =>
2447
- startupState.cleanedUp
2448
- ? Promise.resolve([])
2449
- : runtime.scanPersistedRows(options),
2450
4325
  set: (key: TKey, value: unknown) => {
2451
4326
  if (startupState.cleanedUp) return
2452
4327
  const openTransaction = getOpenTransaction()
4328
+ if (openTransaction?.terminalFailure ?? getTerminalFailure())
4329
+ return
2453
4330
  if (!openTransaction) {
2454
4331
  throw new InvalidPersistedCollectionConfigError(
2455
4332
  `metadata.row.set must be called within an open sync transaction`,
@@ -2459,13 +4336,12 @@ function createWrappedSyncConfig<
2459
4336
  type: `set`,
2460
4337
  value,
2461
4338
  })
2462
- if (!openTransaction.queuedBecauseHydrating) {
2463
- params.metadata!.row.set(key, value)
2464
- }
2465
4339
  },
2466
4340
  delete: (key: TKey) => {
2467
4341
  if (startupState.cleanedUp) return
2468
4342
  const openTransaction = getOpenTransaction()
4343
+ if (openTransaction?.terminalFailure ?? getTerminalFailure())
4344
+ return
2469
4345
  if (!openTransaction) {
2470
4346
  throw new InvalidPersistedCollectionConfigError(
2471
4347
  `metadata.row.delete must be called within an open sync transaction`,
@@ -2474,9 +4350,6 @@ function createWrappedSyncConfig<
2474
4350
  openTransaction.rowMetadataWrites.set(key, {
2475
4351
  type: `delete`,
2476
4352
  })
2477
- if (!openTransaction.queuedBecauseHydrating) {
2478
- params.metadata!.row.delete(key)
2479
- }
2480
4353
  },
2481
4354
  },
2482
4355
  collection: {
@@ -2490,11 +4363,19 @@ function createWrappedSyncConfig<
2490
4363
  ? undefined
2491
4364
  : pendingWrite.value
2492
4365
  }
4366
+ const pending = getPendingCollectionMetadataWrite(key)
4367
+ if (pending.found) {
4368
+ return pending.write.type === `delete`
4369
+ ? undefined
4370
+ : pending.write.value
4371
+ }
2493
4372
  return params.metadata!.collection.get(key)
2494
4373
  },
2495
4374
  set: (key: string, value: unknown) => {
2496
4375
  if (startupState.cleanedUp) return
2497
4376
  const openTransaction = getOpenTransaction()
4377
+ if (openTransaction?.terminalFailure ?? getTerminalFailure())
4378
+ return
2498
4379
  if (!openTransaction) {
2499
4380
  throw new InvalidPersistedCollectionConfigError(
2500
4381
  `metadata.collection.set must be called within an open sync transaction`,
@@ -2504,13 +4385,12 @@ function createWrappedSyncConfig<
2504
4385
  type: `set`,
2505
4386
  value,
2506
4387
  })
2507
- if (!openTransaction.queuedBecauseHydrating) {
2508
- params.metadata!.collection.set(key, value)
2509
- }
2510
4388
  },
2511
4389
  delete: (key: string) => {
2512
4390
  if (startupState.cleanedUp) return
2513
4391
  const openTransaction = getOpenTransaction()
4392
+ if (openTransaction?.terminalFailure ?? getTerminalFailure())
4393
+ return
2514
4394
  if (!openTransaction) {
2515
4395
  throw new InvalidPersistedCollectionConfigError(
2516
4396
  `metadata.collection.delete must be called within an open sync transaction`,
@@ -2519,9 +4399,6 @@ function createWrappedSyncConfig<
2519
4399
  openTransaction.collectionMetadataWrites.set(key, {
2520
4400
  type: `delete`,
2521
4401
  })
2522
- if (!openTransaction.queuedBecauseHydrating) {
2523
- params.metadata!.collection.delete(key)
2524
- }
2525
4402
  },
2526
4403
  list: (prefix?: string) => {
2527
4404
  if (startupState.cleanedUp) return []
@@ -2530,6 +4407,28 @@ function createWrappedSyncConfig<
2530
4407
  .metadata!.collection.list()
2531
4408
  .map(({ key, value }) => [key, value]),
2532
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
+ }
2533
4432
  const openTransaction = getOpenTransaction()
2534
4433
  if (openTransaction) {
2535
4434
  for (const [
@@ -2556,36 +4455,113 @@ function createWrappedSyncConfig<
2556
4455
  : undefined,
2557
4456
  truncate: () => {
2558
4457
  if (startupState.cleanedUp) return
4458
+ runtime.noteSourceTruncate()
2559
4459
  const openTransaction = getOpenTransaction()
4460
+ if (openTransaction?.terminalFailure ?? getTerminalFailure()) return
2560
4461
  if (!openTransaction) {
2561
- params.truncate()
2562
- return
4462
+ throw new NoPendingSyncTransactionWriteError()
2563
4463
  }
2564
4464
 
2565
4465
  openTransaction.operations = []
4466
+ openTransaction.operationKeys.clear()
4467
+ openTransaction.partialUpdateOperationIndexes.clear()
2566
4468
  openTransaction.rowMetadataWrites.clear()
4469
+ openTransaction.deferredHydrationMetadataDeleteKeys.clear()
2567
4470
  // Intentionally preserve collectionMetadataWrites across truncate.
2568
4471
  // Callers (for example electric resume/reset handling) may stage
2569
4472
  // collection-scoped metadata before truncating row data, and those
2570
4473
  // writes must commit atomically with the truncate transaction.
2571
4474
  openTransaction.truncate = true
2572
- if (!openTransaction.queuedBecauseHydrating) {
2573
- params.truncate()
2574
- }
2575
4475
  },
2576
4476
  commit: (signal?: AbortSignal) => {
2577
4477
  if (startupState.cleanedUp) return true
2578
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
+ }
2579
4491
  if (!openTransaction) {
2580
- return params.commit(signal)
4492
+ throw new NoPendingSyncTransactionCommitError()
2581
4493
  }
2582
4494
 
2583
- if (openTransaction.queuedBecauseHydrating) {
2584
- if (signal?.aborted) {
2585
- const aborted = Promise.reject(new SyncTransactionAbortedError())
2586
- void aborted.catch(() => undefined)
2587
- return aborted
2588
- }
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
+ ) {
2589
4565
  let resolveApplied!: () => void
2590
4566
  let rejectApplied!: (error: unknown) => void
2591
4567
  const applied = new Promise<void>((resolve, reject) => {
@@ -2594,75 +4570,142 @@ function createWrappedSyncConfig<
2594
4570
  })
2595
4571
  void applied.catch(() => undefined)
2596
4572
  runtime.queueHydrationBufferedTransaction({
2597
- operations: openTransaction.operations,
2598
- rowMetadataWrites: openTransaction.rowMetadataWrites,
2599
- collectionMetadataWrites:
2600
- openTransaction.collectionMetadataWrites,
2601
- truncate: openTransaction.truncate,
2602
- internal: openTransaction.internal,
2603
- signal,
4573
+ ...transaction,
2604
4574
  resolveApplied,
2605
4575
  rejectApplied,
2606
4576
  })
4577
+ settlePublicationAdmissionWaiters(openTransaction)
4578
+ settleRuntimeTransaction(openTransaction, applied)
2607
4579
  return applied
2608
4580
  }
2609
4581
 
2610
- const applied = params.commit(signal)
2611
- if (!openTransaction.internal) {
2612
- const persistAfterApplication = async () => {
2613
- if (applied !== true) await applied
2614
- await runtime.persistAndBroadcastExternalSyncTransaction({
2615
- operations: openTransaction.operations,
2616
- rowMetadataWrites: openTransaction.rowMetadataWrites,
2617
- collectionMetadataWrites:
2618
- openTransaction.collectionMetadataWrites,
2619
- truncate: openTransaction.truncate,
2620
- internal: false,
2621
- })
2622
- }
2623
- const persisted = persistAfterApplication()
2624
- void persisted.catch(() => undefined)
2625
- return persisted
4582
+ let applied: Promise<void>
4583
+ try {
4584
+ applied = runtime.applyHydrationBufferedTransaction(transaction)
4585
+ } catch (error) {
4586
+ settlePublicationAdmissionWaiters(openTransaction, error)
4587
+ throw error
2626
4588
  }
4589
+ settlePublicationAdmissionWaiters(openTransaction)
4590
+ settleRuntimeTransaction(openTransaction, applied)
2627
4591
  return applied
2628
4592
  },
2629
4593
  }
2630
4594
 
2631
4595
  let sourceResult: SyncConfigRes = {}
4596
+ let sourceResultSettled = false
4597
+ let activeSourceSyncEntry: Promise<void> | undefined
2632
4598
  fullStartPromise = runtime.ensureStarted()
2633
- 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 () => {
2634
4606
  await runtime.ensureStartupMetadataLoaded()
2635
4607
 
2636
4608
  if (startupState.cleanedUp) {
4609
+ sourceResultSettled = true
2637
4610
  return sourceResult
2638
4611
  }
2639
4612
 
2640
- sourceResult = normalizeSyncFnResult(
2641
- sourceSyncConfig.sync(wrappedParams),
2642
- )
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
2643
4647
  return sourceResult
2644
4648
  })()
4649
+ resolveSourceResultAssigned()
4650
+ void sourceResultPromise.catch((error) => {
4651
+ reportUpstreamError(error)
4652
+ })
2645
4653
 
2646
4654
  return {
2647
- cleanup: () => {
4655
+ cleanup: async () => {
2648
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
+ }
2649
4665
  acquisitions.clear()
2650
- sourceResult.cleanup?.()
2651
- runtime.cleanup()
2652
- runtime.clearSyncControls()
2653
- },
2654
- loadSubset: async (options: LoadSubsetOptions) => {
2655
- const acquisition = { forwarded: false }
2656
- acquisitions.set(options, acquisition)
2657
- await fullStartPromise
2658
- 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
+ ])
2659
4689
  if (
2660
- startupState.cleanedUp ||
2661
- acquisitions.get(options) !== acquisition
4690
+ sourceOutcome.status === `rejected` &&
4691
+ runtimeOutcome.status === `rejected`
2662
4692
  ) {
2663
- return
4693
+ throw new AggregateError(
4694
+ [sourceOutcome.reason, runtimeOutcome.reason],
4695
+ `Source cleanup and persistence runtime teardown both failed`,
4696
+ { cause: sourceOutcome.reason },
4697
+ )
2664
4698
  }
2665
- 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> => {
2666
4709
  // Hydration is another async boundary. A release before this
2667
4710
  // point owns no upstream lease and must not start one later.
2668
4711
  if (
@@ -2681,10 +4724,60 @@ function createWrappedSyncConfig<
2681
4724
  acquisition.forwarded = false
2682
4725
  throw error
2683
4726
  }
2684
- })
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
+ })()
2685
4777
  },
2686
4778
  unloadSubset: (options: LoadSubsetOptions) => {
2687
4779
  const acquisition = acquisitions.get(options)
4780
+ acquisition?.cancelAdmissionWait?.()
2688
4781
  acquisitions.delete(options)
2689
4782
  runtime.unloadSubset(
2690
4783
  options,
@@ -2708,6 +4801,7 @@ function createLoopbackSyncConfig<
2708
4801
  commit: params.commit,
2709
4802
  truncate: params.truncate,
2710
4803
  metadata: params.metadata ?? null,
4804
+ markError: params.markError,
2711
4805
  })
2712
4806
  runtime.setCollection(
2713
4807
  params.collection as Collection<T, TKey, PersistedCollectionUtils>,
@@ -2716,19 +4810,15 @@ function createLoopbackSyncConfig<
2716
4810
  void runtime
2717
4811
  .ensureStarted()
2718
4812
  .then(() => {
4813
+ if (runtime.getCurrentTerminalFailure()) return
2719
4814
  params.markReady()
2720
4815
  })
2721
- .catch((error) => {
2722
- console.warn(`Failed persisted loopback startup:`, error)
2723
- params.markReady()
2724
- })
4816
+ .catch(() => undefined)
2725
4817
 
2726
4818
  return {
2727
- cleanup: () => {
2728
- runtime.cleanup()
2729
- runtime.clearSyncControls()
2730
- },
2731
- loadSubset: (options: LoadSubsetOptions) => runtime.loadSubset(options),
4819
+ loadSubset: (options: LoadSubsetOptions): true | Promise<void> =>
4820
+ runtime.loadHydratedSubset(options) ?? runtime.loadSubset(options),
4821
+ cleanup: () => runtime.cleanup(),
2732
4822
  unloadSubset: (options: LoadSubsetOptions) =>
2733
4823
  runtime.unloadSubset(options),
2734
4824
  }
@@ -2739,13 +4829,59 @@ function createLoopbackSyncConfig<
2739
4829
  }
2740
4830
  }
2741
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
+
2742
4876
  export function persistedCollectionOptions<
2743
4877
  T extends object,
2744
4878
  TKey extends string | number,
2745
4879
  TSchema extends StandardSchemaV1 = never,
2746
4880
  TUtils extends UtilsRecord = UtilsRecord,
2747
4881
  >(
2748
- options: PersistedSyncWrappedOptions<T, TKey, TSchema, TUtils>,
4882
+ options: PersistedSyncWrappedOptions<T, TKey, TSchema, TUtils> & {
4883
+ schema?: never
4884
+ },
2749
4885
  ): PersistedSyncOptionsResult<T, TKey, TSchema, TUtils>
2750
4886
 
2751
4887
  export function persistedCollectionOptions<
@@ -2754,7 +4890,9 @@ export function persistedCollectionOptions<
2754
4890
  TSchema extends StandardSchemaV1 = never,
2755
4891
  TUtils extends UtilsRecord = UtilsRecord,
2756
4892
  >(
2757
- options: PersistedLocalOnlyOptions<T, TKey, TSchema, TUtils>,
4893
+ options: PersistedLocalOnlyOptions<T, TKey, TSchema, TUtils> & {
4894
+ schema?: never
4895
+ },
2758
4896
  ): PersistedLocalOnlyOptionsResult<T, TKey, TSchema, TUtils>
2759
4897
 
2760
4898
  export function persistedCollectionOptions<
@@ -2775,6 +4913,17 @@ export function persistedCollectionOptions<
2775
4913
  )
2776
4914
  }
2777
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
+
2778
4927
  if (hasOwnSyncKey(options)) {
2779
4928
  if (!isValidSyncConfig(options.sync)) {
2780
4929
  throw new InvalidSyncConfigError(
@@ -2782,7 +4931,11 @@ export function persistedCollectionOptions<
2782
4931
  )
2783
4932
  }
2784
4933
 
2785
- const { schemaVersion, ...syncOptions } = options
4934
+ const {
4935
+ schemaVersion,
4936
+ initialRender: _initialRender,
4937
+ ...syncOptions
4938
+ } = options
2786
4939
  const collectionId =
2787
4940
  syncOptions.id ?? `persisted-collection:${safeRandomUUID()}`
2788
4941
  const persistence = resolvePersistenceForCollection(
@@ -2800,13 +4953,21 @@ export function persistedCollectionOptions<
2800
4953
  persistence,
2801
4954
  syncOptions.syncMode ?? `eager`,
2802
4955
  collectionId,
4956
+ persistedReadinessTracker,
2803
4957
  )
2804
4958
 
2805
4959
  const result = {
2806
4960
  ...syncOptions,
2807
4961
  id: collectionId,
2808
- sync: createWrappedSyncConfig<T, TKey>(syncOptions.sync, runtime),
4962
+ sync: createWrappedSyncConfig<T, TKey>(
4963
+ syncOptions.sync,
4964
+ runtime,
4965
+ persistedReadinessTracker,
4966
+ ),
2809
4967
  persistence,
4968
+ ...(persistedReadinessTracker
4969
+ ? { [PERSISTED_READINESS]: persistedReadinessTracker }
4970
+ : {}),
2810
4971
  }
2811
4972
 
2812
4973
  return withCollectionConfigFactory(
@@ -2815,11 +4976,15 @@ export function persistedCollectionOptions<
2815
4976
  persistedCollectionOptions({
2816
4977
  ...options,
2817
4978
  id: collectionId,
2818
- }) as typeof result,
4979
+ } as never) as typeof result,
2819
4980
  )
2820
4981
  }
2821
4982
 
2822
- const { schemaVersion, ...localOnlyOptions } = options
4983
+ const {
4984
+ schemaVersion,
4985
+ initialRender: _initialRender,
4986
+ ...localOnlyOptions
4987
+ } = options
2823
4988
  const collectionId =
2824
4989
  localOnlyOptions.id ?? `persisted-collection:${safeRandomUUID()}`
2825
4990
  const persistence = resolvePersistenceForCollection(
@@ -2836,11 +5001,13 @@ export function persistedCollectionOptions<
2836
5001
  persistence,
2837
5002
  localOnlyOptions.syncMode ?? `eager`,
2838
5003
  localOnlyOptions.id ?? DEFAULT_DB_NAME,
5004
+ persistedReadinessTracker,
2839
5005
  )
2840
5006
 
2841
5007
  const wrappedOnInsert = async (
2842
5008
  params: InsertMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2843
5009
  ) => {
5010
+ const lifecycleGeneration = runtime.getLifecycleGeneration()
2844
5011
  const handlerResult = localOnlyOptions.onInsert
2845
5012
  ? await localOnlyOptions.onInsert(
2846
5013
  params as unknown as InsertMutationFnParams<T, TKey, TUtils>,
@@ -2849,6 +5016,7 @@ export function persistedCollectionOptions<
2849
5016
 
2850
5017
  await runtime.persistAndConfirmCollectionMutations(
2851
5018
  params.transaction.mutations as Array<PendingMutation<T>>,
5019
+ lifecycleGeneration,
2852
5020
  )
2853
5021
 
2854
5022
  return handlerResult ?? {}
@@ -2857,6 +5025,7 @@ export function persistedCollectionOptions<
2857
5025
  const wrappedOnUpdate = async (
2858
5026
  params: UpdateMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2859
5027
  ) => {
5028
+ const lifecycleGeneration = runtime.getLifecycleGeneration()
2860
5029
  const handlerResult = localOnlyOptions.onUpdate
2861
5030
  ? await localOnlyOptions.onUpdate(
2862
5031
  params as unknown as UpdateMutationFnParams<T, TKey, TUtils>,
@@ -2865,6 +5034,7 @@ export function persistedCollectionOptions<
2865
5034
 
2866
5035
  await runtime.persistAndConfirmCollectionMutations(
2867
5036
  params.transaction.mutations as Array<PendingMutation<T>>,
5037
+ lifecycleGeneration,
2868
5038
  )
2869
5039
 
2870
5040
  return handlerResult ?? {}
@@ -2873,6 +5043,7 @@ export function persistedCollectionOptions<
2873
5043
  const wrappedOnDelete = async (
2874
5044
  params: DeleteMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2875
5045
  ) => {
5046
+ const lifecycleGeneration = runtime.getLifecycleGeneration()
2876
5047
  const handlerResult = localOnlyOptions.onDelete
2877
5048
  ? await localOnlyOptions.onDelete(
2878
5049
  params as unknown as DeleteMutationFnParams<T, TKey, TUtils>,
@@ -2881,6 +5052,7 @@ export function persistedCollectionOptions<
2881
5052
 
2882
5053
  await runtime.persistAndConfirmCollectionMutations(
2883
5054
  params.transaction.mutations as Array<PendingMutation<T>>,
5055
+ lifecycleGeneration,
2884
5056
  )
2885
5057
 
2886
5058
  return handlerResult ?? {}
@@ -2908,6 +5080,9 @@ export function persistedCollectionOptions<
2908
5080
  ...localOnlyOptions,
2909
5081
  id: collectionId,
2910
5082
  persistence,
5083
+ ...(persistedReadinessTracker
5084
+ ? { [PERSISTED_READINESS]: persistedReadinessTracker }
5085
+ : {}),
2911
5086
  sync: createLoopbackSyncConfig(runtime),
2912
5087
  onInsert: wrappedOnInsert,
2913
5088
  onUpdate: wrappedOnUpdate,
@@ -2923,7 +5098,7 @@ export function persistedCollectionOptions<
2923
5098
  persistedCollectionOptions({
2924
5099
  ...options,
2925
5100
  id: collectionId,
2926
- }) as typeof result,
5101
+ } as never) as typeof result,
2927
5102
  )
2928
5103
  }
2929
5104