@tanstack/electron-db-sqlite-persistence 0.1.33 → 0.2.1

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.
@@ -1,840 +1,16 @@
1
- import { safeRandomUUID } from '@tanstack/db-sqlite-persistence-core'
2
- import type {
3
- ApplyLocalMutationsResponse,
4
- PersistedCollectionCoordinator,
5
- PersistedIndexSpec,
6
- PersistedMutationEnvelope,
7
- PersistenceAdapter,
8
- ProtocolEnvelope,
9
- PullSinceResponse,
10
- } from '@tanstack/db-sqlite-persistence-core'
11
- import type { LoadSubsetOptions } from '@tanstack/db'
1
+ import { BroadcastCollectionCoordinator } from '@tanstack/db-sqlite-persistence-core/broadcast-coordinator'
2
+ import type { BroadcastCollectionCoordinatorOptions } from '@tanstack/db-sqlite-persistence-core/broadcast-coordinator'
12
3
 
13
- // ---------------------------------------------------------------------------
14
- // Constants
15
- // ---------------------------------------------------------------------------
16
-
17
- const HEARTBEAT_INTERVAL_MS = 3_000
18
- const RPC_TIMEOUT_MS = 10_000
19
- const RPC_RETRY_ATTEMPTS = 2
20
- const RPC_RETRY_DELAY_MS = 200
21
- const WRITER_LOCK_BUSY_RETRY_MS = 50
22
- const WRITER_LOCK_MAX_RETRIES = 20
23
-
24
- // ---------------------------------------------------------------------------
25
- // Internal types
26
- // ---------------------------------------------------------------------------
27
-
28
- type RPCRequest =
29
- | {
30
- type: `rpc:ensureRemoteSubset:req`
31
- rpcId: string
32
- options: LoadSubsetOptions
33
- }
34
- | {
35
- type: `rpc:ensurePersistedIndex:req`
36
- rpcId: string
37
- signature: string
38
- spec: PersistedIndexSpec
39
- }
40
- | {
41
- type: `rpc:applyLocalMutations:req`
42
- rpcId: string
43
- envelopeId: string
44
- mutations: Array<PersistedMutationEnvelope>
45
- }
46
- | {
47
- type: `rpc:pullSince:req`
48
- rpcId: string
49
- fromRowVersion: number
50
- }
51
-
52
- type RPCResponse =
53
- | {
54
- type: `rpc:ensureRemoteSubset:res`
55
- rpcId: string
56
- ok: boolean
57
- error?: string
58
- }
59
- | {
60
- type: `rpc:ensurePersistedIndex:res`
61
- rpcId: string
62
- ok: boolean
63
- error?: string
64
- }
65
- | ApplyLocalMutationsResponse
66
- | PullSinceResponse
67
-
68
- type PendingRPC = {
69
- resolve: (response: RPCResponse) => void
70
- reject: (error: Error) => void
71
- timer: ReturnType<typeof setTimeout>
72
- }
73
-
74
- type CollectionState = {
75
- isLeader: boolean
76
- lockAbortController: AbortController | null
77
- heartbeatTimer: ReturnType<typeof setInterval> | null
78
- latestTerm: number
79
- latestSeq: number
80
- latestRowVersion: number
81
- subscribers: Set<(message: ProtocolEnvelope<unknown>) => void>
82
- }
83
-
84
- // Adapter with pullSince support
85
- type AdapterWithPullSince = PersistenceAdapter & {
86
- pullSince?: (
87
- collectionId: string,
88
- fromRowVersion: number,
89
- ) => Promise<
90
- | {
91
- latestRowVersion: number
92
- requiresFullReload: true
93
- }
94
- | {
95
- latestRowVersion: number
96
- requiresFullReload: false
97
- changedKeys: Array<string | number>
98
- deletedKeys: Array<string | number>
99
- }
100
- >
101
- getStreamPosition?: (collectionId: string) => Promise<{
102
- latestTerm: number
103
- latestSeq: number
104
- latestRowVersion: number
105
- }>
106
- }
107
-
108
- // ---------------------------------------------------------------------------
109
- // Options
110
- // ---------------------------------------------------------------------------
111
-
112
- export type ElectronCollectionCoordinatorOptions = {
113
- dbName: string
114
- adapter?: AdapterWithPullSince
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // ElectronCollectionCoordinator
119
- // ---------------------------------------------------------------------------
120
-
121
- export class ElectronCollectionCoordinator implements PersistedCollectionCoordinator {
122
- private readonly nodeId = safeRandomUUID()
123
- private readonly dbName: string
124
- private adapter: AdapterWithPullSince | null
125
- private readonly channel: BroadcastChannel
126
- private readonly collections = new Map<string, CollectionState>()
127
- private readonly pendingRPCs = new Map<string, PendingRPC>()
128
- private readonly appliedEnvelopeIds = new Map<string, number>()
129
- private disposed = false
130
-
131
- /** Method indirection to prevent TypeScript from narrowing `disposed` across awaits */
132
- private isDisposed(): boolean {
133
- return this.disposed
134
- }
135
-
136
- private requireAdapter(): AdapterWithPullSince {
137
- if (!this.adapter) {
138
- throw new Error(
139
- `ElectronCollectionCoordinator: adapter not set. Call setAdapter() before using leader-side operations.`,
140
- )
141
- }
142
- return this.adapter
143
- }
4
+ export type ElectronCollectionCoordinatorOptions = Omit<
5
+ BroadcastCollectionCoordinatorOptions,
6
+ `coordinatorName`
7
+ >
144
8
 
9
+ export class ElectronCollectionCoordinator extends BroadcastCollectionCoordinator {
145
10
  constructor(options: ElectronCollectionCoordinatorOptions) {
146
- this.dbName = options.dbName
147
- this.adapter = options.adapter ?? null
148
- this.channel = new BroadcastChannel(`tsdb:coord:${this.dbName}`)
149
- this.channel.onmessage = (event: MessageEvent) => {
150
- this.onChannelMessage(event.data)
151
- }
152
- }
153
-
154
- /**
155
- * Set or replace the persistence adapter used for leader-side RPC handling.
156
- * Called by `createElectronSQLitePersistence` to wire the internally-created
157
- * adapter into the coordinator.
158
- */
159
- setAdapter(adapter: AdapterWithPullSince): void {
160
- this.adapter = adapter
161
- }
162
-
163
- // -----------------------------------------------------------------------
164
- // PersistedCollectionCoordinator interface
165
- // -----------------------------------------------------------------------
166
-
167
- getNodeId(): string {
168
- return this.nodeId
169
- }
170
-
171
- subscribe(
172
- collectionId: string,
173
- onMessage: (message: ProtocolEnvelope<unknown>) => void,
174
- ): () => void {
175
- const state = this.ensureCollectionState(collectionId)
176
- state.subscribers.add(onMessage)
177
- return () => {
178
- state.subscribers.delete(onMessage)
179
- }
180
- }
181
-
182
- publish(_collectionId: string, message: ProtocolEnvelope<unknown>): void {
183
- this.channel.postMessage(message)
184
- }
185
-
186
- isLeader(collectionId: string): boolean {
187
- return this.collections.get(collectionId)?.isLeader ?? false
188
- }
189
-
190
- async ensureLeadership(collectionId: string): Promise<void> {
191
- const state = this.ensureCollectionState(collectionId)
192
- if (state.isLeader) return
193
- await this.acquireLeadership(collectionId, state)
194
- }
195
-
196
- async requestEnsureRemoteSubset(
197
- collectionId: string,
198
- options: LoadSubsetOptions,
199
- ): Promise<void> {
200
- if (this.isLeader(collectionId)) return
201
-
202
- const response = await this.sendRPC<{
203
- type: `rpc:ensureRemoteSubset:res`
204
- rpcId: string
205
- ok: boolean
206
- error?: string
207
- }>(collectionId, {
208
- type: `rpc:ensureRemoteSubset:req`,
209
- rpcId: safeRandomUUID(),
210
- options,
11
+ super({
12
+ ...options,
13
+ coordinatorName: `ElectronCollectionCoordinator`,
211
14
  })
212
-
213
- if (!response.ok) {
214
- throw new Error(
215
- `ensureRemoteSubset failed: ${response.error ?? `unknown error`}`,
216
- )
217
- }
218
- }
219
-
220
- async requestEnsurePersistedIndex(
221
- collectionId: string,
222
- signature: string,
223
- spec: PersistedIndexSpec,
224
- ): Promise<void> {
225
- if (this.isLeader(collectionId)) {
226
- await this.requireAdapter().ensureIndex(collectionId, signature, spec)
227
- return
228
- }
229
-
230
- const response = await this.sendRPC<{
231
- type: `rpc:ensurePersistedIndex:res`
232
- rpcId: string
233
- ok: boolean
234
- error?: string
235
- }>(collectionId, {
236
- type: `rpc:ensurePersistedIndex:req`,
237
- rpcId: safeRandomUUID(),
238
- signature,
239
- spec,
240
- })
241
-
242
- if (!response.ok) {
243
- throw new Error(
244
- `ensurePersistedIndex failed: ${response.error ?? `unknown error`}`,
245
- )
246
- }
247
- }
248
-
249
- async requestApplyLocalMutations(
250
- collectionId: string,
251
- mutations: Array<PersistedMutationEnvelope>,
252
- ): Promise<ApplyLocalMutationsResponse> {
253
- if (this.isLeader(collectionId)) {
254
- return this.handleApplyLocalMutations(collectionId, {
255
- type: `rpc:applyLocalMutations:req`,
256
- rpcId: safeRandomUUID(),
257
- envelopeId: safeRandomUUID(),
258
- mutations,
259
- })
260
- }
261
-
262
- return this.sendRPC<ApplyLocalMutationsResponse>(collectionId, {
263
- type: `rpc:applyLocalMutations:req`,
264
- rpcId: safeRandomUUID(),
265
- envelopeId: safeRandomUUID(),
266
- mutations,
267
- })
268
- }
269
-
270
- async pullSince(
271
- collectionId: string,
272
- fromRowVersion: number,
273
- ): Promise<PullSinceResponse> {
274
- if (this.isLeader(collectionId)) {
275
- return this.handlePullSince(collectionId, {
276
- type: `rpc:pullSince:req`,
277
- rpcId: safeRandomUUID(),
278
- fromRowVersion,
279
- })
280
- }
281
-
282
- return this.sendRPC<PullSinceResponse>(collectionId, {
283
- type: `rpc:pullSince:req`,
284
- rpcId: safeRandomUUID(),
285
- fromRowVersion,
286
- })
287
- }
288
-
289
- // -----------------------------------------------------------------------
290
- // Lifecycle
291
- // -----------------------------------------------------------------------
292
-
293
- dispose(): void {
294
- this.disposed = true
295
-
296
- for (const [collectionId, state] of this.collections) {
297
- this.releaseLeadership(collectionId, state)
298
- }
299
-
300
- for (const [, pending] of this.pendingRPCs) {
301
- clearTimeout(pending.timer)
302
- pending.reject(new Error(`coordinator disposed`))
303
- }
304
- this.pendingRPCs.clear()
305
-
306
- this.channel.close()
307
- this.collections.clear()
308
15
  }
309
-
310
- // -----------------------------------------------------------------------
311
- // Leadership via Web Locks
312
- // -----------------------------------------------------------------------
313
-
314
- private ensureCollectionState(collectionId: string): CollectionState {
315
- let state = this.collections.get(collectionId)
316
- if (!state) {
317
- state = {
318
- isLeader: false,
319
- lockAbortController: null,
320
- heartbeatTimer: null,
321
- latestTerm: 0,
322
- latestSeq: 0,
323
- latestRowVersion: 0,
324
- subscribers: new Set(),
325
- }
326
- this.collections.set(collectionId, state)
327
- void this.acquireLeadership(collectionId, state)
328
- }
329
- return state
330
- }
331
-
332
- private async acquireLeadership(
333
- collectionId: string,
334
- state: CollectionState,
335
- ): Promise<void> {
336
- if (this.disposed || state.isLeader) return
337
-
338
- const lockName = `tsdb:leader:${this.dbName}:${collectionId}`
339
- const abortController = new AbortController()
340
- state.lockAbortController = abortController
341
-
342
- try {
343
- await navigator.locks.request(
344
- lockName,
345
- { signal: abortController.signal },
346
- async () => {
347
- if (this.isDisposed()) return
348
-
349
- try {
350
- // Restore stream position from DB before claiming leadership
351
- const adapter = this.requireAdapter()
352
- if (adapter.getStreamPosition) {
353
- const pos = await adapter.getStreamPosition(collectionId)
354
- state.latestTerm = pos.latestTerm
355
- state.latestSeq = pos.latestSeq
356
- state.latestRowVersion = pos.latestRowVersion
357
- }
358
-
359
- state.latestTerm++
360
- state.isLeader = true
361
-
362
- this.emitHeartbeat(collectionId, state)
363
- state.heartbeatTimer = setInterval(() => {
364
- this.emitHeartbeat(collectionId, state)
365
- }, HEARTBEAT_INTERVAL_MS)
366
-
367
- // Hold the lock until disposed or aborted
368
- await new Promise<void>((resolve) => {
369
- const onAbort = () => {
370
- abortController.signal.removeEventListener(`abort`, onAbort)
371
- resolve()
372
- }
373
- if (abortController.signal.aborted) {
374
- resolve()
375
- return
376
- }
377
- abortController.signal.addEventListener(`abort`, onAbort)
378
- })
379
- } finally {
380
- state.isLeader = false
381
- if (state.heartbeatTimer) {
382
- clearInterval(state.heartbeatTimer)
383
- state.heartbeatTimer = null
384
- }
385
- }
386
- },
387
- )
388
- } catch (error) {
389
- if (error instanceof DOMException && error.name === `AbortError`) {
390
- return
391
- }
392
- console.warn(`Failed to acquire leadership for ${collectionId}:`, error)
393
- }
394
-
395
- // Re-acquire if not disposed (leadership was released by another means)
396
- if (!this.isDisposed()) {
397
- void this.acquireLeadership(collectionId, state)
398
- }
399
- }
400
-
401
- private releaseLeadership(
402
- _collectionId: string,
403
- state: CollectionState,
404
- ): void {
405
- if (state.lockAbortController) {
406
- state.lockAbortController.abort()
407
- state.lockAbortController = null
408
- }
409
- if (state.heartbeatTimer) {
410
- clearInterval(state.heartbeatTimer)
411
- state.heartbeatTimer = null
412
- }
413
- state.isLeader = false
414
- }
415
-
416
- private emitHeartbeat(collectionId: string, state: CollectionState): void {
417
- const envelope: ProtocolEnvelope<unknown> = {
418
- v: 1,
419
- dbName: this.dbName,
420
- collectionId,
421
- senderId: this.nodeId,
422
- ts: Date.now(),
423
- payload: {
424
- type: `leader:heartbeat`,
425
- term: state.latestTerm,
426
- leaderId: this.nodeId,
427
- latestSeq: state.latestSeq,
428
- latestRowVersion: state.latestRowVersion,
429
- },
430
- }
431
- this.channel.postMessage(envelope)
432
- }
433
-
434
- // -----------------------------------------------------------------------
435
- // BroadcastChannel message handling
436
- // -----------------------------------------------------------------------
437
-
438
- private onChannelMessage(data: unknown): void {
439
- if (!isProtocolEnvelope(data)) return
440
-
441
- const envelope = data
442
-
443
- // Ignore own messages
444
- if (envelope.senderId === this.nodeId) return
445
-
446
- const payload = envelope.payload
447
- if (!payload || typeof payload !== `object`) return
448
-
449
- const type = (payload as Record<string, unknown>).type as string | undefined
450
-
451
- // Handle RPC responses (for pending outbound RPCs)
452
- if (type && type.endsWith(`:res`)) {
453
- const rpcId = (payload as { rpcId?: string }).rpcId
454
- if (rpcId && this.pendingRPCs.has(rpcId)) {
455
- const pending = this.pendingRPCs.get(rpcId)!
456
- this.pendingRPCs.delete(rpcId)
457
- clearTimeout(pending.timer)
458
- pending.resolve(payload as RPCResponse)
459
- return
460
- }
461
- }
462
-
463
- // Handle RPC requests (leader only)
464
- if (type && type.endsWith(`:req`)) {
465
- const collectionId = envelope.collectionId
466
- if (this.isLeader(collectionId)) {
467
- void this.handleRPCRequest(collectionId, payload as RPCRequest)
468
- }
469
- return
470
- }
471
-
472
- // Forward protocol messages to subscribers
473
- const state = this.collections.get(envelope.collectionId)
474
- if (state) {
475
- for (const subscriber of state.subscribers) {
476
- subscriber(envelope)
477
- }
478
- }
479
- }
480
-
481
- // -----------------------------------------------------------------------
482
- // RPC - Outbound (follower side)
483
- // -----------------------------------------------------------------------
484
-
485
- private async sendRPC<T extends RPCResponse>(
486
- collectionId: string,
487
- request: RPCRequest,
488
- ): Promise<T> {
489
- let lastError: Error | undefined
490
-
491
- for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
492
- if (attempt > 0) {
493
- await sleep(RPC_RETRY_DELAY_MS * attempt)
494
- }
495
-
496
- try {
497
- return await this.sendRPCOnce<T>(collectionId, request)
498
- } catch (error) {
499
- lastError = error instanceof Error ? error : new Error(String(error))
500
- }
501
- }
502
-
503
- throw lastError ?? new Error(`RPC failed after retries`)
504
- }
505
-
506
- private sendRPCOnce<T extends RPCResponse>(
507
- collectionId: string,
508
- request: RPCRequest,
509
- ): Promise<T> {
510
- return new Promise<T>((resolve, reject) => {
511
- const rpcId = request.rpcId
512
-
513
- const timer = setTimeout(() => {
514
- this.pendingRPCs.delete(rpcId)
515
- reject(
516
- new Error(`RPC ${request.type} timed out after ${RPC_TIMEOUT_MS}ms`),
517
- )
518
- }, RPC_TIMEOUT_MS)
519
-
520
- this.pendingRPCs.set(rpcId, {
521
- resolve: resolve as (response: RPCResponse) => void,
522
- reject,
523
- timer,
524
- })
525
-
526
- const envelope: ProtocolEnvelope<unknown> = {
527
- v: 1,
528
- dbName: this.dbName,
529
- collectionId,
530
- senderId: this.nodeId,
531
- ts: Date.now(),
532
- payload: request,
533
- }
534
- this.channel.postMessage(envelope)
535
- })
536
- }
537
-
538
- // -----------------------------------------------------------------------
539
- // RPC - Inbound (leader side)
540
- // -----------------------------------------------------------------------
541
-
542
- private async handleRPCRequest(
543
- collectionId: string,
544
- request: RPCRequest,
545
- ): Promise<void> {
546
- let response: RPCResponse
547
-
548
- try {
549
- switch (request.type) {
550
- case `rpc:ensureRemoteSubset:req`:
551
- response = await this.handleEnsureRemoteSubset(collectionId, request)
552
- break
553
- case `rpc:ensurePersistedIndex:req`:
554
- response = await this.handleEnsurePersistedIndex(
555
- collectionId,
556
- request,
557
- )
558
- break
559
- case `rpc:applyLocalMutations:req`:
560
- response = await this.handleApplyLocalMutations(collectionId, request)
561
- break
562
- case `rpc:pullSince:req`:
563
- response = await this.handlePullSince(collectionId, request)
564
- break
565
- default:
566
- return
567
- }
568
- } catch (error) {
569
- const errorMessage =
570
- error instanceof Error ? error.message : String(error)
571
- response = {
572
- type: request.type.replace(`:req`, `:res`) as RPCResponse[`type`],
573
- rpcId: request.rpcId,
574
- ok: false,
575
- error: errorMessage,
576
- } as RPCResponse
577
- }
578
-
579
- const envelope: ProtocolEnvelope<unknown> = {
580
- v: 1,
581
- dbName: this.dbName,
582
- collectionId,
583
- senderId: this.nodeId,
584
- ts: Date.now(),
585
- payload: response,
586
- }
587
- this.channel.postMessage(envelope)
588
- }
589
-
590
- private handleEnsureRemoteSubset(
591
- _collectionId: string,
592
- request: { type: `rpc:ensureRemoteSubset:req`; rpcId: string },
593
- ): RPCResponse {
594
- // Leader doesn't need to do anything special — the remote subset
595
- // is ensured by the leader's own sync connection
596
- return {
597
- type: `rpc:ensureRemoteSubset:res`,
598
- rpcId: request.rpcId,
599
- ok: true,
600
- }
601
- }
602
-
603
- private async handleEnsurePersistedIndex(
604
- collectionId: string,
605
- request: {
606
- type: `rpc:ensurePersistedIndex:req`
607
- rpcId: string
608
- signature: string
609
- spec: PersistedIndexSpec
610
- },
611
- ): Promise<RPCResponse> {
612
- await this.withWriterLock(() =>
613
- this.requireAdapter().ensureIndex(
614
- collectionId,
615
- request.signature,
616
- request.spec,
617
- ),
618
- )
619
- return {
620
- type: `rpc:ensurePersistedIndex:res`,
621
- rpcId: request.rpcId,
622
- ok: true,
623
- }
624
- }
625
-
626
- private async handleApplyLocalMutations(
627
- collectionId: string,
628
- request: {
629
- type: `rpc:applyLocalMutations:req`
630
- rpcId: string
631
- envelopeId: string
632
- mutations: Array<PersistedMutationEnvelope>
633
- },
634
- ): Promise<ApplyLocalMutationsResponse> {
635
- // Dedupe by envelopeId
636
- if (this.appliedEnvelopeIds.has(request.envelopeId)) {
637
- return {
638
- type: `rpc:applyLocalMutations:res`,
639
- rpcId: request.rpcId,
640
- ok: false,
641
- code: `CONFLICT`,
642
- error: `envelope ${request.envelopeId} already applied`,
643
- }
644
- }
645
-
646
- const state = this.collections.get(collectionId)
647
- if (!state || !state.isLeader) {
648
- return {
649
- type: `rpc:applyLocalMutations:res`,
650
- rpcId: request.rpcId,
651
- ok: false,
652
- code: `NOT_LEADER`,
653
- error: `not the leader for ${collectionId}`,
654
- }
655
- }
656
-
657
- // Assign stream position
658
- state.latestSeq++
659
- state.latestRowVersion++
660
-
661
- const term = state.latestTerm
662
- const seq = state.latestSeq
663
- const rowVersion = state.latestRowVersion
664
-
665
- // Build and apply the persisted transaction
666
- const tx = {
667
- txId: safeRandomUUID(),
668
- term,
669
- seq,
670
- rowVersion,
671
- mutations: request.mutations.map((m) => ({
672
- type: m.type,
673
- key: m.key,
674
- value: m.value,
675
- })),
676
- }
677
-
678
- await this.withWriterLock(() =>
679
- this.requireAdapter().applyCommittedTx(collectionId, tx),
680
- )
681
-
682
- // Track envelope for dedup
683
- this.appliedEnvelopeIds.set(request.envelopeId, Date.now())
684
- this.pruneAppliedEnvelopeIds()
685
-
686
- // Broadcast tx:committed to all tabs
687
- const changedRows = request.mutations
688
- .filter((m) => m.type !== `delete`)
689
- .map((m) => ({ key: m.key, value: m.value }))
690
- const deletedKeys = request.mutations
691
- .filter((m) => m.type === `delete`)
692
- .map((m) => m.key)
693
-
694
- const txCommitted: ProtocolEnvelope<unknown> = {
695
- v: 1,
696
- dbName: this.dbName,
697
- collectionId,
698
- senderId: this.nodeId,
699
- ts: Date.now(),
700
- payload: {
701
- type: `tx:committed`,
702
- term,
703
- seq,
704
- txId: tx.txId,
705
- latestRowVersion: rowVersion,
706
- requiresFullReload: false,
707
- changedRows,
708
- deletedKeys,
709
- },
710
- }
711
- this.channel.postMessage(txCommitted)
712
-
713
- // Deliver to local subscribers too
714
- for (const subscriber of state.subscribers) {
715
- subscriber(txCommitted)
716
- }
717
-
718
- return {
719
- type: `rpc:applyLocalMutations:res`,
720
- rpcId: request.rpcId,
721
- ok: true,
722
- term,
723
- seq,
724
- latestRowVersion: rowVersion,
725
- acceptedMutationIds: request.mutations.map((m) => m.mutationId),
726
- }
727
- }
728
-
729
- private async handlePullSince(
730
- collectionId: string,
731
- request: {
732
- type: `rpc:pullSince:req`
733
- rpcId: string
734
- fromRowVersion: number
735
- },
736
- ): Promise<PullSinceResponse> {
737
- const state = this.collections.get(collectionId)
738
-
739
- const adapter = this.requireAdapter()
740
- if (!adapter.pullSince) {
741
- return {
742
- type: `rpc:pullSince:res`,
743
- rpcId: request.rpcId,
744
- ok: true,
745
- latestTerm: state?.latestTerm ?? 0,
746
- latestSeq: state?.latestSeq ?? 0,
747
- latestRowVersion: state?.latestRowVersion ?? 0,
748
- requiresFullReload: true,
749
- }
750
- }
751
-
752
- const result = await adapter.pullSince(collectionId, request.fromRowVersion)
753
-
754
- if (result.requiresFullReload) {
755
- return {
756
- type: `rpc:pullSince:res`,
757
- rpcId: request.rpcId,
758
- ok: true,
759
- latestTerm: state?.latestTerm ?? 0,
760
- latestSeq: state?.latestSeq ?? 0,
761
- latestRowVersion: result.latestRowVersion,
762
- requiresFullReload: true,
763
- }
764
- }
765
-
766
- return {
767
- type: `rpc:pullSince:res`,
768
- rpcId: request.rpcId,
769
- ok: true,
770
- latestTerm: state?.latestTerm ?? 0,
771
- latestSeq: state?.latestSeq ?? 0,
772
- latestRowVersion: result.latestRowVersion,
773
- requiresFullReload: false,
774
- changedKeys: result.changedKeys,
775
- deletedKeys: result.deletedKeys,
776
- }
777
- }
778
-
779
- // -----------------------------------------------------------------------
780
- // DB Writer Lock
781
- // -----------------------------------------------------------------------
782
-
783
- private async withWriterLock<T>(fn: () => Promise<T>): Promise<T> {
784
- const lockName = `tsdb:writer:${this.dbName}`
785
-
786
- for (let attempt = 0; attempt <= WRITER_LOCK_MAX_RETRIES; attempt++) {
787
- try {
788
- return await navigator.locks.request(lockName, async () => fn())
789
- } catch (error) {
790
- if (error instanceof DOMException && error.name === `AbortError`) {
791
- throw error
792
- }
793
-
794
- if (attempt < WRITER_LOCK_MAX_RETRIES) {
795
- await sleep(WRITER_LOCK_BUSY_RETRY_MS * Math.min(attempt + 1, 5))
796
- continue
797
- }
798
-
799
- throw error
800
- }
801
- }
802
-
803
- // Unreachable but satisfies TypeScript
804
- throw new Error(`writer lock acquisition failed`)
805
- }
806
-
807
- // -----------------------------------------------------------------------
808
- // Helpers
809
- // -----------------------------------------------------------------------
810
-
811
- private pruneAppliedEnvelopeIds(): void {
812
- // Keep envelopes for 60 seconds for dedup
813
- const cutoff = Date.now() - 60_000
814
- for (const [id, ts] of this.appliedEnvelopeIds) {
815
- if (ts < cutoff) {
816
- this.appliedEnvelopeIds.delete(id)
817
- }
818
- }
819
- }
820
- }
821
-
822
- // ---------------------------------------------------------------------------
823
- // Utilities
824
- // ---------------------------------------------------------------------------
825
-
826
- function isProtocolEnvelope(data: unknown): data is ProtocolEnvelope<unknown> {
827
- if (!data || typeof data !== `object`) return false
828
- const record = data as Record<string, unknown>
829
- return (
830
- record.v === 1 &&
831
- typeof record.dbName === `string` &&
832
- typeof record.collectionId === `string` &&
833
- typeof record.senderId === `string` &&
834
- typeof record.ts === `number`
835
- )
836
- }
837
-
838
- function sleep(ms: number): Promise<void> {
839
- return new Promise((resolve) => setTimeout(resolve, ms))
840
16
  }