@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
@@ -0,0 +1,2456 @@
1
+ import { safeRandomUUID } from '@tanstack/db'
2
+ import {
3
+ DuplicateRemoteSubsetOwnerError,
4
+ IndeterminateCommitError,
5
+ PersistedCollectionDurabilityError,
6
+ RetryableRemoteSubsetAcquisitionError,
7
+ toPersistedCollectionDurabilityError,
8
+ } from './errors'
9
+ import {
10
+ toProcessLocalLoadSubsetOptions,
11
+ toTransportedLoadSubsetOptions,
12
+ } from './remote-subset-wire'
13
+ import { unloadRemoteSubsetOwner } from './remote-subset-owner'
14
+ import type { LoadSubsetOptions } from '@tanstack/db'
15
+ import type { IndeterminateCommitRequestType } from './errors'
16
+ import type { TransportedLoadSubsetOptions } from './remote-subset-wire'
17
+ import type { RemoteSubsetOwner } from './remote-subset-owner'
18
+ import type {
19
+ ApplyCommittedTxResponse,
20
+ ApplyLocalMutationsResponse,
21
+ EnsureRemoteSubsetRequest,
22
+ EnsureRemoteSubsetResponse,
23
+ HydrationPersistenceAdapter,
24
+ PersistedCollectionCoordinator,
25
+ PersistedIndexSpec,
26
+ PersistedMutationEnvelope,
27
+ PersistedRowMetadataMutation,
28
+ PersistedTx,
29
+ PersistenceAdapter,
30
+ ProtocolEnvelope,
31
+ PullSinceResponse,
32
+ ReleaseRemoteSubsetRequest,
33
+ ReleaseRemoteSubsetResponse,
34
+ TxCommitted,
35
+ } from './persisted'
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Constants
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const HEARTBEAT_INTERVAL_MS = 3_000
42
+ const LEADERSHIP_RETRY_DELAY_MS = 200
43
+ const RPC_TIMEOUT_MS = 10_000
44
+ const RPC_RETRY_ATTEMPTS = 2
45
+ const RPC_RETRY_DELAY_MS = 200
46
+ const REMOTE_SUBSET_REPLAY_RETRY_ATTEMPTS = 2
47
+ const RPC_DEDUPE_RETENTION_MS = 60_000
48
+ const WRITER_LOCK_BUSY_RETRY_MS = 50
49
+ const WRITER_LOCK_MAX_RETRIES = 20
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Internal types
53
+ // ---------------------------------------------------------------------------
54
+
55
+ type RPCRequest =
56
+ | EnsureRemoteSubsetRequest
57
+ | ReleaseRemoteSubsetRequest
58
+ | {
59
+ type: `rpc:ensurePersistedIndex:req`
60
+ rpcId: string
61
+ signature: string
62
+ spec: PersistedIndexSpec
63
+ }
64
+ | {
65
+ type: `rpc:applyLocalMutations:req`
66
+ rpcId: string
67
+ envelopeId: string
68
+ mutations: Array<PersistedMutationEnvelope>
69
+ }
70
+ | {
71
+ type: `rpc:applyCommittedTx:req`
72
+ rpcId: string
73
+ envelopeId: string
74
+ tx: PersistedTx
75
+ }
76
+ | {
77
+ type: `rpc:pullSince:req`
78
+ rpcId: string
79
+ fromRowVersion: number
80
+ }
81
+
82
+ type RPCResponse =
83
+ | EnsureRemoteSubsetResponse
84
+ | ReleaseRemoteSubsetResponse
85
+ | {
86
+ type: `rpc:ensurePersistedIndex:res`
87
+ rpcId: string
88
+ ok: boolean
89
+ error?: string
90
+ }
91
+ | ApplyLocalMutationsResponse
92
+ | ApplyCommittedTxResponse
93
+ | PullSinceResponse
94
+
95
+ type PendingRPC = {
96
+ resolve: (response: RPCResponse) => void
97
+ reject: (error: Error) => void
98
+ timer: ReturnType<typeof setTimeout>
99
+ }
100
+
101
+ type CollectionState = {
102
+ participatesInLeadership: boolean
103
+ isLeader: boolean
104
+ leaderId: string | null
105
+ lockAbortController: AbortController | null
106
+ heartbeatTimer: ReturnType<typeof setInterval> | null
107
+ latestTerm: number
108
+ latestSeq: number
109
+ latestRowVersion: number
110
+ subscribers: Set<(message: ProtocolEnvelope<unknown>) => void>
111
+ routeWaiters?: Set<() => void>
112
+ }
113
+
114
+ // Adapter capabilities used by coordinator-side operations. Resume snapshots
115
+ // belong to the persisted sync wrapper, not the elected writer transport.
116
+ type CoordinatorAdapter = Pick<
117
+ PersistenceAdapter,
118
+ `loadSubset` | `applyCommittedTx` | `ensureIndex` | `runInRegularScope`
119
+ > & {
120
+ pullSince?: (
121
+ collectionId: string,
122
+ fromRowVersion: number,
123
+ ) => Promise<
124
+ | {
125
+ latestRowVersion: number
126
+ requiresFullReload: true
127
+ }
128
+ | {
129
+ latestRowVersion: number
130
+ requiresFullReload: false
131
+ changedKeys: Array<string | number>
132
+ deletedKeys: Array<string | number>
133
+ }
134
+ >
135
+ getStreamPosition?: (collectionId: string) => Promise<{
136
+ latestTerm: number
137
+ latestSeq: number
138
+ latestRowVersion: number
139
+ }>
140
+ }
141
+
142
+ type ActiveRemoteSubsetAcquisition = {
143
+ collectionId: string
144
+ requesterId: string
145
+ acquisitionId: string
146
+ owner: RemoteSubsetOwner
147
+ options: TransportedLoadSubsetOptions
148
+ load: Promise<void>
149
+ transferred: boolean
150
+ released: boolean
151
+ terminalRelease: boolean
152
+ release: Promise<void> | null
153
+ }
154
+
155
+ type AwaitingRemoteSubsetOwnerAcquisition = {
156
+ collectionId: string
157
+ requesterId: string
158
+ acquisitionId: string
159
+ options: TransportedLoadSubsetOptions
160
+ released: true
161
+ awaitingOwner: true
162
+ }
163
+
164
+ type RemoteSubsetAcquisition =
165
+ | ActiveRemoteSubsetAcquisition
166
+ | AwaitingRemoteSubsetOwnerAcquisition
167
+ | {
168
+ collectionId: string
169
+ requesterId: string
170
+ acquisitionId: string
171
+ released: true
172
+ }
173
+
174
+ type OutboundRemoteSubsetAcquisition = {
175
+ collectionId: string
176
+ acquisitionId: string
177
+ options: TransportedLoadSubsetOptions
178
+ localOptions: TransportedLoadSubsetOptions &
179
+ Pick<LoadSubsetOptions, `signal` | `subscription`>
180
+ acquiredLeaderId: string | null
181
+ inFlight: Promise<void> | null
182
+ release: Promise<void> | null
183
+ releaseRequested?: boolean
184
+ forceReplay: boolean
185
+ retryTimer: ReturnType<typeof setTimeout> | null
186
+ retryAttempts: number
187
+ }
188
+
189
+ type AppliedEnvelope =
190
+ | {
191
+ appliedAt: number
192
+ requestType: `rpc:applyLocalMutations:req`
193
+ response: ApplyLocalMutationsResponse
194
+ }
195
+ | {
196
+ appliedAt: number
197
+ requestType: `rpc:applyCommittedTx:req`
198
+ response: ApplyCommittedTxResponse
199
+ }
200
+
201
+ type InFlightEnvelope =
202
+ | {
203
+ requestType: `rpc:applyLocalMutations:req`
204
+ response: Promise<ApplyLocalMutationsResponse>
205
+ }
206
+ | {
207
+ requestType: `rpc:applyCommittedTx:req`
208
+ response: Promise<ApplyCommittedTxResponse>
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Options
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /** @internal Shared by the Browser and Electron persistence hosts. */
216
+ export type BroadcastCollectionCoordinatorOptions = {
217
+ dbName: string
218
+ adapter?: CoordinatorAdapter
219
+ coordinatorName: string
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // BroadcastCollectionCoordinator
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /** @internal Shared coordination engine for BroadcastChannel/Web Locks hosts. */
227
+ export class BroadcastCollectionCoordinator implements PersistedCollectionCoordinator {
228
+ private readonly nodeId = safeRandomUUID()
229
+ private readonly dbName: string
230
+ private readonly coordinatorName: string
231
+ private defaultAdapter: CoordinatorAdapter | null
232
+ private readonly collectionAdapters = new Map<string, CoordinatorAdapter>()
233
+ private readonly remoteSubsetOwners = new Map<string, RemoteSubsetOwner>()
234
+ private readonly remoteSubsetIds = new Map<
235
+ string,
236
+ WeakMap<LoadSubsetOptions, string>
237
+ >()
238
+ private readonly outboundRemoteSubsetAcquisitions = new Map<
239
+ string,
240
+ OutboundRemoteSubsetAcquisition
241
+ >()
242
+ private readonly inboundRemoteSubsetAcquisitions = new Map<
243
+ string,
244
+ RemoteSubsetAcquisition
245
+ >()
246
+ private readonly releasedRemoteSubsetAcquisitionTimes = new Map<
247
+ string,
248
+ number
249
+ >()
250
+ private readonly channel: BroadcastChannel
251
+ private readonly collections = new Map<string, CollectionState>()
252
+ private readonly pendingRPCs = new Map<string, PendingRPC>()
253
+ private readonly appliedEnvelopes = new Map<string, AppliedEnvelope>()
254
+ private readonly inFlightEnvelopes = new Map<string, InFlightEnvelope>()
255
+ private appliedEnvelopePruneTimer: ReturnType<typeof setTimeout> | null = null
256
+ private rejectDisposed: ((error: Error) => void) | null = null
257
+ private readonly disposedPromise: Promise<never>
258
+ private disposed = false
259
+
260
+ /** Method indirection to prevent TypeScript from narrowing `disposed` across awaits */
261
+ private isDisposed(): boolean {
262
+ return this.disposed
263
+ }
264
+
265
+ /** Method indirection to re-read leadership participation across awaits. */
266
+ private participatesInLeadership(state: CollectionState): boolean {
267
+ return state.participatesInLeadership
268
+ }
269
+
270
+ private requireAdapter(collectionId: string): CoordinatorAdapter {
271
+ let adapter = this.collectionAdapters.get(collectionId)
272
+ if (!adapter && this.defaultAdapter) {
273
+ adapter = this.defaultAdapter
274
+ this.collectionAdapters.set(collectionId, adapter)
275
+ }
276
+ if (!adapter) {
277
+ throw new Error(
278
+ `${this.coordinatorName}: adapter not set for collection "${collectionId}". Call setAdapterForCollection() before using leader-side operations.`,
279
+ )
280
+ }
281
+ return adapter
282
+ }
283
+
284
+ constructor(options: BroadcastCollectionCoordinatorOptions) {
285
+ this.dbName = options.dbName
286
+ this.coordinatorName = options.coordinatorName
287
+ this.defaultAdapter = options.adapter ?? null
288
+ this.channel = new BroadcastChannel(`tsdb:coord:${this.dbName}`)
289
+ this.channel.onmessage = (event: MessageEvent) => {
290
+ this.onChannelMessage(event.data)
291
+ }
292
+ this.disposedPromise = new Promise<never>((_resolve, reject) => {
293
+ this.rejectDisposed = reject
294
+ })
295
+ void this.disposedPromise.catch(() => undefined)
296
+ }
297
+
298
+ /**
299
+ * Set or replace the persistence adapter used for leader-side RPC handling.
300
+ * Called by a host persistence factory to wire the internally-created adapter.
301
+ */
302
+ setAdapter(adapter: CoordinatorAdapter): void {
303
+ this.defaultAdapter = adapter
304
+ }
305
+
306
+ /** Register the persistence adapter that owns one collection. */
307
+ setAdapterForCollection(
308
+ collectionId: string,
309
+ adapter: CoordinatorAdapter,
310
+ ): void {
311
+ this.collectionAdapters.set(collectionId, adapter)
312
+ }
313
+
314
+ registerRemoteSubsetOwner(
315
+ collectionId: string,
316
+ owner: RemoteSubsetOwner,
317
+ ): () => void {
318
+ if (this.remoteSubsetOwners.has(collectionId)) {
319
+ throw new DuplicateRemoteSubsetOwnerError(collectionId)
320
+ }
321
+ this.remoteSubsetOwners.set(collectionId, owner)
322
+ for (const acquisition of this.outboundRemoteSubsetAcquisitions.values()) {
323
+ if (acquisition.collectionId !== collectionId) continue
324
+ acquisition.acquiredLeaderId = null
325
+ acquisition.forceReplay = true
326
+ }
327
+ void this.replayRemoteSubsetAcquisitions(collectionId)
328
+ this.rebindRemoteInboundSubsetAcquisitions(collectionId, owner)
329
+ return () => {
330
+ if (this.remoteSubsetOwners.get(collectionId) !== owner) return
331
+ this.remoteSubsetOwners.delete(collectionId)
332
+ this.releaseInboundRemoteSubsetAcquisitions(collectionId, owner)
333
+ }
334
+ }
335
+
336
+ // -----------------------------------------------------------------------
337
+ // PersistedCollectionCoordinator interface
338
+ // -----------------------------------------------------------------------
339
+
340
+ getNodeId(): string {
341
+ return this.nodeId
342
+ }
343
+
344
+ subscribe(
345
+ collectionId: string,
346
+ onMessage: (message: ProtocolEnvelope<unknown>) => void,
347
+ ): () => void {
348
+ const state = this.ensureCollectionState(collectionId)
349
+ state.subscribers.add(onMessage)
350
+ return () => {
351
+ state.subscribers.delete(onMessage)
352
+ if (state.subscribers.size === 0) {
353
+ state.participatesInLeadership = false
354
+ this.releaseCollectionIfUnused(collectionId, state)
355
+ }
356
+ }
357
+ }
358
+
359
+ publish(_collectionId: string, message: ProtocolEnvelope<unknown>): void {
360
+ this.channel.postMessage(message)
361
+ }
362
+
363
+ isLeader(collectionId: string): boolean {
364
+ return this.collections.get(collectionId)?.isLeader ?? false
365
+ }
366
+
367
+ async ensureLeadership(collectionId: string): Promise<void> {
368
+ const state = this.ensureCollectionState(collectionId)
369
+ if (state.isLeader) return
370
+ await this.acquireLeadership(collectionId, state)
371
+ }
372
+
373
+ async requestEnsureRemoteSubset(
374
+ collectionId: string,
375
+ options: LoadSubsetOptions,
376
+ ): Promise<void> {
377
+ const transportedOptions = toTransportedLoadSubsetOptions(options)
378
+ const localOptions = toProcessLocalLoadSubsetOptions(
379
+ options,
380
+ transportedOptions,
381
+ )
382
+ let collectionIds = this.remoteSubsetIds.get(collectionId)
383
+ if (!collectionIds) {
384
+ collectionIds = new WeakMap()
385
+ this.remoteSubsetIds.set(collectionId, collectionIds)
386
+ }
387
+ let acquisitionId = collectionIds.get(options)
388
+ let acquisition = acquisitionId
389
+ ? this.outboundRemoteSubsetAcquisitions.get(
390
+ remoteSubsetAcquisitionKey(collectionId, acquisitionId),
391
+ )
392
+ : undefined
393
+ if (!acquisition) {
394
+ acquisitionId = safeRandomUUID()
395
+ collectionIds.set(options, acquisitionId)
396
+ acquisition = {
397
+ collectionId,
398
+ acquisitionId,
399
+ options: transportedOptions,
400
+ localOptions,
401
+ acquiredLeaderId: null,
402
+ inFlight: null,
403
+ release: null,
404
+ releaseRequested: false,
405
+ forceReplay: false,
406
+ retryTimer: null,
407
+ retryAttempts: 0,
408
+ }
409
+ this.outboundRemoteSubsetAcquisitions.set(
410
+ remoteSubsetAcquisitionKey(collectionId, acquisitionId),
411
+ acquisition,
412
+ )
413
+ }
414
+
415
+ try {
416
+ await this.acquireRemoteSubset(acquisition)
417
+ } catch (error) {
418
+ if (options.signal?.aborted) {
419
+ acquisition.releaseRequested = true
420
+ this.cancelRemoteSubsetReplayRetry(acquisition)
421
+ // The route-pending case owns no lease, while a concurrent response
422
+ // loss may have reached an owner. A best-effort terminal release is
423
+ // harmless for the former and prevents an orphan in the latter.
424
+ this.postRemoteSubsetRelease(acquisition)
425
+ const key = remoteSubsetAcquisitionKey(
426
+ collectionId,
427
+ acquisition.acquisitionId,
428
+ )
429
+ if (this.outboundRemoteSubsetAcquisitions.get(key) === acquisition) {
430
+ this.outboundRemoteSubsetAcquisitions.delete(key)
431
+ collectionIds.delete(options)
432
+ }
433
+ }
434
+ throw error
435
+ }
436
+ }
437
+
438
+ async requestReleaseRemoteSubset(
439
+ collectionId: string,
440
+ options: LoadSubsetOptions,
441
+ ): Promise<void> {
442
+ const collectionIds = this.remoteSubsetIds.get(collectionId)
443
+ const acquisitionId = collectionIds?.get(options)
444
+ if (!acquisitionId) return
445
+ const key = remoteSubsetAcquisitionKey(collectionId, acquisitionId)
446
+ const acquisition = this.outboundRemoteSubsetAcquisitions.get(key)
447
+ if (!acquisition) return
448
+ acquisition.releaseRequested = true
449
+ this.cancelRemoteSubsetReplayRetry(acquisition)
450
+ if (!acquisition.release) {
451
+ acquisition.release = (async () => {
452
+ const request: Extract<
453
+ RPCRequest,
454
+ { type: `rpc:releaseRemoteSubset:req` }
455
+ > = {
456
+ type: `rpc:releaseRemoteSubset:req`,
457
+ rpcId: safeRandomUUID(),
458
+ acquisitionId,
459
+ }
460
+ const localLeader = this.isLeader(collectionId)
461
+ let releaseReachedLeader = localLeader
462
+ try {
463
+ const response = localLeader
464
+ ? await this.handleReleaseRemoteSubset(
465
+ collectionId,
466
+ request,
467
+ this.nodeId,
468
+ )
469
+ : await this.sendRPC<ReleaseRemoteSubsetResponse>(
470
+ collectionId,
471
+ request,
472
+ )
473
+ releaseReachedLeader = true
474
+ if (!response.ok) {
475
+ throw new Error(`releaseRemoteSubset failed: ${response.error}`)
476
+ }
477
+ } finally {
478
+ // A response (including an owner unload failure) proves that the
479
+ // release reached the leader. Logical demand then retires exactly
480
+ // once. Only a transport failure with no response retains retry
481
+ // debt because delivery remains unknown.
482
+ if (
483
+ releaseReachedLeader &&
484
+ this.outboundRemoteSubsetAcquisitions.get(key) === acquisition
485
+ ) {
486
+ this.outboundRemoteSubsetAcquisitions.delete(key)
487
+ collectionIds!.delete(options)
488
+ }
489
+ }
490
+ })()
491
+ }
492
+ try {
493
+ await acquisition.release
494
+ } finally {
495
+ acquisition.release = null
496
+ }
497
+ }
498
+
499
+ private async acquireRemoteSubset(
500
+ acquisition: OutboundRemoteSubsetAcquisition,
501
+ ): Promise<void> {
502
+ if (acquisition.inFlight) return acquisition.inFlight
503
+
504
+ const route = { localOwner: false }
505
+ let resolveWork!: () => void
506
+ let rejectWork!: (error: unknown) => void
507
+ const work = new Promise<void>((resolve, reject) => {
508
+ resolveWork = resolve
509
+ rejectWork = reject
510
+ })
511
+ acquisition.inFlight = work
512
+ const run = async (): Promise<void> => {
513
+ const request: Extract<
514
+ RPCRequest,
515
+ { type: `rpc:ensureRemoteSubset:req` }
516
+ > = {
517
+ type: `rpc:ensureRemoteSubset:req`,
518
+ rpcId: safeRandomUUID(),
519
+ acquisitionId: acquisition.acquisitionId,
520
+ options: acquisition.options,
521
+ }
522
+ const state = this.collections.get(acquisition.collectionId)
523
+ if (
524
+ state?.participatesInLeadership &&
525
+ !state.isLeader &&
526
+ state.leaderId === null
527
+ ) {
528
+ await this.waitForLeadershipRoute(
529
+ acquisition.collectionId,
530
+ state,
531
+ acquisition.localOptions.signal,
532
+ )
533
+ }
534
+ route.localOwner = this.isLeader(acquisition.collectionId)
535
+ let response: EnsureRemoteSubsetResponse
536
+ try {
537
+ response = route.localOwner
538
+ ? await this.handleEnsureRemoteSubset(
539
+ acquisition.collectionId,
540
+ request,
541
+ this.nodeId,
542
+ acquisition.localOptions,
543
+ )
544
+ : await this.sendRPC<EnsureRemoteSubsetResponse>(
545
+ acquisition.collectionId,
546
+ request,
547
+ )
548
+ } catch (error) {
549
+ if (
550
+ route.localOwner ||
551
+ error instanceof RetryableRemoteSubsetAcquisitionError
552
+ ) {
553
+ throw error
554
+ }
555
+ throw new RetryableRemoteSubsetAcquisitionError(
556
+ `Remote subset transport failed`,
557
+ error,
558
+ )
559
+ }
560
+
561
+ if (!response.ok) {
562
+ if (response.retryable) {
563
+ throw new RetryableRemoteSubsetAcquisitionError(response.error)
564
+ }
565
+ throw new Error(`ensureRemoteSubset failed: ${response.error}`)
566
+ }
567
+ acquisition.acquiredLeaderId = response.leaderId
568
+ }
569
+ void run().then(resolveWork, rejectWork)
570
+ let acquired = false
571
+ try {
572
+ await work
573
+ acquired = true
574
+ this.cancelRemoteSubsetReplayRetry(acquisition)
575
+ } finally {
576
+ if (acquisition.inFlight === work) acquisition.inFlight = null
577
+ const current = this.collections.get(acquisition.collectionId)
578
+ const currentLeaderId = current?.isLeader
579
+ ? this.nodeId
580
+ : (current?.leaderId ?? null)
581
+ const key = remoteSubsetAcquisitionKey(
582
+ acquisition.collectionId,
583
+ acquisition.acquisitionId,
584
+ )
585
+ if (
586
+ acquired &&
587
+ !acquisition.releaseRequested &&
588
+ this.outboundRemoteSubsetAcquisitions.get(key) === acquisition &&
589
+ (acquisition.forceReplay ||
590
+ (currentLeaderId !== null &&
591
+ acquisition.acquiredLeaderId !== currentLeaderId))
592
+ ) {
593
+ acquisition.forceReplay = false
594
+ void this.acquireRemoteSubset(acquisition).catch((error) => {
595
+ this.scheduleRemoteSubsetReplayRetry(acquisition, error)
596
+ })
597
+ }
598
+ }
599
+ }
600
+
601
+ async requestEnsurePersistedIndex(
602
+ collectionId: string,
603
+ signature: string,
604
+ spec: PersistedIndexSpec,
605
+ scopedAdapter?: HydrationPersistenceAdapter,
606
+ localEnsureCompleted = false,
607
+ ): Promise<void> {
608
+ if (this.isLeader(collectionId)) {
609
+ if (localEnsureCompleted) return
610
+ await (scopedAdapter ?? this.requireAdapter(collectionId)).ensureIndex(
611
+ collectionId,
612
+ signature,
613
+ spec,
614
+ )
615
+ return
616
+ }
617
+
618
+ const response = await this.sendRPC<{
619
+ type: `rpc:ensurePersistedIndex:res`
620
+ rpcId: string
621
+ ok: boolean
622
+ error?: string
623
+ }>(collectionId, {
624
+ type: `rpc:ensurePersistedIndex:req`,
625
+ rpcId: safeRandomUUID(),
626
+ signature,
627
+ spec,
628
+ })
629
+
630
+ if (!response.ok) {
631
+ throw new Error(
632
+ `ensurePersistedIndex failed: ${response.error ?? `unknown error`}`,
633
+ )
634
+ }
635
+ }
636
+
637
+ async requestApplyLocalMutations(
638
+ collectionId: string,
639
+ mutations: Array<PersistedMutationEnvelope>,
640
+ ): Promise<ApplyLocalMutationsResponse> {
641
+ const initialRoute = this.waitForInitialRoute(collectionId)
642
+ if (initialRoute) await initialRoute
643
+ if (this.isLeader(collectionId)) {
644
+ return this.handleApplyLocalMutations(collectionId, {
645
+ type: `rpc:applyLocalMutations:req`,
646
+ rpcId: safeRandomUUID(),
647
+ envelopeId: safeRandomUUID(),
648
+ mutations,
649
+ })
650
+ }
651
+
652
+ return this.sendRPC<ApplyLocalMutationsResponse>(collectionId, {
653
+ type: `rpc:applyLocalMutations:req`,
654
+ rpcId: safeRandomUUID(),
655
+ envelopeId: safeRandomUUID(),
656
+ mutations,
657
+ })
658
+ }
659
+
660
+ async requestApplyCommittedTx(
661
+ collectionId: string,
662
+ tx: PersistedTx,
663
+ scopedAdapter?: HydrationPersistenceAdapter,
664
+ ): Promise<ApplyCommittedTxResponse> {
665
+ const initialRoute = this.waitForInitialRoute(collectionId)
666
+ if (initialRoute) await initialRoute
667
+ const request: Extract<RPCRequest, { type: `rpc:applyCommittedTx:req` }> = {
668
+ type: `rpc:applyCommittedTx:req`,
669
+ rpcId: safeRandomUUID(),
670
+ envelopeId: safeRandomUUID(),
671
+ tx,
672
+ }
673
+ if (this.isLeader(collectionId)) {
674
+ return this.handleApplyCommittedTx(collectionId, request, scopedAdapter)
675
+ }
676
+
677
+ return this.sendRPC<ApplyCommittedTxResponse>(collectionId, request)
678
+ }
679
+
680
+ async pullSince(
681
+ collectionId: string,
682
+ fromRowVersion: number,
683
+ scopedAdapter?: HydrationPersistenceAdapter,
684
+ ): Promise<PullSinceResponse> {
685
+ if (this.isLeader(collectionId)) {
686
+ return this.handlePullSince(
687
+ collectionId,
688
+ {
689
+ type: `rpc:pullSince:req`,
690
+ rpcId: safeRandomUUID(),
691
+ fromRowVersion,
692
+ },
693
+ scopedAdapter,
694
+ )
695
+ }
696
+
697
+ return this.sendRPC<PullSinceResponse>(collectionId, {
698
+ type: `rpc:pullSince:req`,
699
+ rpcId: safeRandomUUID(),
700
+ fromRowVersion,
701
+ })
702
+ }
703
+
704
+ // -----------------------------------------------------------------------
705
+ // Lifecycle
706
+ // -----------------------------------------------------------------------
707
+
708
+ dispose(): void {
709
+ if (this.disposed) return
710
+ this.disposed = true
711
+ const disposedError = new Error(`coordinator disposed`)
712
+ this.rejectDisposed?.(disposedError)
713
+ this.rejectDisposed = null
714
+
715
+ for (const acquisition of this.outboundRemoteSubsetAcquisitions.values()) {
716
+ this.cancelRemoteSubsetReplayRetry(acquisition)
717
+ this.postRemoteSubsetRelease(acquisition)
718
+ }
719
+ this.outboundRemoteSubsetAcquisitions.clear()
720
+ this.remoteSubsetIds.clear()
721
+
722
+ for (const [collectionId, state] of this.collections) {
723
+ this.releaseLeadership(collectionId, state)
724
+ }
725
+
726
+ for (const [, pending] of this.pendingRPCs) {
727
+ clearTimeout(pending.timer)
728
+ pending.reject(disposedError)
729
+ }
730
+ this.pendingRPCs.clear()
731
+ if (this.appliedEnvelopePruneTimer !== null) {
732
+ clearTimeout(this.appliedEnvelopePruneTimer)
733
+ this.appliedEnvelopePruneTimer = null
734
+ }
735
+
736
+ this.channel.close()
737
+ this.collections.clear()
738
+ this.collectionAdapters.clear()
739
+ for (const collectionId of this.remoteSubsetOwners.keys()) {
740
+ this.releaseInboundRemoteSubsetAcquisitions(collectionId)
741
+ }
742
+ this.remoteSubsetOwners.clear()
743
+ this.inboundRemoteSubsetAcquisitions.clear()
744
+ this.releasedRemoteSubsetAcquisitionTimes.clear()
745
+ this.appliedEnvelopes.clear()
746
+ this.inFlightEnvelopes.clear()
747
+ }
748
+
749
+ // -----------------------------------------------------------------------
750
+ // Leadership via Web Locks
751
+ // -----------------------------------------------------------------------
752
+
753
+ private getOrCreateCollectionState(collectionId: string): CollectionState {
754
+ let state = this.collections.get(collectionId)
755
+ if (!state) {
756
+ state = {
757
+ participatesInLeadership: false,
758
+ isLeader: false,
759
+ leaderId: null,
760
+ lockAbortController: null,
761
+ heartbeatTimer: null,
762
+ latestTerm: 0,
763
+ latestSeq: 0,
764
+ latestRowVersion: 0,
765
+ subscribers: new Set(),
766
+ routeWaiters: new Set(),
767
+ }
768
+ this.collections.set(collectionId, state)
769
+ }
770
+ return state
771
+ }
772
+
773
+ private ensureCollectionState(collectionId: string): CollectionState {
774
+ const state = this.getOrCreateCollectionState(collectionId)
775
+ if (!state.participatesInLeadership) {
776
+ state.participatesInLeadership = true
777
+ void this.acquireLeadership(collectionId, state)
778
+ }
779
+ return state
780
+ }
781
+
782
+ private async acquireLeadership(
783
+ collectionId: string,
784
+ state: CollectionState,
785
+ ): Promise<void> {
786
+ if (this.disposed || !state.participatesInLeadership || state.isLeader) {
787
+ return
788
+ }
789
+
790
+ const lockName = `tsdb:leader:${this.dbName}:${collectionId}`
791
+ const abortController = new AbortController()
792
+ state.lockAbortController?.abort()
793
+ state.lockAbortController = abortController
794
+
795
+ try {
796
+ await navigator.locks.request(
797
+ lockName,
798
+ { signal: abortController.signal },
799
+ async () => {
800
+ if (
801
+ this.isDisposed() ||
802
+ !this.participatesInLeadership(state) ||
803
+ this.collections.get(collectionId) !== state
804
+ ) {
805
+ return
806
+ }
807
+
808
+ try {
809
+ // Restore stream position from DB before claiming leadership
810
+ const adapter = this.requireAdapter(collectionId)
811
+ if (adapter.getStreamPosition) {
812
+ const pos = await adapter.getStreamPosition(collectionId)
813
+ state.latestTerm = pos.latestTerm
814
+ state.latestSeq = pos.latestSeq
815
+ state.latestRowVersion = pos.latestRowVersion
816
+ }
817
+
818
+ if (
819
+ this.isDisposed() ||
820
+ !this.participatesInLeadership(state) ||
821
+ this.collections.get(collectionId) !== state ||
822
+ abortController.signal.aborted ||
823
+ state.lockAbortController !== abortController
824
+ ) {
825
+ return
826
+ }
827
+
828
+ state.latestTerm++
829
+ state.isLeader = true
830
+ state.leaderId = this.nodeId
831
+ this.notifyRouteWaiters(state)
832
+
833
+ this.emitHeartbeat(collectionId, state)
834
+ void this.replayRemoteSubsetAcquisitions(collectionId)
835
+ state.heartbeatTimer = setInterval(() => {
836
+ this.emitHeartbeat(collectionId, state)
837
+ }, HEARTBEAT_INTERVAL_MS)
838
+
839
+ // Hold the lock until disposed or aborted
840
+ await new Promise<void>((resolve) => {
841
+ const onAbort = () => {
842
+ abortController.signal.removeEventListener(`abort`, onAbort)
843
+ resolve()
844
+ }
845
+ if (abortController.signal.aborted) {
846
+ resolve()
847
+ return
848
+ }
849
+ abortController.signal.addEventListener(`abort`, onAbort)
850
+ })
851
+ } finally {
852
+ this.releaseInboundRemoteSubsetAcquisitions(collectionId)
853
+ state.isLeader = false
854
+ state.leaderId = null
855
+ if (state.heartbeatTimer) {
856
+ clearInterval(state.heartbeatTimer)
857
+ state.heartbeatTimer = null
858
+ }
859
+ }
860
+ },
861
+ )
862
+ } catch (error) {
863
+ if (error instanceof DOMException && error.name === `AbortError`) {
864
+ return
865
+ }
866
+ console.warn(`Failed to acquire leadership for ${collectionId}:`, error)
867
+ if (
868
+ !this.isDisposed() &&
869
+ this.participatesInLeadership(state) &&
870
+ this.collections.get(collectionId) === state
871
+ ) {
872
+ await sleep(LEADERSHIP_RETRY_DELAY_MS)
873
+ }
874
+ } finally {
875
+ if (state.lockAbortController === abortController) {
876
+ state.lockAbortController = null
877
+ }
878
+ }
879
+
880
+ // Re-acquire if not disposed (leadership was released by another means)
881
+ if (
882
+ !this.isDisposed() &&
883
+ this.participatesInLeadership(state) &&
884
+ this.collections.get(collectionId) === state &&
885
+ state.lockAbortController === null
886
+ ) {
887
+ void this.acquireLeadership(collectionId, state)
888
+ }
889
+ }
890
+
891
+ private waitForInitialRoute(collectionId: string): Promise<void> | undefined {
892
+ const state = this.collections.get(collectionId)
893
+ if (
894
+ state?.participatesInLeadership &&
895
+ !state.isLeader &&
896
+ state.leaderId === null
897
+ ) {
898
+ this.channel.postMessage({
899
+ v: 1,
900
+ dbName: this.dbName,
901
+ collectionId,
902
+ senderId: this.nodeId,
903
+ ts: Date.now(),
904
+ payload: { type: `leader:routeRequest` },
905
+ } satisfies ProtocolEnvelope<{ type: `leader:routeRequest` }>)
906
+ return this.waitForLeadershipRoute(
907
+ collectionId,
908
+ state,
909
+ undefined,
910
+ RPC_TIMEOUT_MS,
911
+ )
912
+ }
913
+ return undefined
914
+ }
915
+
916
+ private waitForLeadershipRoute(
917
+ collectionId: string,
918
+ state: CollectionState,
919
+ signal?: AbortSignal,
920
+ timeoutMs?: number,
921
+ ): Promise<void> {
922
+ if (state.isLeader || state.leaderId !== null) return Promise.resolve()
923
+
924
+ return new Promise<void>((resolve, reject) => {
925
+ let timer: ReturnType<typeof setTimeout> | undefined
926
+ const finish = () => {
927
+ state.routeWaiters?.delete(onRouteChange)
928
+ signal?.removeEventListener(`abort`, onAbort)
929
+ if (timer !== undefined) clearTimeout(timer)
930
+ }
931
+ const onAbort = () => {
932
+ finish()
933
+ reject(
934
+ new DOMException(`Remote subset acquisition aborted`, `AbortError`),
935
+ )
936
+ }
937
+ const onRouteChange = () => {
938
+ if (this.isDisposed() || !state.participatesInLeadership) {
939
+ finish()
940
+ reject(
941
+ new Error(
942
+ `${this.coordinatorName}: leadership route closed for collection "${collectionId}"`,
943
+ ),
944
+ )
945
+ return
946
+ }
947
+ if (state.isLeader || state.leaderId !== null) {
948
+ finish()
949
+ resolve()
950
+ }
951
+ }
952
+
953
+ ;(state.routeWaiters ??= new Set()).add(onRouteChange)
954
+ if (timeoutMs !== undefined) {
955
+ timer = setTimeout(() => {
956
+ finish()
957
+ reject(
958
+ new Error(
959
+ `${this.coordinatorName}: leadership route for collection "${collectionId}" timed out after ${timeoutMs}ms`,
960
+ ),
961
+ )
962
+ }, timeoutMs)
963
+ }
964
+ signal?.addEventListener(`abort`, onAbort, { once: true })
965
+ if (signal?.aborted) {
966
+ onAbort()
967
+ } else {
968
+ onRouteChange()
969
+ }
970
+ })
971
+ }
972
+
973
+ private notifyRouteWaiters(state: CollectionState): void {
974
+ for (const notify of Array.from(state.routeWaiters ?? [])) notify()
975
+ }
976
+
977
+ private releaseLeadership(
978
+ collectionId: string,
979
+ state: CollectionState,
980
+ ): void {
981
+ this.releaseInboundRemoteSubsetAcquisitions(collectionId)
982
+ if (state.lockAbortController) {
983
+ state.lockAbortController.abort()
984
+ state.lockAbortController = null
985
+ }
986
+ if (state.heartbeatTimer) {
987
+ clearInterval(state.heartbeatTimer)
988
+ state.heartbeatTimer = null
989
+ }
990
+ state.isLeader = false
991
+ state.leaderId = null
992
+ this.notifyRouteWaiters(state)
993
+ }
994
+
995
+ private releaseCollectionIfUnused(
996
+ collectionId: string,
997
+ state: CollectionState,
998
+ ): void {
999
+ if (
1000
+ state.subscribers.size > 0 ||
1001
+ this.collections.get(collectionId) !== state
1002
+ ) {
1003
+ return
1004
+ }
1005
+ this.releaseLeadership(collectionId, state)
1006
+ this.collections.delete(collectionId)
1007
+ this.collectionAdapters.delete(collectionId)
1008
+ const prefix = `${JSON.stringify([collectionId]).slice(0, -1)},`
1009
+ for (const key of this.appliedEnvelopes.keys()) {
1010
+ if (key.startsWith(prefix)) this.appliedEnvelopes.delete(key)
1011
+ }
1012
+ this.pruneAppliedEnvelopes()
1013
+ }
1014
+
1015
+ private postRemoteSubsetRelease(
1016
+ acquisition: OutboundRemoteSubsetAcquisition,
1017
+ ): void {
1018
+ if (acquisition.release) return
1019
+ const request: Extract<
1020
+ RPCRequest,
1021
+ { type: `rpc:releaseRemoteSubset:req` }
1022
+ > = {
1023
+ type: `rpc:releaseRemoteSubset:req`,
1024
+ rpcId: safeRandomUUID(),
1025
+ acquisitionId: acquisition.acquisitionId,
1026
+ }
1027
+ if (this.isLeader(acquisition.collectionId)) {
1028
+ void this.handleReleaseRemoteSubset(
1029
+ acquisition.collectionId,
1030
+ request,
1031
+ this.nodeId,
1032
+ ).catch(() => {
1033
+ // The owner already received the exact unload failure through onError.
1034
+ })
1035
+ return
1036
+ }
1037
+ this.channel.postMessage({
1038
+ v: 1,
1039
+ dbName: this.dbName,
1040
+ collectionId: acquisition.collectionId,
1041
+ senderId: this.nodeId,
1042
+ ts: Date.now(),
1043
+ payload: request,
1044
+ } satisfies ProtocolEnvelope<unknown>)
1045
+ }
1046
+
1047
+ private cancelRemoteSubsetReplayRetry(
1048
+ acquisition: OutboundRemoteSubsetAcquisition,
1049
+ ): void {
1050
+ if (acquisition.retryTimer !== null) {
1051
+ clearTimeout(acquisition.retryTimer)
1052
+ acquisition.retryTimer = null
1053
+ }
1054
+ acquisition.retryAttempts = 0
1055
+ }
1056
+
1057
+ private scheduleRemoteSubsetReplayRetry(
1058
+ acquisition: OutboundRemoteSubsetAcquisition,
1059
+ error: unknown,
1060
+ ): void {
1061
+ const key = remoteSubsetAcquisitionKey(
1062
+ acquisition.collectionId,
1063
+ acquisition.acquisitionId,
1064
+ )
1065
+ if (
1066
+ !(error instanceof RetryableRemoteSubsetAcquisitionError) ||
1067
+ this.isDisposed() ||
1068
+ this.outboundRemoteSubsetAcquisitions.get(key) !== acquisition ||
1069
+ acquisition.releaseRequested ||
1070
+ acquisition.retryTimer !== null ||
1071
+ acquisition.retryAttempts >= REMOTE_SUBSET_REPLAY_RETRY_ATTEMPTS
1072
+ ) {
1073
+ return
1074
+ }
1075
+
1076
+ acquisition.retryAttempts++
1077
+ acquisition.retryTimer = setTimeout(() => {
1078
+ acquisition.retryTimer = null
1079
+ if (
1080
+ this.isDisposed() ||
1081
+ this.outboundRemoteSubsetAcquisitions.get(key) !== acquisition ||
1082
+ acquisition.releaseRequested
1083
+ ) {
1084
+ return
1085
+ }
1086
+ void this.acquireRemoteSubset(acquisition).catch((retryError) => {
1087
+ this.scheduleRemoteSubsetReplayRetry(acquisition, retryError)
1088
+ })
1089
+ }, RPC_RETRY_DELAY_MS)
1090
+ }
1091
+
1092
+ private async replayRemoteSubsetAcquisitions(
1093
+ collectionId: string,
1094
+ ): Promise<void> {
1095
+ if (this.isDisposed()) return
1096
+ const state = this.collections.get(collectionId)
1097
+ const leaderId = state?.isLeader ? this.nodeId : state?.leaderId
1098
+ if (!leaderId) return
1099
+
1100
+ const replays: Array<Promise<void>> = []
1101
+ for (const acquisition of this.outboundRemoteSubsetAcquisitions.values()) {
1102
+ if (
1103
+ acquisition.collectionId !== collectionId ||
1104
+ acquisition.releaseRequested ||
1105
+ (!acquisition.forceReplay && acquisition.acquiredLeaderId === leaderId)
1106
+ ) {
1107
+ continue
1108
+ }
1109
+ if (acquisition.inFlight) {
1110
+ acquisition.forceReplay = true
1111
+ continue
1112
+ }
1113
+ acquisition.forceReplay = false
1114
+ replays.push(
1115
+ this.acquireRemoteSubset(acquisition).catch((error) => {
1116
+ this.scheduleRemoteSubsetReplayRetry(acquisition, error)
1117
+ }),
1118
+ )
1119
+ }
1120
+ await Promise.all(replays)
1121
+ }
1122
+
1123
+ private emitHeartbeat(collectionId: string, state: CollectionState): void {
1124
+ const envelope: ProtocolEnvelope<unknown> = {
1125
+ v: 1,
1126
+ dbName: this.dbName,
1127
+ collectionId,
1128
+ senderId: this.nodeId,
1129
+ ts: Date.now(),
1130
+ payload: {
1131
+ type: `leader:heartbeat`,
1132
+ term: state.latestTerm,
1133
+ leaderId: this.nodeId,
1134
+ latestSeq: state.latestSeq,
1135
+ latestRowVersion: state.latestRowVersion,
1136
+ },
1137
+ }
1138
+ this.channel.postMessage(envelope)
1139
+ }
1140
+
1141
+ // -----------------------------------------------------------------------
1142
+ // BroadcastChannel message handling
1143
+ // -----------------------------------------------------------------------
1144
+
1145
+ private onChannelMessage(data: unknown): void {
1146
+ if (!isProtocolEnvelope(data)) return
1147
+
1148
+ const envelope = data
1149
+
1150
+ // Ignore own messages
1151
+ if (envelope.senderId === this.nodeId) return
1152
+
1153
+ const payload = envelope.payload
1154
+ if (!payload || typeof payload !== `object`) return
1155
+
1156
+ const type = (payload as Record<string, unknown>).type as string | undefined
1157
+
1158
+ if (type === `leader:routeRequest`) {
1159
+ const state = this.collections.get(envelope.collectionId)
1160
+ if (state?.isLeader) this.emitHeartbeat(envelope.collectionId, state)
1161
+ return
1162
+ }
1163
+
1164
+ if (type === `leader:heartbeat`) {
1165
+ const heartbeat = payload as {
1166
+ leaderId?: unknown
1167
+ term?: unknown
1168
+ latestSeq?: unknown
1169
+ latestRowVersion?: unknown
1170
+ }
1171
+ if (
1172
+ typeof heartbeat.leaderId === `string` &&
1173
+ typeof heartbeat.term === `number` &&
1174
+ typeof heartbeat.latestSeq === `number` &&
1175
+ typeof heartbeat.latestRowVersion === `number`
1176
+ ) {
1177
+ const state = this.getOrCreateCollectionState(envelope.collectionId)
1178
+ if (heartbeat.term < state.latestTerm) return
1179
+ const changedLeader = state.leaderId !== heartbeat.leaderId
1180
+ state.leaderId = heartbeat.leaderId
1181
+ state.latestTerm = Math.max(state.latestTerm, heartbeat.term)
1182
+ state.latestSeq = Math.max(state.latestSeq, heartbeat.latestSeq)
1183
+ state.latestRowVersion = Math.max(
1184
+ state.latestRowVersion,
1185
+ heartbeat.latestRowVersion,
1186
+ )
1187
+ this.notifyRouteWaiters(state)
1188
+ if (changedLeader) {
1189
+ void this.replayRemoteSubsetAcquisitions(envelope.collectionId)
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ // Handle RPC responses (for pending outbound RPCs)
1195
+ if (type && type.endsWith(`:res`)) {
1196
+ const rpcId = (payload as { rpcId?: string }).rpcId
1197
+ if (rpcId && this.pendingRPCs.has(rpcId)) {
1198
+ const pending = this.pendingRPCs.get(rpcId)!
1199
+ this.pendingRPCs.delete(rpcId)
1200
+ clearTimeout(pending.timer)
1201
+ pending.resolve(payload as RPCResponse)
1202
+ return
1203
+ }
1204
+ }
1205
+
1206
+ // Handle RPC requests (leader only)
1207
+ if (type && type.endsWith(`:req`)) {
1208
+ if (!isRPCRequest(payload)) return
1209
+ const collectionId = envelope.collectionId
1210
+ if (this.isLeader(collectionId)) {
1211
+ void this.handleRPCRequest(collectionId, payload, envelope.senderId)
1212
+ }
1213
+ return
1214
+ }
1215
+
1216
+ // Forward protocol messages to subscribers
1217
+ const state = this.collections.get(envelope.collectionId)
1218
+ if (state) {
1219
+ for (const subscriber of state.subscribers) {
1220
+ subscriber(envelope)
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ // -----------------------------------------------------------------------
1226
+ // RPC - Outbound (follower side)
1227
+ // -----------------------------------------------------------------------
1228
+
1229
+ private async sendRPC<T extends RPCResponse>(
1230
+ collectionId: string,
1231
+ request: RPCRequest,
1232
+ ): Promise<T> {
1233
+ if (this.isDisposed()) throw new Error(`coordinator disposed`)
1234
+ let lastError: Error | undefined
1235
+ let firstTransportCause: unknown
1236
+ const mutationRequestType = isMutatingRPCRequest(request)
1237
+ ? request.type
1238
+ : undefined
1239
+ const mutationRoute = mutationRequestType
1240
+ ? this.captureMutationRoute(collectionId)
1241
+ : undefined
1242
+
1243
+ for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
1244
+ if (attempt > 0) {
1245
+ await Promise.race([
1246
+ sleep(RPC_RETRY_DELAY_MS * attempt),
1247
+ this.disposedPromise,
1248
+ ])
1249
+ }
1250
+ if (this.isDisposed()) throw new Error(`coordinator disposed`)
1251
+
1252
+ if (
1253
+ mutationRoute &&
1254
+ mutationRequestType &&
1255
+ firstTransportCause !== undefined
1256
+ ) {
1257
+ this.assertMutationRouteUnchanged(
1258
+ collectionId,
1259
+ mutationRequestType,
1260
+ mutationRoute,
1261
+ firstTransportCause,
1262
+ )
1263
+ }
1264
+
1265
+ if (this.isLeader(collectionId)) {
1266
+ return (await this.dispatchRPCRequest(
1267
+ collectionId,
1268
+ request,
1269
+ this.nodeId,
1270
+ )) as T
1271
+ }
1272
+
1273
+ try {
1274
+ return await this.sendRPCOnce<T>(collectionId, request)
1275
+ } catch (error) {
1276
+ if (this.isDisposed()) throw error
1277
+ firstTransportCause ??= error
1278
+ if (mutationRoute && mutationRequestType) {
1279
+ this.assertMutationRouteUnchanged(
1280
+ collectionId,
1281
+ mutationRequestType,
1282
+ mutationRoute,
1283
+ firstTransportCause,
1284
+ )
1285
+ }
1286
+ lastError = error instanceof Error ? error : new Error(String(error))
1287
+ }
1288
+ }
1289
+
1290
+ throw lastError ?? new Error(`RPC failed after retries`)
1291
+ }
1292
+
1293
+ private captureMutationRoute(collectionId: string): {
1294
+ leaderId: string | null
1295
+ term: number | null
1296
+ } {
1297
+ const state = this.collections.get(collectionId)
1298
+ return {
1299
+ leaderId: state?.isLeader ? this.nodeId : (state?.leaderId ?? null),
1300
+ term: state?.latestTerm ?? null,
1301
+ }
1302
+ }
1303
+
1304
+ private assertMutationRouteUnchanged(
1305
+ collectionId: string,
1306
+ requestType: IndeterminateCommitRequestType,
1307
+ previous: { leaderId: string | null; term: number | null },
1308
+ cause: unknown,
1309
+ ): void {
1310
+ const current = this.captureMutationRoute(collectionId)
1311
+ if (
1312
+ previous.leaderId !== null &&
1313
+ previous.term !== null &&
1314
+ current.leaderId === previous.leaderId &&
1315
+ current.term === previous.term
1316
+ ) {
1317
+ return
1318
+ }
1319
+ throw new IndeterminateCommitError({
1320
+ collectionId,
1321
+ requestType,
1322
+ previousLeaderId: previous.leaderId,
1323
+ previousTerm: previous.term,
1324
+ currentLeaderId: current.leaderId,
1325
+ currentTerm: current.term,
1326
+ cause,
1327
+ })
1328
+ }
1329
+
1330
+ private sendRPCOnce<T extends RPCResponse>(
1331
+ collectionId: string,
1332
+ request: RPCRequest,
1333
+ ): Promise<T> {
1334
+ return new Promise<T>((resolve, reject) => {
1335
+ const rpcId = request.rpcId
1336
+
1337
+ const timer = setTimeout(() => {
1338
+ this.pendingRPCs.delete(rpcId)
1339
+ reject(
1340
+ new Error(`RPC ${request.type} timed out after ${RPC_TIMEOUT_MS}ms`),
1341
+ )
1342
+ }, RPC_TIMEOUT_MS)
1343
+
1344
+ this.pendingRPCs.set(rpcId, {
1345
+ resolve: resolve as (response: RPCResponse) => void,
1346
+ reject,
1347
+ timer,
1348
+ })
1349
+
1350
+ const envelope: ProtocolEnvelope<unknown> = {
1351
+ v: 1,
1352
+ dbName: this.dbName,
1353
+ collectionId,
1354
+ senderId: this.nodeId,
1355
+ ts: Date.now(),
1356
+ payload: request,
1357
+ }
1358
+ this.channel.postMessage(envelope)
1359
+ })
1360
+ }
1361
+
1362
+ // -----------------------------------------------------------------------
1363
+ // RPC - Inbound (leader side)
1364
+ // -----------------------------------------------------------------------
1365
+
1366
+ private async handleRPCRequest(
1367
+ collectionId: string,
1368
+ request: RPCRequest,
1369
+ requesterId: string,
1370
+ ): Promise<void> {
1371
+ let response: RPCResponse
1372
+
1373
+ try {
1374
+ response = await this.dispatchRPCRequest(
1375
+ collectionId,
1376
+ request,
1377
+ requesterId,
1378
+ )
1379
+ } catch (error) {
1380
+ response = createRPCErrorResponse(request, error)
1381
+ }
1382
+
1383
+ if (this.isDisposed()) {
1384
+ return
1385
+ }
1386
+
1387
+ const envelope: ProtocolEnvelope<unknown> = {
1388
+ v: 1,
1389
+ dbName: this.dbName,
1390
+ collectionId,
1391
+ senderId: this.nodeId,
1392
+ ts: Date.now(),
1393
+ payload: response,
1394
+ }
1395
+ this.channel.postMessage(envelope)
1396
+ }
1397
+
1398
+ private dispatchRPCRequest(
1399
+ collectionId: string,
1400
+ request: RPCRequest,
1401
+ requesterId: string,
1402
+ ): Promise<RPCResponse> {
1403
+ switch (request.type) {
1404
+ case `rpc:ensureRemoteSubset:req`:
1405
+ return this.handleEnsureRemoteSubset(collectionId, request, requesterId)
1406
+ case `rpc:releaseRemoteSubset:req`:
1407
+ return this.handleReleaseRemoteSubset(
1408
+ collectionId,
1409
+ request,
1410
+ requesterId,
1411
+ )
1412
+ case `rpc:ensurePersistedIndex:req`:
1413
+ return this.handleEnsurePersistedIndex(collectionId, request)
1414
+ case `rpc:applyLocalMutations:req`:
1415
+ return this.handleApplyLocalMutations(collectionId, request)
1416
+ case `rpc:applyCommittedTx:req`:
1417
+ return this.handleApplyCommittedTx(collectionId, request)
1418
+ case `rpc:pullSince:req`:
1419
+ return this.handlePullSince(collectionId, request)
1420
+ }
1421
+ }
1422
+
1423
+ private async handleEnsureRemoteSubset(
1424
+ collectionId: string,
1425
+ request: Extract<RPCRequest, { type: `rpc:ensureRemoteSubset:req` }>,
1426
+ requesterId: string,
1427
+ localOptions?: TransportedLoadSubsetOptions,
1428
+ ): Promise<EnsureRemoteSubsetResponse> {
1429
+ this.pruneReleasedRemoteSubsetAcquisitions()
1430
+ const key = inboundRemoteSubsetAcquisitionKey(
1431
+ collectionId,
1432
+ requesterId,
1433
+ request.acquisitionId,
1434
+ )
1435
+ const existing = this.inboundRemoteSubsetAcquisitions.get(key)
1436
+ const awaitingOwner =
1437
+ existing && `awaitingOwner` in existing ? existing : undefined
1438
+ if (existing) {
1439
+ if (`owner` in existing) {
1440
+ if (!existing.released) {
1441
+ await existing.load
1442
+ return {
1443
+ type: `rpc:ensureRemoteSubset:res`,
1444
+ rpcId: request.rpcId,
1445
+ ok: true,
1446
+ leaderId: this.nodeId,
1447
+ }
1448
+ }
1449
+ await existing.release
1450
+ if (existing.terminalRelease) {
1451
+ return {
1452
+ type: `rpc:ensureRemoteSubset:res`,
1453
+ rpcId: request.rpcId,
1454
+ ok: true,
1455
+ leaderId: this.nodeId,
1456
+ }
1457
+ }
1458
+ if (this.inboundRemoteSubsetAcquisitions.get(key) === existing) {
1459
+ this.inboundRemoteSubsetAcquisitions.delete(key)
1460
+ }
1461
+ } else if (!(`awaitingOwner` in existing)) {
1462
+ return {
1463
+ type: `rpc:ensureRemoteSubset:res`,
1464
+ rpcId: request.rpcId,
1465
+ ok: true,
1466
+ leaderId: this.nodeId,
1467
+ }
1468
+ }
1469
+ }
1470
+
1471
+ const owner = this.remoteSubsetOwners.get(collectionId)
1472
+ if (!owner) {
1473
+ throw new RetryableRemoteSubsetAcquisitionError(
1474
+ `${this.coordinatorName}: no remote subset owner registered for collection "${collectionId}"`,
1475
+ )
1476
+ }
1477
+
1478
+ await this.startRemoteSubsetOwnerAcquisition({
1479
+ collectionId,
1480
+ requesterId,
1481
+ acquisitionId: request.acquisitionId,
1482
+ owner,
1483
+ options: awaitingOwner?.options ?? localOptions ?? request.options,
1484
+ restoreOnSynchronousFailure: awaitingOwner,
1485
+ })
1486
+ return {
1487
+ type: `rpc:ensureRemoteSubset:res`,
1488
+ rpcId: request.rpcId,
1489
+ ok: true,
1490
+ leaderId: this.nodeId,
1491
+ }
1492
+ }
1493
+
1494
+ private async handleReleaseRemoteSubset(
1495
+ collectionId: string,
1496
+ request: Extract<RPCRequest, { type: `rpc:releaseRemoteSubset:req` }>,
1497
+ requesterId: string,
1498
+ ): Promise<ReleaseRemoteSubsetResponse> {
1499
+ this.pruneReleasedRemoteSubsetAcquisitions()
1500
+ const key = inboundRemoteSubsetAcquisitionKey(
1501
+ collectionId,
1502
+ requesterId,
1503
+ request.acquisitionId,
1504
+ )
1505
+ const acquisition = this.inboundRemoteSubsetAcquisitions.get(key)
1506
+ if (!acquisition) {
1507
+ this.setReleasedRemoteSubsetAcquisition(key, {
1508
+ collectionId,
1509
+ requesterId,
1510
+ acquisitionId: request.acquisitionId,
1511
+ released: true,
1512
+ })
1513
+ } else if (`owner` in acquisition) {
1514
+ acquisition.terminalRelease = true
1515
+ try {
1516
+ await this.releaseRemoteSubsetAcquisition(acquisition)
1517
+ } finally {
1518
+ if (this.inboundRemoteSubsetAcquisitions.get(key) === acquisition) {
1519
+ this.setReleasedRemoteSubsetAcquisition(key, {
1520
+ collectionId,
1521
+ requesterId,
1522
+ acquisitionId: request.acquisitionId,
1523
+ released: true,
1524
+ })
1525
+ }
1526
+ }
1527
+ } else if (`awaitingOwner` in acquisition) {
1528
+ this.setReleasedRemoteSubsetAcquisition(key, {
1529
+ collectionId,
1530
+ requesterId,
1531
+ acquisitionId: request.acquisitionId,
1532
+ released: true,
1533
+ })
1534
+ }
1535
+ return {
1536
+ type: `rpc:releaseRemoteSubset:res`,
1537
+ rpcId: request.rpcId,
1538
+ ok: true,
1539
+ }
1540
+ }
1541
+
1542
+ private releaseRemoteSubsetAcquisition(
1543
+ acquisition: ActiveRemoteSubsetAcquisition,
1544
+ ): Promise<void> {
1545
+ if (acquisition.release) return acquisition.release
1546
+ acquisition.released = true
1547
+ acquisition.release = (async () => {
1548
+ try {
1549
+ await acquisition.load
1550
+ } catch {
1551
+ // A returned promise transfers the lease even when initial loading fails.
1552
+ }
1553
+ try {
1554
+ if (acquisition.transferred) {
1555
+ await unloadRemoteSubsetOwner(acquisition.owner, acquisition.options)
1556
+ }
1557
+ } finally {
1558
+ if (!acquisition.terminalRelease) {
1559
+ const key = inboundRemoteSubsetAcquisitionKey(
1560
+ acquisition.collectionId,
1561
+ acquisition.requesterId,
1562
+ acquisition.acquisitionId,
1563
+ )
1564
+ if (this.inboundRemoteSubsetAcquisitions.get(key) === acquisition) {
1565
+ this.inboundRemoteSubsetAcquisitions.set(key, {
1566
+ collectionId: acquisition.collectionId,
1567
+ requesterId: acquisition.requesterId,
1568
+ acquisitionId: acquisition.acquisitionId,
1569
+ options: acquisition.options,
1570
+ released: true,
1571
+ awaitingOwner: true,
1572
+ })
1573
+ }
1574
+ }
1575
+ }
1576
+ })()
1577
+ return acquisition.release
1578
+ }
1579
+
1580
+ private releaseInboundRemoteSubsetAcquisitions(
1581
+ collectionId: string,
1582
+ owner?: RemoteSubsetOwner,
1583
+ ): void {
1584
+ for (const acquisition of this.inboundRemoteSubsetAcquisitions.values()) {
1585
+ if (
1586
+ !(`owner` in acquisition) ||
1587
+ acquisition.collectionId !== collectionId ||
1588
+ (owner && acquisition.owner !== owner)
1589
+ ) {
1590
+ continue
1591
+ }
1592
+ void this.releaseRemoteSubsetAcquisition(acquisition).catch(
1593
+ () => undefined,
1594
+ )
1595
+ }
1596
+ }
1597
+
1598
+ private rebindRemoteInboundSubsetAcquisitions(
1599
+ collectionId: string,
1600
+ owner: RemoteSubsetOwner,
1601
+ ): void {
1602
+ for (const acquisition of this.inboundRemoteSubsetAcquisitions.values()) {
1603
+ if (`awaitingOwner` in acquisition) {
1604
+ if (
1605
+ acquisition.collectionId === collectionId &&
1606
+ acquisition.requesterId !== this.nodeId
1607
+ ) {
1608
+ void this.bindAwaitingRemoteSubsetAcquisition(
1609
+ acquisition,
1610
+ owner,
1611
+ ).catch(() => {
1612
+ // The owner receives the exact load failure through onError.
1613
+ })
1614
+ }
1615
+ continue
1616
+ }
1617
+ if (
1618
+ !(`owner` in acquisition) ||
1619
+ acquisition.collectionId !== collectionId ||
1620
+ acquisition.requesterId === this.nodeId ||
1621
+ !acquisition.released ||
1622
+ acquisition.terminalRelease
1623
+ ) {
1624
+ continue
1625
+ }
1626
+
1627
+ void this.rebindRemoteInboundSubsetAcquisition(acquisition, owner).catch(
1628
+ () => undefined,
1629
+ )
1630
+ }
1631
+ }
1632
+
1633
+ private async rebindRemoteInboundSubsetAcquisition(
1634
+ previous: ActiveRemoteSubsetAcquisition,
1635
+ owner: RemoteSubsetOwner,
1636
+ ): Promise<void> {
1637
+ await previous.release
1638
+ if (
1639
+ previous.terminalRelease ||
1640
+ this.remoteSubsetOwners.get(previous.collectionId) !== owner
1641
+ ) {
1642
+ return
1643
+ }
1644
+
1645
+ const key = inboundRemoteSubsetAcquisitionKey(
1646
+ previous.collectionId,
1647
+ previous.requesterId,
1648
+ previous.acquisitionId,
1649
+ )
1650
+ const current = this.inboundRemoteSubsetAcquisitions.get(key)
1651
+ if (current && current !== previous) {
1652
+ if (`awaitingOwner` in current) {
1653
+ await this.bindAwaitingRemoteSubsetAcquisition(current, owner)
1654
+ }
1655
+ return
1656
+ }
1657
+
1658
+ const awaitingOwner: AwaitingRemoteSubsetOwnerAcquisition = {
1659
+ collectionId: previous.collectionId,
1660
+ requesterId: previous.requesterId,
1661
+ acquisitionId: previous.acquisitionId,
1662
+ options: previous.options,
1663
+ released: true,
1664
+ awaitingOwner: true,
1665
+ }
1666
+ this.inboundRemoteSubsetAcquisitions.set(key, awaitingOwner)
1667
+ await this.bindAwaitingRemoteSubsetAcquisition(awaitingOwner, owner)
1668
+ }
1669
+
1670
+ private async bindAwaitingRemoteSubsetAcquisition(
1671
+ awaitingOwner: AwaitingRemoteSubsetOwnerAcquisition,
1672
+ owner: RemoteSubsetOwner,
1673
+ ): Promise<void> {
1674
+ if (this.remoteSubsetOwners.get(awaitingOwner.collectionId) !== owner) {
1675
+ return
1676
+ }
1677
+ const key = inboundRemoteSubsetAcquisitionKey(
1678
+ awaitingOwner.collectionId,
1679
+ awaitingOwner.requesterId,
1680
+ awaitingOwner.acquisitionId,
1681
+ )
1682
+ if (this.inboundRemoteSubsetAcquisitions.get(key) !== awaitingOwner) return
1683
+
1684
+ await this.startRemoteSubsetOwnerAcquisition({
1685
+ collectionId: awaitingOwner.collectionId,
1686
+ requesterId: awaitingOwner.requesterId,
1687
+ acquisitionId: awaitingOwner.acquisitionId,
1688
+ owner,
1689
+ options: awaitingOwner.options,
1690
+ restoreOnSynchronousFailure: awaitingOwner,
1691
+ })
1692
+ }
1693
+
1694
+ private async startRemoteSubsetOwnerAcquisition(options: {
1695
+ collectionId: string
1696
+ requesterId: string
1697
+ acquisitionId: string
1698
+ owner: RemoteSubsetOwner
1699
+ options: TransportedLoadSubsetOptions
1700
+ restoreOnSynchronousFailure?: AwaitingRemoteSubsetOwnerAcquisition
1701
+ }): Promise<void> {
1702
+ const key = inboundRemoteSubsetAcquisitionKey(
1703
+ options.collectionId,
1704
+ options.requesterId,
1705
+ options.acquisitionId,
1706
+ )
1707
+ const acquisition: ActiveRemoteSubsetAcquisition = {
1708
+ collectionId: options.collectionId,
1709
+ requesterId: options.requesterId,
1710
+ acquisitionId: options.acquisitionId,
1711
+ owner: options.owner,
1712
+ options: options.options,
1713
+ load: Promise.resolve(),
1714
+ transferred: false,
1715
+ released: false,
1716
+ terminalRelease: false,
1717
+ release: null,
1718
+ }
1719
+ this.releasedRemoteSubsetAcquisitionTimes.delete(key)
1720
+ this.inboundRemoteSubsetAcquisitions.set(key, acquisition)
1721
+ let resolveLoad!: () => void
1722
+ let rejectLoad!: (error: unknown) => void
1723
+ acquisition.load = new Promise<void>((resolve, reject) => {
1724
+ resolveLoad = resolve
1725
+ rejectLoad = reject
1726
+ })
1727
+ try {
1728
+ const load = options.owner(acquisition.options)
1729
+ acquisition.transferred = true
1730
+ void Promise.resolve(load).then(resolveLoad, rejectLoad)
1731
+ } catch (error) {
1732
+ rejectLoad(error)
1733
+ }
1734
+
1735
+ try {
1736
+ await acquisition.load
1737
+ } catch (error) {
1738
+ if (!acquisition.transferred) {
1739
+ if (this.inboundRemoteSubsetAcquisitions.get(key) === acquisition) {
1740
+ if (options.restoreOnSynchronousFailure) {
1741
+ this.inboundRemoteSubsetAcquisitions.set(
1742
+ key,
1743
+ options.restoreOnSynchronousFailure,
1744
+ )
1745
+ } else {
1746
+ this.inboundRemoteSubsetAcquisitions.delete(key)
1747
+ }
1748
+ }
1749
+ } else {
1750
+ // A returned promise transfers a physical lease even when it rejects.
1751
+ // Retire that failed lease before allowing the same logical demand to
1752
+ // retry against the current owner.
1753
+ await this.releaseRemoteSubsetAcquisition(acquisition).catch(
1754
+ () => undefined,
1755
+ )
1756
+ }
1757
+ throw error
1758
+ }
1759
+ }
1760
+
1761
+ private async handleEnsurePersistedIndex(
1762
+ collectionId: string,
1763
+ request: {
1764
+ type: `rpc:ensurePersistedIndex:req`
1765
+ rpcId: string
1766
+ signature: string
1767
+ spec: PersistedIndexSpec
1768
+ },
1769
+ ): Promise<RPCResponse> {
1770
+ await this.withScheduledWriterLock(collectionId, (adapter) =>
1771
+ adapter.ensureIndex(collectionId, request.signature, request.spec),
1772
+ )
1773
+ return {
1774
+ type: `rpc:ensurePersistedIndex:res`,
1775
+ rpcId: request.rpcId,
1776
+ ok: true,
1777
+ }
1778
+ }
1779
+
1780
+ private async handleApplyLocalMutations(
1781
+ collectionId: string,
1782
+ request: {
1783
+ type: `rpc:applyLocalMutations:req`
1784
+ rpcId: string
1785
+ envelopeId: string
1786
+ mutations: Array<PersistedMutationEnvelope>
1787
+ },
1788
+ ): Promise<ApplyLocalMutationsResponse> {
1789
+ const envelopeKey = appliedEnvelopeKey(collectionId, request.envelopeId)
1790
+ return this.runDeduplicatedEnvelope({
1791
+ envelopeKey,
1792
+ rpcId: request.rpcId,
1793
+ selectApplied: (envelope) =>
1794
+ envelope.requestType === `rpc:applyLocalMutations:req`
1795
+ ? envelope.response
1796
+ : undefined,
1797
+ selectInFlight: (envelope) =>
1798
+ envelope.requestType === `rpc:applyLocalMutations:req`
1799
+ ? envelope.response
1800
+ : undefined,
1801
+ conflict: (phase) => ({
1802
+ type: `rpc:applyLocalMutations:res`,
1803
+ rpcId: request.rpcId,
1804
+ ok: false,
1805
+ code: `CONFLICT`,
1806
+ error: `envelope ${request.envelopeId} ${phase}`,
1807
+ }),
1808
+ apply: () => this.applyLocalMutationsOnce(collectionId, request),
1809
+ createPending: (response) => ({
1810
+ requestType: request.type,
1811
+ response,
1812
+ }),
1813
+ })
1814
+ }
1815
+
1816
+ private async applyLocalMutationsOnce(
1817
+ collectionId: string,
1818
+ request: Extract<RPCRequest, { type: `rpc:applyLocalMutations:req` }>,
1819
+ ): Promise<ApplyLocalMutationsResponse> {
1820
+ const state = this.collections.get(collectionId)
1821
+ if (!state || !state.isLeader) {
1822
+ return {
1823
+ type: `rpc:applyLocalMutations:res`,
1824
+ rpcId: request.rpcId,
1825
+ ok: false,
1826
+ code: `NOT_LEADER`,
1827
+ error: `not the leader for ${collectionId}`,
1828
+ }
1829
+ }
1830
+
1831
+ // Build and apply the persisted transaction
1832
+ const rowMetadataMutations: Array<PersistedRowMetadataMutation> = []
1833
+ for (const mutation of request.mutations) {
1834
+ if (!(`metadataChanged` in mutation) || !mutation.metadataChanged) {
1835
+ continue
1836
+ }
1837
+ rowMetadataMutations.push(
1838
+ mutation.metadata === undefined
1839
+ ? { type: `delete`, key: mutation.key }
1840
+ : {
1841
+ type: `set`,
1842
+ key: mutation.key,
1843
+ value: mutation.metadata,
1844
+ },
1845
+ )
1846
+ }
1847
+ const pendingTx = {
1848
+ txId: safeRandomUUID(),
1849
+ mutations: request.mutations.map((m) => ({
1850
+ type: m.type,
1851
+ key: m.key,
1852
+ value: m.value,
1853
+ ...(`metadataChanged` in m
1854
+ ? { metadata: m.metadata, metadataChanged: m.metadataChanged }
1855
+ : {}),
1856
+ })),
1857
+ rowMetadataMutations,
1858
+ }
1859
+
1860
+ const tx = await this.applyDurablyAtNextStreamPosition(
1861
+ collectionId,
1862
+ state,
1863
+ pendingTx,
1864
+ )
1865
+ if (tx === null) {
1866
+ return {
1867
+ type: `rpc:applyLocalMutations:res`,
1868
+ rpcId: request.rpcId,
1869
+ ok: false,
1870
+ code: `NOT_LEADER`,
1871
+ error: `not the leader for ${collectionId}`,
1872
+ }
1873
+ }
1874
+
1875
+ const response: ApplyLocalMutationsResponse = {
1876
+ type: `rpc:applyLocalMutations:res`,
1877
+ rpcId: request.rpcId,
1878
+ ok: true,
1879
+ term: tx.term,
1880
+ seq: tx.seq,
1881
+ latestRowVersion: tx.rowVersion,
1882
+ acceptedMutationIds: request.mutations.map((m) => m.mutationId),
1883
+ }
1884
+ if (this.isDisposed() || this.collections.get(collectionId) !== state) {
1885
+ return response
1886
+ }
1887
+ this.appliedEnvelopes.set(
1888
+ appliedEnvelopeKey(collectionId, request.envelopeId),
1889
+ {
1890
+ appliedAt: Date.now(),
1891
+ requestType: request.type,
1892
+ response,
1893
+ },
1894
+ )
1895
+ this.pruneAppliedEnvelopes()
1896
+
1897
+ // Broadcast tx:committed to all tabs
1898
+ const changedRows = request.mutations
1899
+ .filter((m) => m.type !== `delete`)
1900
+ .map((m) => ({ key: m.key, value: m.value }))
1901
+ const deletedKeys = request.mutations
1902
+ .filter((m) => m.type === `delete`)
1903
+ .map((m) => m.key)
1904
+
1905
+ const txCommitted: ProtocolEnvelope<unknown> = {
1906
+ v: 1,
1907
+ dbName: this.dbName,
1908
+ collectionId,
1909
+ senderId: this.nodeId,
1910
+ ts: Date.now(),
1911
+ payload: {
1912
+ type: `tx:committed`,
1913
+ term: tx.term,
1914
+ seq: tx.seq,
1915
+ txId: tx.txId,
1916
+ latestRowVersion: tx.rowVersion,
1917
+ requiresFullReload: false,
1918
+ changedRows,
1919
+ deletedKeys,
1920
+ rowMetadataMutations,
1921
+ },
1922
+ }
1923
+ this.channel.postMessage(txCommitted)
1924
+
1925
+ // Deliver to local subscribers too
1926
+ for (const subscriber of state.subscribers) {
1927
+ subscriber(txCommitted)
1928
+ }
1929
+
1930
+ return response
1931
+ }
1932
+
1933
+ private async handleApplyCommittedTx(
1934
+ collectionId: string,
1935
+ request: Extract<RPCRequest, { type: `rpc:applyCommittedTx:req` }>,
1936
+ scopedAdapter?: HydrationPersistenceAdapter,
1937
+ ): Promise<ApplyCommittedTxResponse> {
1938
+ const envelopeKey = appliedEnvelopeKey(collectionId, request.envelopeId)
1939
+ return this.runDeduplicatedEnvelope({
1940
+ envelopeKey,
1941
+ rpcId: request.rpcId,
1942
+ selectApplied: (envelope) =>
1943
+ envelope.requestType === `rpc:applyCommittedTx:req`
1944
+ ? envelope.response
1945
+ : undefined,
1946
+ selectInFlight: (envelope) =>
1947
+ envelope.requestType === `rpc:applyCommittedTx:req`
1948
+ ? envelope.response
1949
+ : undefined,
1950
+ conflict: (phase) => ({
1951
+ type: `rpc:applyCommittedTx:res`,
1952
+ rpcId: request.rpcId,
1953
+ ok: false,
1954
+ code: `CONFLICT`,
1955
+ error: `envelope ${request.envelopeId} ${phase}`,
1956
+ }),
1957
+ apply: () =>
1958
+ this.applyCommittedTxOnce(collectionId, request, scopedAdapter),
1959
+ createPending: (response) => ({
1960
+ requestType: request.type,
1961
+ response,
1962
+ }),
1963
+ })
1964
+ }
1965
+
1966
+ private async runDeduplicatedEnvelope<
1967
+ TResponse extends { rpcId: string },
1968
+ >(options: {
1969
+ envelopeKey: string
1970
+ rpcId: string
1971
+ selectApplied: (envelope: AppliedEnvelope) => TResponse | undefined
1972
+ selectInFlight: (
1973
+ envelope: InFlightEnvelope,
1974
+ ) => Promise<TResponse> | undefined
1975
+ conflict: (phase: `already applied` | `is already in flight`) => TResponse
1976
+ apply: () => Promise<TResponse>
1977
+ createPending: (response: Promise<TResponse>) => InFlightEnvelope
1978
+ }): Promise<TResponse> {
1979
+ const appliedEnvelope = this.appliedEnvelopes.get(options.envelopeKey)
1980
+ if (appliedEnvelope) {
1981
+ const response = options.selectApplied(appliedEnvelope)
1982
+ return response
1983
+ ? { ...response, rpcId: options.rpcId }
1984
+ : options.conflict(`already applied`)
1985
+ }
1986
+
1987
+ const inFlightEnvelope = this.inFlightEnvelopes.get(options.envelopeKey)
1988
+ if (inFlightEnvelope) {
1989
+ const pending = options.selectInFlight(inFlightEnvelope)
1990
+ if (!pending) return options.conflict(`is already in flight`)
1991
+ const response = await pending
1992
+ return { ...response, rpcId: options.rpcId }
1993
+ }
1994
+
1995
+ const response = options.apply()
1996
+ const pendingEnvelope = options.createPending(response)
1997
+ this.inFlightEnvelopes.set(options.envelopeKey, pendingEnvelope)
1998
+ try {
1999
+ return await response
2000
+ } finally {
2001
+ if (this.inFlightEnvelopes.get(options.envelopeKey) === pendingEnvelope) {
2002
+ this.inFlightEnvelopes.delete(options.envelopeKey)
2003
+ }
2004
+ }
2005
+ }
2006
+
2007
+ private async applyCommittedTxOnce(
2008
+ collectionId: string,
2009
+ request: Extract<RPCRequest, { type: `rpc:applyCommittedTx:req` }>,
2010
+ scopedAdapter?: HydrationPersistenceAdapter,
2011
+ ): Promise<ApplyCommittedTxResponse> {
2012
+ const state = this.collections.get(collectionId)
2013
+ if (!state || !state.isLeader) {
2014
+ return {
2015
+ type: `rpc:applyCommittedTx:res`,
2016
+ rpcId: request.rpcId,
2017
+ ok: false,
2018
+ code: `NOT_LEADER`,
2019
+ error: `not the leader for ${collectionId}`,
2020
+ }
2021
+ }
2022
+
2023
+ const tx = await this.applyDurablyAtNextStreamPosition(
2024
+ collectionId,
2025
+ state,
2026
+ request.tx,
2027
+ scopedAdapter,
2028
+ )
2029
+ if (tx === null) {
2030
+ return {
2031
+ type: `rpc:applyCommittedTx:res`,
2032
+ rpcId: request.rpcId,
2033
+ ok: false,
2034
+ code: `NOT_LEADER`,
2035
+ error: `not the leader for ${collectionId}`,
2036
+ }
2037
+ }
2038
+ const response: ApplyCommittedTxResponse = {
2039
+ type: `rpc:applyCommittedTx:res`,
2040
+ rpcId: request.rpcId,
2041
+ ok: true,
2042
+ term: tx.term,
2043
+ seq: tx.seq,
2044
+ latestRowVersion: tx.rowVersion,
2045
+ }
2046
+ if (this.isDisposed() || this.collections.get(collectionId) !== state) {
2047
+ return response
2048
+ }
2049
+ this.appliedEnvelopes.set(
2050
+ appliedEnvelopeKey(collectionId, request.envelopeId),
2051
+ {
2052
+ appliedAt: Date.now(),
2053
+ requestType: request.type,
2054
+ response,
2055
+ },
2056
+ )
2057
+ this.pruneAppliedEnvelopes()
2058
+
2059
+ const committedBase = {
2060
+ type: `tx:committed` as const,
2061
+ term: tx.term,
2062
+ seq: tx.seq,
2063
+ txId: tx.txId,
2064
+ latestRowVersion: tx.rowVersion,
2065
+ }
2066
+ const committedPayload: TxCommitted = tx.truncate
2067
+ ? {
2068
+ ...committedBase,
2069
+ requiresFullReload: true,
2070
+ }
2071
+ : {
2072
+ ...committedBase,
2073
+ requiresFullReload: false,
2074
+ changedRows: tx.mutations
2075
+ .filter((mutation) => mutation.type !== `delete`)
2076
+ .map((mutation) => ({
2077
+ key: mutation.key,
2078
+ value: mutation.value,
2079
+ })),
2080
+ deletedKeys: tx.mutations
2081
+ .filter((mutation) => mutation.type === `delete`)
2082
+ .map((mutation) => mutation.key),
2083
+ rowMetadataMutations: tx.rowMetadataMutations,
2084
+ collectionMetadataMutations: tx.collectionMetadataMutations,
2085
+ }
2086
+ const committed: ProtocolEnvelope<TxCommitted> = {
2087
+ v: 1,
2088
+ dbName: this.dbName,
2089
+ collectionId,
2090
+ senderId: this.nodeId,
2091
+ ts: Date.now(),
2092
+ payload: committedPayload,
2093
+ }
2094
+ this.channel.postMessage(committed)
2095
+ for (const subscriber of state.subscribers) {
2096
+ subscriber(committed)
2097
+ }
2098
+
2099
+ return response
2100
+ }
2101
+
2102
+ private async applyDurablyAtNextStreamPosition(
2103
+ collectionId: string,
2104
+ state: CollectionState,
2105
+ pendingTx: Omit<PersistedTx, `term` | `seq` | `rowVersion`>,
2106
+ scopedAdapter?: HydrationPersistenceAdapter,
2107
+ ): Promise<PersistedTx | null> {
2108
+ const expectedTerm = state.latestTerm
2109
+ return this.withScheduledWriterLock(
2110
+ collectionId,
2111
+ async (adapter) => {
2112
+ if (
2113
+ this.isDisposed() ||
2114
+ this.collections.get(collectionId) !== state ||
2115
+ !state.isLeader ||
2116
+ state.leaderId !== this.nodeId ||
2117
+ state.latestTerm !== expectedTerm
2118
+ ) {
2119
+ return null
2120
+ }
2121
+ const tx: PersistedTx = {
2122
+ ...pendingTx,
2123
+ term: state.latestTerm,
2124
+ seq: state.latestSeq + 1,
2125
+ rowVersion: state.latestRowVersion + 1,
2126
+ }
2127
+ try {
2128
+ await adapter.applyCommittedTx(collectionId, tx)
2129
+ } catch (error) {
2130
+ throw toPersistedCollectionDurabilityError(collectionId, error)
2131
+ }
2132
+ state.latestSeq = tx.seq
2133
+ state.latestRowVersion = tx.rowVersion
2134
+ return tx
2135
+ },
2136
+ scopedAdapter,
2137
+ )
2138
+ }
2139
+
2140
+ private async handlePullSince(
2141
+ collectionId: string,
2142
+ request: {
2143
+ type: `rpc:pullSince:req`
2144
+ rpcId: string
2145
+ fromRowVersion: number
2146
+ },
2147
+ scopedAdapter?: HydrationPersistenceAdapter,
2148
+ ): Promise<PullSinceResponse> {
2149
+ const state = this.collections.get(collectionId)
2150
+
2151
+ const adapter = scopedAdapter ?? this.requireAdapter(collectionId)
2152
+ if (!adapter.pullSince) {
2153
+ return {
2154
+ type: `rpc:pullSince:res`,
2155
+ rpcId: request.rpcId,
2156
+ ok: true,
2157
+ latestTerm: state?.latestTerm ?? 0,
2158
+ latestSeq: state?.latestSeq ?? 0,
2159
+ latestRowVersion: state?.latestRowVersion ?? 0,
2160
+ requiresFullReload: true,
2161
+ }
2162
+ }
2163
+
2164
+ const result = await adapter.pullSince(collectionId, request.fromRowVersion)
2165
+
2166
+ if (result.requiresFullReload) {
2167
+ return {
2168
+ type: `rpc:pullSince:res`,
2169
+ rpcId: request.rpcId,
2170
+ ok: true,
2171
+ latestTerm: state?.latestTerm ?? 0,
2172
+ latestSeq: state?.latestSeq ?? 0,
2173
+ latestRowVersion: result.latestRowVersion,
2174
+ requiresFullReload: true,
2175
+ }
2176
+ }
2177
+
2178
+ return {
2179
+ type: `rpc:pullSince:res`,
2180
+ rpcId: request.rpcId,
2181
+ ok: true,
2182
+ latestTerm: state?.latestTerm ?? 0,
2183
+ latestSeq: state?.latestSeq ?? 0,
2184
+ latestRowVersion: result.latestRowVersion,
2185
+ requiresFullReload: false,
2186
+ changedKeys: result.changedKeys,
2187
+ deletedKeys: result.deletedKeys,
2188
+ }
2189
+ }
2190
+
2191
+ // -----------------------------------------------------------------------
2192
+ // DB Writer Lock
2193
+ // -----------------------------------------------------------------------
2194
+
2195
+ private withScheduledWriterLock<T>(
2196
+ collectionId: string,
2197
+ task: (adapter: CoordinatorAdapter) => Promise<T>,
2198
+ scopedAdapter?: HydrationPersistenceAdapter,
2199
+ ): Promise<T> {
2200
+ if (scopedAdapter) {
2201
+ return this.withWriterLock(() => task(scopedAdapter))
2202
+ }
2203
+ // Enter the shared scheduler before taking the database-wide Web Lock.
2204
+ // A hydrate waiting for its first commit may need that lock to finish.
2205
+ const adapter = this.requireAdapter(collectionId)
2206
+ return adapter.runInRegularScope
2207
+ ? adapter.runInRegularScope((unscheduledAdapter) =>
2208
+ this.withWriterLock(() => task(unscheduledAdapter)),
2209
+ )
2210
+ : this.withWriterLock(() => task(adapter))
2211
+ }
2212
+
2213
+ private async withWriterLock<T>(fn: () => Promise<T>): Promise<T> {
2214
+ const lockName = `tsdb:writer:${this.dbName}`
2215
+
2216
+ for (let attempt = 0; attempt <= WRITER_LOCK_MAX_RETRIES; attempt++) {
2217
+ const callbackState = { entered: false }
2218
+ try {
2219
+ return await navigator.locks.request(lockName, async () => {
2220
+ callbackState.entered = true
2221
+ return fn()
2222
+ })
2223
+ } catch (error) {
2224
+ if (callbackState.entered) {
2225
+ throw error
2226
+ }
2227
+ if (error instanceof DOMException && error.name === `AbortError`) {
2228
+ throw error
2229
+ }
2230
+
2231
+ if (attempt < WRITER_LOCK_MAX_RETRIES) {
2232
+ await sleep(WRITER_LOCK_BUSY_RETRY_MS * Math.min(attempt + 1, 5))
2233
+ continue
2234
+ }
2235
+
2236
+ throw error
2237
+ }
2238
+ }
2239
+
2240
+ // Unreachable but satisfies TypeScript
2241
+ throw new Error(`writer lock acquisition failed`)
2242
+ }
2243
+
2244
+ // -----------------------------------------------------------------------
2245
+ // Helpers
2246
+ // -----------------------------------------------------------------------
2247
+
2248
+ private pruneAppliedEnvelopes(): void {
2249
+ if (this.appliedEnvelopePruneTimer !== null) {
2250
+ clearTimeout(this.appliedEnvelopePruneTimer)
2251
+ this.appliedEnvelopePruneTimer = null
2252
+ }
2253
+ const cutoff = Date.now() - RPC_DEDUPE_RETENTION_MS
2254
+ for (const [key, envelope] of this.appliedEnvelopes) {
2255
+ if (envelope.appliedAt <= cutoff) {
2256
+ this.appliedEnvelopes.delete(key)
2257
+ }
2258
+ }
2259
+ if (this.disposed || this.appliedEnvelopes.size === 0) return
2260
+
2261
+ let earliestAppliedAt = Number.POSITIVE_INFINITY
2262
+ for (const envelope of this.appliedEnvelopes.values()) {
2263
+ earliestAppliedAt = Math.min(earliestAppliedAt, envelope.appliedAt)
2264
+ }
2265
+ const delay = Math.max(
2266
+ 0,
2267
+ earliestAppliedAt + RPC_DEDUPE_RETENTION_MS - Date.now(),
2268
+ )
2269
+ this.appliedEnvelopePruneTimer = setTimeout(() => {
2270
+ this.appliedEnvelopePruneTimer = null
2271
+ this.pruneAppliedEnvelopes()
2272
+ }, delay)
2273
+ }
2274
+
2275
+ private setReleasedRemoteSubsetAcquisition(
2276
+ key: string,
2277
+ acquisition: RemoteSubsetAcquisition,
2278
+ ): void {
2279
+ this.inboundRemoteSubsetAcquisitions.set(key, acquisition)
2280
+ this.releasedRemoteSubsetAcquisitionTimes.set(key, Date.now())
2281
+ }
2282
+
2283
+ private pruneReleasedRemoteSubsetAcquisitions(): void {
2284
+ const cutoff = Date.now() - RPC_DEDUPE_RETENTION_MS
2285
+ for (const [key, releasedAt] of this.releasedRemoteSubsetAcquisitionTimes) {
2286
+ if (releasedAt < cutoff) {
2287
+ this.releasedRemoteSubsetAcquisitionTimes.delete(key)
2288
+ this.inboundRemoteSubsetAcquisitions.delete(key)
2289
+ }
2290
+ }
2291
+ }
2292
+ }
2293
+
2294
+ // ---------------------------------------------------------------------------
2295
+ // Utilities
2296
+ // ---------------------------------------------------------------------------
2297
+
2298
+ function isProtocolEnvelope(data: unknown): data is ProtocolEnvelope<unknown> {
2299
+ if (!data || typeof data !== `object`) return false
2300
+ const record = data as Record<string, unknown>
2301
+ return (
2302
+ record.v === 1 &&
2303
+ typeof record.dbName === `string` &&
2304
+ typeof record.collectionId === `string` &&
2305
+ typeof record.senderId === `string` &&
2306
+ typeof record.ts === `number`
2307
+ )
2308
+ }
2309
+
2310
+ function isRPCRequest(payload: unknown): payload is RPCRequest {
2311
+ if (!payload || typeof payload !== `object`) return false
2312
+ switch ((payload as { type?: unknown }).type) {
2313
+ case `rpc:ensureRemoteSubset:req`:
2314
+ case `rpc:releaseRemoteSubset:req`:
2315
+ case `rpc:ensurePersistedIndex:req`:
2316
+ case `rpc:applyLocalMutations:req`:
2317
+ case `rpc:applyCommittedTx:req`:
2318
+ case `rpc:pullSince:req`:
2319
+ return true
2320
+ default:
2321
+ return false
2322
+ }
2323
+ }
2324
+
2325
+ function sleep(ms: number): Promise<void> {
2326
+ return new Promise((resolve) => setTimeout(resolve, ms))
2327
+ }
2328
+
2329
+ function appliedEnvelopeKey(collectionId: string, envelopeId: string): string {
2330
+ return JSON.stringify([collectionId, envelopeId])
2331
+ }
2332
+
2333
+ function remoteSubsetAcquisitionKey(
2334
+ collectionId: string,
2335
+ acquisitionId: string,
2336
+ ): string {
2337
+ return JSON.stringify([collectionId, acquisitionId])
2338
+ }
2339
+
2340
+ function inboundRemoteSubsetAcquisitionKey(
2341
+ collectionId: string,
2342
+ requesterId: string,
2343
+ acquisitionId: string,
2344
+ ): string {
2345
+ return JSON.stringify([collectionId, requesterId, acquisitionId])
2346
+ }
2347
+
2348
+ function createRPCErrorResponse(
2349
+ request: RPCRequest,
2350
+ cause: unknown,
2351
+ ): RPCResponse {
2352
+ const error = cause instanceof Error ? cause.message : String(cause)
2353
+ switch (request.type) {
2354
+ case `rpc:ensureRemoteSubset:req`:
2355
+ return {
2356
+ type: `rpc:ensureRemoteSubset:res`,
2357
+ rpcId: request.rpcId,
2358
+ ok: false,
2359
+ error,
2360
+ ...(cause instanceof RetryableRemoteSubsetAcquisitionError
2361
+ ? { retryable: true as const }
2362
+ : {}),
2363
+ }
2364
+ case `rpc:releaseRemoteSubset:req`:
2365
+ return {
2366
+ type: `rpc:releaseRemoteSubset:res`,
2367
+ rpcId: request.rpcId,
2368
+ ok: false,
2369
+ error,
2370
+ }
2371
+ case `rpc:ensurePersistedIndex:req`:
2372
+ return {
2373
+ type: `rpc:ensurePersistedIndex:res`,
2374
+ rpcId: request.rpcId,
2375
+ ok: false,
2376
+ error,
2377
+ }
2378
+ case `rpc:applyLocalMutations:req`:
2379
+ if (cause instanceof PersistedCollectionDurabilityError) {
2380
+ return {
2381
+ type: `rpc:applyLocalMutations:res`,
2382
+ rpcId: request.rpcId,
2383
+ ok: false,
2384
+ code: `PERSISTENCE_ERROR`,
2385
+ error,
2386
+ ...toSafeDurabilityDetails(cause),
2387
+ }
2388
+ }
2389
+ return {
2390
+ type: `rpc:applyLocalMutations:res`,
2391
+ rpcId: request.rpcId,
2392
+ ok: false,
2393
+ code: `CONFLICT`,
2394
+ error,
2395
+ }
2396
+ case `rpc:applyCommittedTx:req`:
2397
+ if (cause instanceof PersistedCollectionDurabilityError) {
2398
+ return {
2399
+ type: `rpc:applyCommittedTx:res`,
2400
+ rpcId: request.rpcId,
2401
+ ok: false,
2402
+ code: `PERSISTENCE_ERROR`,
2403
+ error,
2404
+ ...toSafeDurabilityDetails(cause),
2405
+ }
2406
+ }
2407
+ return {
2408
+ type: `rpc:applyCommittedTx:res`,
2409
+ rpcId: request.rpcId,
2410
+ ok: false,
2411
+ code: `CONFLICT`,
2412
+ error,
2413
+ }
2414
+ case `rpc:pullSince:req`:
2415
+ return {
2416
+ type: `rpc:pullSince:res`,
2417
+ rpcId: request.rpcId,
2418
+ ok: false,
2419
+ error,
2420
+ }
2421
+ }
2422
+ }
2423
+
2424
+ function isMutatingRPCRequest(request: RPCRequest): request is Extract<
2425
+ RPCRequest,
2426
+ {
2427
+ type: IndeterminateCommitRequestType
2428
+ }
2429
+ > {
2430
+ return (
2431
+ request.type === `rpc:applyLocalMutations:req` ||
2432
+ request.type === `rpc:applyCommittedTx:req`
2433
+ )
2434
+ }
2435
+
2436
+ function toSafeDurabilityDetails(error: PersistedCollectionDurabilityError): {
2437
+ sourceCode?: string | number
2438
+ path?: string | ReadonlyArray<string | number>
2439
+ } {
2440
+ const sourceCode =
2441
+ typeof error.code === `string` || typeof error.code === `number`
2442
+ ? error.code
2443
+ : undefined
2444
+ const path =
2445
+ typeof error.path === `string` ||
2446
+ (Array.isArray(error.path) &&
2447
+ error.path.every(
2448
+ (part) => typeof part === `string` || typeof part === `number`,
2449
+ ))
2450
+ ? (error.path as string | ReadonlyArray<string | number>)
2451
+ : undefined
2452
+ return {
2453
+ ...(sourceCode === undefined ? {} : { sourceCode }),
2454
+ ...(path === undefined ? {} : { path }),
2455
+ }
2456
+ }