@tanstack/electron-db-sqlite-persistence 0.1.2

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