@tanstack/db-sqlite-persistence-core 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.
@@ -0,0 +1,2820 @@
1
+ import { compileSingleRowExpression, toBooleanPredicate } from '@tanstack/db'
2
+ import {
3
+ InvalidPersistedCollectionConfigError,
4
+ InvalidPersistedCollectionCoordinatorError,
5
+ InvalidPersistedStorageKeyEncodingError,
6
+ InvalidPersistedStorageKeyError,
7
+ InvalidPersistenceAdapterError,
8
+ InvalidSyncConfigError,
9
+ } from './errors'
10
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
11
+ import type {
12
+ ChangeMessageOrDeleteKeyMessage,
13
+ Collection,
14
+ CollectionConfig,
15
+ CollectionIndexMetadata,
16
+ DeleteMutationFnParams,
17
+ InsertMutationFnParams,
18
+ LoadSubsetOptions,
19
+ PendingMutation,
20
+ SyncConfig,
21
+ SyncConfigRes,
22
+ SyncMetadataApi,
23
+ UpdateMutationFnParams,
24
+ UtilsRecord,
25
+ } from '@tanstack/db'
26
+
27
+ export type PersistedMutationEnvelope =
28
+ | {
29
+ mutationId: string
30
+ type: `insert`
31
+ key: string | number
32
+ value: Record<string, unknown>
33
+ }
34
+ | {
35
+ mutationId: string
36
+ type: `update`
37
+ key: string | number
38
+ value: Record<string, unknown>
39
+ }
40
+ | {
41
+ mutationId: string
42
+ type: `delete`
43
+ key: string | number
44
+ value: Record<string, unknown>
45
+ }
46
+
47
+ export type ProtocolEnvelope<TPayload> = {
48
+ v: 1
49
+ dbName: string
50
+ collectionId: string
51
+ senderId: string
52
+ ts: number
53
+ payload: TPayload
54
+ }
55
+
56
+ export type LeaderHeartbeat = {
57
+ type: `leader:heartbeat`
58
+ term: number
59
+ leaderId: string
60
+ latestSeq: number
61
+ latestRowVersion: number
62
+ }
63
+
64
+ export type TxCommitted = {
65
+ type: `tx:committed`
66
+ term: number
67
+ seq: number
68
+ txId: string
69
+ latestRowVersion: number
70
+ } & (
71
+ | {
72
+ requiresFullReload: true
73
+ }
74
+ | {
75
+ requiresFullReload: false
76
+ changedRows: Array<{
77
+ key: string | number
78
+ value: Record<string, unknown>
79
+ }>
80
+ deletedKeys: Array<string | number>
81
+ rowMetadataMutations?: Array<
82
+ PersistedRowMetadataMutation<string | number>
83
+ >
84
+ collectionMetadataMutations?: Array<PersistedCollectionMetadataMutation>
85
+ }
86
+ )
87
+
88
+ export type EnsureRemoteSubsetRequest = {
89
+ type: `rpc:ensureRemoteSubset:req`
90
+ rpcId: string
91
+ options: LoadSubsetOptions
92
+ }
93
+
94
+ export type EnsureRemoteSubsetResponse =
95
+ | {
96
+ type: `rpc:ensureRemoteSubset:res`
97
+ rpcId: string
98
+ ok: true
99
+ }
100
+ | {
101
+ type: `rpc:ensureRemoteSubset:res`
102
+ rpcId: string
103
+ ok: false
104
+ error: string
105
+ }
106
+
107
+ export type ApplyLocalMutationsRequest = {
108
+ type: `rpc:applyLocalMutations:req`
109
+ rpcId: string
110
+ envelopeId: string
111
+ mutations: Array<PersistedMutationEnvelope>
112
+ }
113
+
114
+ export type ApplyLocalMutationsResponse =
115
+ | {
116
+ type: `rpc:applyLocalMutations:res`
117
+ rpcId: string
118
+ ok: true
119
+ term: number
120
+ seq: number
121
+ latestRowVersion: number
122
+ acceptedMutationIds: Array<string>
123
+ }
124
+ | {
125
+ type: `rpc:applyLocalMutations:res`
126
+ rpcId: string
127
+ ok: false
128
+ code: `NOT_LEADER` | `VALIDATION_ERROR` | `CONFLICT` | `TIMEOUT`
129
+ error: string
130
+ }
131
+
132
+ export type PullSinceRequest = {
133
+ type: `rpc:pullSince:req`
134
+ rpcId: string
135
+ fromRowVersion: number
136
+ }
137
+
138
+ export type PullSinceResponse =
139
+ | {
140
+ type: `rpc:pullSince:res`
141
+ rpcId: string
142
+ ok: true
143
+ latestTerm: number
144
+ latestSeq: number
145
+ latestRowVersion: number
146
+ requiresFullReload: true
147
+ }
148
+ | {
149
+ type: `rpc:pullSince:res`
150
+ rpcId: string
151
+ ok: true
152
+ latestTerm: number
153
+ latestSeq: number
154
+ latestRowVersion: number
155
+ requiresFullReload: false
156
+ changedKeys: Array<string | number>
157
+ deletedKeys: Array<string | number>
158
+ deltas?: Array<
159
+ ReplayableTxDelta<Record<string, unknown>, string | number>
160
+ >
161
+ }
162
+ | {
163
+ type: `rpc:pullSince:res`
164
+ rpcId: string
165
+ ok: false
166
+ error: string
167
+ }
168
+
169
+ export type CollectionReset = {
170
+ type: `collection:reset`
171
+ schemaVersion: number
172
+ resetEpoch: number
173
+ }
174
+
175
+ export interface PersistedIndexSpec {
176
+ readonly expressionSql: ReadonlyArray<string>
177
+ readonly whereSql?: string
178
+ readonly metadata?: Readonly<Record<string, unknown>>
179
+ }
180
+
181
+ export type PersistedRowMetadataMutation<
182
+ TKey extends string | number = string | number,
183
+ > = { type: `set`; key: TKey; value: unknown } | { type: `delete`; key: TKey }
184
+
185
+ export type PersistedCollectionMetadataMutation =
186
+ | { type: `set`; key: string; value: unknown }
187
+ | { type: `delete`; key: string }
188
+
189
+ export type ReplayableTxDelta<
190
+ T extends Record<string, unknown> = Record<string, unknown>,
191
+ TKey extends string | number = string | number,
192
+ > = {
193
+ txId: string
194
+ latestRowVersion: number
195
+ changedRows: Array<{ key: TKey; value: T }>
196
+ deletedKeys: Array<TKey>
197
+ rowMetadataMutations: Array<PersistedRowMetadataMutation<TKey>>
198
+ collectionMetadataMutations: Array<PersistedCollectionMetadataMutation>
199
+ }
200
+
201
+ export type PersistedScannedRow<
202
+ T extends object,
203
+ TKey extends string | number = string | number,
204
+ > = {
205
+ key: TKey
206
+ value: T
207
+ metadata?: unknown
208
+ }
209
+
210
+ export type PersistedRowScanOptions = {
211
+ metadataOnly?: boolean
212
+ }
213
+
214
+ export type PersistedTx<
215
+ T extends object,
216
+ TKey extends string | number = string | number,
217
+ > = {
218
+ txId: string
219
+ term: number
220
+ seq: number
221
+ rowVersion: number
222
+ truncate?: boolean
223
+ mutations: Array<
224
+ | {
225
+ type: `insert`
226
+ key: TKey
227
+ value: T
228
+ metadata?: unknown
229
+ metadataChanged?: boolean
230
+ }
231
+ | {
232
+ type: `update`
233
+ key: TKey
234
+ value: T
235
+ metadata?: unknown
236
+ metadataChanged?: boolean
237
+ }
238
+ | { type: `delete`; key: TKey; value: T }
239
+ >
240
+ rowMetadataMutations?: Array<PersistedRowMetadataMutation<TKey>>
241
+ collectionMetadataMutations?: Array<PersistedCollectionMetadataMutation>
242
+ }
243
+
244
+ export interface PersistenceAdapter<
245
+ T extends object,
246
+ TKey extends string | number = string | number,
247
+ > {
248
+ loadSubset: (
249
+ collectionId: string,
250
+ options: LoadSubsetOptions,
251
+ ctx?: { requiredIndexSignatures?: ReadonlyArray<string> },
252
+ ) => Promise<Array<{ key: TKey; value: T; metadata?: unknown }>>
253
+ applyCommittedTx: (
254
+ collectionId: string,
255
+ tx: PersistedTx<T, TKey>,
256
+ ) => Promise<void>
257
+ loadCollectionMetadata?: (
258
+ collectionId: string,
259
+ ) => Promise<Array<{ key: string; value: unknown }>>
260
+ scanRows?: (
261
+ collectionId: string,
262
+ options?: PersistedRowScanOptions,
263
+ ) => Promise<Array<PersistedScannedRow<T, TKey>>>
264
+ ensureIndex: (
265
+ collectionId: string,
266
+ signature: string,
267
+ spec: PersistedIndexSpec,
268
+ ) => Promise<void>
269
+ markIndexRemoved?: (collectionId: string, signature: string) => Promise<void>
270
+ getStreamPosition?: (collectionId: string) => Promise<{
271
+ latestTerm: number
272
+ latestSeq: number
273
+ latestRowVersion: number
274
+ }>
275
+ }
276
+
277
+ export interface SQLiteDriver {
278
+ exec: (sql: string) => Promise<void>
279
+ query: <T>(
280
+ sql: string,
281
+ params?: ReadonlyArray<unknown>,
282
+ ) => Promise<ReadonlyArray<T>>
283
+ run: (sql: string, params?: ReadonlyArray<unknown>) => Promise<void>
284
+ transaction: <T>(
285
+ fn: (transactionDriver: SQLiteDriver) => Promise<T>,
286
+ ) => Promise<T>
287
+ transactionWithDriver?: <T>(
288
+ fn: (transactionDriver: SQLiteDriver) => Promise<T>,
289
+ ) => Promise<T>
290
+ }
291
+
292
+ export interface PersistedCollectionCoordinator {
293
+ getNodeId: () => string
294
+ subscribe: (
295
+ collectionId: string,
296
+ onMessage: (message: ProtocolEnvelope<unknown>) => void,
297
+ ) => () => void
298
+ publish: (collectionId: string, message: ProtocolEnvelope<unknown>) => void
299
+ isLeader: (collectionId: string) => boolean
300
+ ensureLeadership: (collectionId: string) => Promise<void>
301
+ requestEnsureRemoteSubset?: (
302
+ collectionId: string,
303
+ options: LoadSubsetOptions,
304
+ ) => Promise<void>
305
+ requestEnsurePersistedIndex: (
306
+ collectionId: string,
307
+ signature: string,
308
+ spec: PersistedIndexSpec,
309
+ ) => Promise<void>
310
+ requestApplyLocalMutations?: (
311
+ collectionId: string,
312
+ mutations: Array<PersistedMutationEnvelope>,
313
+ ) => Promise<ApplyLocalMutationsResponse>
314
+ pullSince?: (
315
+ collectionId: string,
316
+ fromRowVersion: number,
317
+ ) => Promise<PullSinceResponse>
318
+ }
319
+
320
+ export interface PersistedCollectionPersistence<
321
+ T extends object,
322
+ TKey extends string | number = string | number,
323
+ > {
324
+ adapter: PersistenceAdapter<T, TKey>
325
+ coordinator?: PersistedCollectionCoordinator
326
+ resolvePersistenceForCollection?: (options: {
327
+ collectionId: string
328
+ mode: PersistedCollectionMode
329
+ schemaVersion?: number
330
+ }) => PersistedCollectionPersistence<T, TKey>
331
+ resolvePersistenceForMode?: (
332
+ mode: PersistedCollectionMode,
333
+ ) => PersistedCollectionPersistence<T, TKey>
334
+ }
335
+
336
+ type PersistedResolvedPersistence<
337
+ T extends object,
338
+ TKey extends string | number,
339
+ > = PersistedCollectionPersistence<T, TKey> & {
340
+ coordinator: PersistedCollectionCoordinator
341
+ }
342
+
343
+ export type PersistedCollectionLeadershipState = {
344
+ nodeId: string
345
+ isLeader: boolean
346
+ }
347
+
348
+ export interface PersistedCollectionUtils extends UtilsRecord {
349
+ acceptMutations: (transaction: {
350
+ mutations: Array<PendingMutation<Record<string, unknown>>>
351
+ }) => Promise<void> | void
352
+ getLeadershipState?: () => PersistedCollectionLeadershipState
353
+ forceReloadSubset?: (options: LoadSubsetOptions) => Promise<void> | void
354
+ }
355
+
356
+ export type PersistedSyncWrappedOptions<
357
+ T extends object,
358
+ TKey extends string | number,
359
+ TSchema extends StandardSchemaV1 = never,
360
+ TUtils extends UtilsRecord = UtilsRecord,
361
+ > = CollectionConfig<T, TKey, TSchema, TUtils> & {
362
+ sync: SyncConfig<T, TKey>
363
+ persistence: PersistedCollectionPersistence<T, TKey>
364
+ schemaVersion?: number
365
+ }
366
+
367
+ export type PersistedLocalOnlyOptions<
368
+ T extends object,
369
+ TKey extends string | number,
370
+ TSchema extends StandardSchemaV1 = never,
371
+ TUtils extends UtilsRecord = UtilsRecord,
372
+ > = Omit<CollectionConfig<T, TKey, TSchema, TUtils>, `sync`> & {
373
+ persistence: PersistedCollectionPersistence<T, TKey>
374
+ schemaVersion?: number
375
+ }
376
+
377
+ type PersistedSyncOptionsResult<
378
+ T extends object,
379
+ TKey extends string | number,
380
+ TSchema extends StandardSchemaV1,
381
+ TUtils extends UtilsRecord,
382
+ > = CollectionConfig<T, TKey, TSchema, TUtils> & {
383
+ persistence: PersistedResolvedPersistence<T, TKey>
384
+ }
385
+
386
+ type PersistedLocalOnlyOptionsResult<
387
+ T extends object,
388
+ TKey extends string | number,
389
+ TSchema extends StandardSchemaV1,
390
+ TUtils extends UtilsRecord,
391
+ > = CollectionConfig<T, TKey, TSchema, TUtils & PersistedCollectionUtils> & {
392
+ id: string
393
+ persistence: PersistedResolvedPersistence<T, TKey>
394
+ utils: TUtils & PersistedCollectionUtils
395
+ }
396
+
397
+ const REQUIRED_COORDINATOR_METHODS: ReadonlyArray<
398
+ keyof Pick<
399
+ PersistedCollectionCoordinator,
400
+ | `getNodeId`
401
+ | `subscribe`
402
+ | `publish`
403
+ | `isLeader`
404
+ | `ensureLeadership`
405
+ | `requestEnsurePersistedIndex`
406
+ >
407
+ > = [
408
+ `getNodeId`,
409
+ `subscribe`,
410
+ `publish`,
411
+ `isLeader`,
412
+ `ensureLeadership`,
413
+ `requestEnsurePersistedIndex`,
414
+ ]
415
+
416
+ const REQUIRED_ADAPTER_METHODS: ReadonlyArray<
417
+ keyof Pick<
418
+ PersistenceAdapter<object, string | number>,
419
+ `loadSubset` | `applyCommittedTx` | `ensureIndex`
420
+ >
421
+ > = [`loadSubset`, `applyCommittedTx`, `ensureIndex`]
422
+
423
+ const TARGETED_INVALIDATION_KEY_LIMIT = 128
424
+ const DEFAULT_DB_NAME = `tanstack-db`
425
+ const REMOTE_ENSURE_RETRY_DELAY_MS = 50
426
+
427
+ type SyncControlFns<T extends object, TKey extends string | number> = {
428
+ begin: ((options?: { immediate?: boolean }) => void) | null
429
+ write:
430
+ | ((
431
+ message:
432
+ | { type: `insert`; value: T; metadata?: Record<string, unknown> }
433
+ | { type: `update`; value: T; metadata?: Record<string, unknown> }
434
+ | { type: `delete`; key: TKey },
435
+ ) => void)
436
+ | null
437
+ commit: (() => void) | null
438
+ truncate: (() => void) | null
439
+ metadata: SyncMetadataApi<TKey> | null
440
+ }
441
+
442
+ /**
443
+ * Phase-0 coordinator implementation for single-process runtimes.
444
+ * It satisfies the coordinator contract without cross-process transport.
445
+ */
446
+ export class SingleProcessCoordinator implements PersistedCollectionCoordinator {
447
+ private readonly nodeId: string
448
+
449
+ constructor(nodeId: string = crypto.randomUUID()) {
450
+ this.nodeId = nodeId
451
+ }
452
+
453
+ public getNodeId(): string {
454
+ return this.nodeId
455
+ }
456
+
457
+ public subscribe(): () => void {
458
+ return () => {}
459
+ }
460
+
461
+ public publish(): void {}
462
+
463
+ public isLeader(): boolean {
464
+ return true
465
+ }
466
+
467
+ public async ensureLeadership(): Promise<void> {}
468
+
469
+ public async requestEnsureRemoteSubset(): Promise<void> {}
470
+
471
+ public async requestEnsurePersistedIndex(): Promise<void> {}
472
+
473
+ public pullSince(): Promise<PullSinceResponse> {
474
+ return Promise.resolve({
475
+ type: `rpc:pullSince:res`,
476
+ rpcId: crypto.randomUUID(),
477
+ ok: true,
478
+ latestTerm: 1,
479
+ latestSeq: 0,
480
+ latestRowVersion: 0,
481
+ requiresFullReload: false,
482
+ changedKeys: [],
483
+ deletedKeys: [],
484
+ deltas: [],
485
+ })
486
+ }
487
+ }
488
+
489
+ export function validatePersistedCollectionCoordinator(
490
+ coordinator: PersistedCollectionCoordinator,
491
+ ): void {
492
+ for (const method of REQUIRED_COORDINATOR_METHODS) {
493
+ if (typeof coordinator[method] !== `function`) {
494
+ throw new InvalidPersistedCollectionCoordinatorError(method)
495
+ }
496
+ }
497
+ }
498
+
499
+ function validatePersistenceAdapter<
500
+ T extends object,
501
+ TKey extends string | number,
502
+ >(adapter: PersistenceAdapter<T, TKey>): void {
503
+ for (const method of REQUIRED_ADAPTER_METHODS) {
504
+ if (typeof adapter[method] !== `function`) {
505
+ throw new InvalidPersistenceAdapterError(method)
506
+ }
507
+ }
508
+ }
509
+
510
+ function resolvePersistence<T extends object, TKey extends string | number>(
511
+ persistence: PersistedCollectionPersistence<T, TKey>,
512
+ ): PersistedResolvedPersistence<T, TKey> {
513
+ validatePersistenceAdapter(persistence.adapter)
514
+
515
+ const coordinator = persistence.coordinator ?? new SingleProcessCoordinator()
516
+ validatePersistedCollectionCoordinator(coordinator)
517
+
518
+ return {
519
+ ...persistence,
520
+ coordinator,
521
+ }
522
+ }
523
+
524
+ function resolvePersistenceForMode<
525
+ T extends object,
526
+ TKey extends string | number,
527
+ >(
528
+ persistence: PersistedCollectionPersistence<T, TKey>,
529
+ mode: PersistedCollectionMode,
530
+ ): PersistedResolvedPersistence<T, TKey> {
531
+ const modeSpecificPersistence = persistence.resolvePersistenceForMode?.(mode)
532
+ return resolvePersistence(modeSpecificPersistence ?? persistence)
533
+ }
534
+
535
+ function resolvePersistenceForCollection<
536
+ T extends object,
537
+ TKey extends string | number,
538
+ >(
539
+ persistence: PersistedCollectionPersistence<T, TKey>,
540
+ options: {
541
+ collectionId: string
542
+ mode: PersistedCollectionMode
543
+ schemaVersion?: number
544
+ },
545
+ ): PersistedResolvedPersistence<T, TKey> {
546
+ const collectionSpecificPersistence =
547
+ persistence.resolvePersistenceForCollection?.(options)
548
+ if (collectionSpecificPersistence) {
549
+ return resolvePersistence(collectionSpecificPersistence)
550
+ }
551
+
552
+ return resolvePersistenceForMode(persistence, options.mode)
553
+ }
554
+
555
+ function hasOwnSyncKey(options: object): options is { sync: unknown } {
556
+ return Object.prototype.hasOwnProperty.call(options, `sync`)
557
+ }
558
+
559
+ function isRecord(value: unknown): value is Record<string, unknown> {
560
+ return typeof value === `object` && value !== null
561
+ }
562
+
563
+ function isValidSyncConfig(value: unknown): value is SyncConfig<object> {
564
+ if (!isRecord(value)) {
565
+ return false
566
+ }
567
+
568
+ return typeof value.sync === `function`
569
+ }
570
+
571
+ export type PersistedCollectionMode = `sync-present` | `sync-absent`
572
+ type PersistedMode = PersistedCollectionMode
573
+
574
+ type NormalizedSyncOperation<T extends object, TKey extends string | number> =
575
+ | {
576
+ type: `update`
577
+ key: TKey
578
+ value: T
579
+ metadata?: Record<string, unknown>
580
+ }
581
+ | {
582
+ type: `delete`
583
+ key: TKey
584
+ value: T
585
+ }
586
+
587
+ type BufferedSyncTransaction<T extends object, TKey extends string | number> = {
588
+ operations: Array<NormalizedSyncOperation<T, TKey>>
589
+ rowMetadataWrites: Map<
590
+ TKey,
591
+ { type: `set`; value: unknown } | { type: `delete` }
592
+ >
593
+ collectionMetadataWrites: Map<
594
+ string,
595
+ { type: `set`; value: unknown } | { type: `delete` }
596
+ >
597
+ truncate: boolean
598
+ internal: boolean
599
+ }
600
+
601
+ type OpenSyncTransaction<
602
+ T extends object,
603
+ TKey extends string | number,
604
+ > = BufferedSyncTransaction<T, TKey> & {
605
+ queuedBecauseHydrating: boolean
606
+ }
607
+
608
+ type SyncWriteNormalization<T extends object, TKey extends string | number> = {
609
+ forwardMessage:
610
+ | {
611
+ type: `update`
612
+ value: T
613
+ metadata?: Record<string, unknown>
614
+ }
615
+ | {
616
+ type: `delete`
617
+ key: TKey
618
+ }
619
+ operation: NormalizedSyncOperation<T, TKey>
620
+ }
621
+
622
+ class ApplyMutex {
623
+ private queue: Promise<void> = Promise.resolve()
624
+
625
+ async run<T>(task: () => Promise<T>): Promise<T> {
626
+ const taskPromise = this.queue.then(() => task())
627
+ this.queue = taskPromise.then(
628
+ () => undefined,
629
+ () => undefined,
630
+ )
631
+ return taskPromise
632
+ }
633
+ }
634
+
635
+ function toStableSerializable(value: unknown): unknown {
636
+ if (value == null) {
637
+ return value
638
+ }
639
+
640
+ switch (typeof value) {
641
+ case `string`:
642
+ case `number`:
643
+ case `boolean`:
644
+ return value
645
+ case `bigint`:
646
+ return value.toString()
647
+ case `function`:
648
+ case `symbol`:
649
+ case `undefined`:
650
+ return undefined
651
+ }
652
+
653
+ if (value instanceof Date) {
654
+ return value.toISOString()
655
+ }
656
+
657
+ if (Array.isArray(value)) {
658
+ return value
659
+ .map((entry) => toStableSerializable(entry))
660
+ .filter((entry) => entry !== undefined)
661
+ }
662
+
663
+ if (value instanceof Set) {
664
+ return Array.from(value)
665
+ .map((entry) => toStableSerializable(entry))
666
+ .filter((entry) => entry !== undefined)
667
+ .sort((left, right) => {
668
+ const leftSerialized = JSON.stringify(left)
669
+ const rightSerialized = JSON.stringify(right)
670
+ return leftSerialized < rightSerialized
671
+ ? -1
672
+ : leftSerialized > rightSerialized
673
+ ? 1
674
+ : 0
675
+ })
676
+ }
677
+
678
+ if (value instanceof Map) {
679
+ return Array.from(value.entries())
680
+ .map(([key, mapValue]) => ({
681
+ key: toStableSerializable(key),
682
+ value: toStableSerializable(mapValue),
683
+ }))
684
+ .filter((entry) => entry.key !== undefined && entry.value !== undefined)
685
+ .sort((left, right) => {
686
+ const leftSerialized = JSON.stringify(left.key)
687
+ const rightSerialized = JSON.stringify(right.key)
688
+ return leftSerialized < rightSerialized
689
+ ? -1
690
+ : leftSerialized > rightSerialized
691
+ ? 1
692
+ : 0
693
+ })
694
+ }
695
+
696
+ const record = value as Record<string, unknown>
697
+ const orderedKeys = Object.keys(record).sort((left, right) =>
698
+ left < right ? -1 : left > right ? 1 : 0,
699
+ )
700
+ const serializableRecord: Record<string, unknown> = {}
701
+ for (const key of orderedKeys) {
702
+ const serializableValue = toStableSerializable(record[key])
703
+ if (serializableValue !== undefined) {
704
+ serializableRecord[key] = serializableValue
705
+ }
706
+ }
707
+ return serializableRecord
708
+ }
709
+
710
+ function stableSerialize(value: unknown): string {
711
+ return JSON.stringify(toStableSerializable(value) ?? null)
712
+ }
713
+
714
+ function normalizeSubsetOptionsForKey(
715
+ options: LoadSubsetOptions,
716
+ ): Record<string, unknown> {
717
+ return {
718
+ where: toStableSerializable(options.where),
719
+ orderBy: toStableSerializable(options.orderBy),
720
+ limit: options.limit,
721
+ cursor: toStableSerializable(options.cursor),
722
+ offset: options.offset,
723
+ }
724
+ }
725
+
726
+ function normalizeSyncFnResult(result: void | (() => void) | SyncConfigRes) {
727
+ if (typeof result === `function`) {
728
+ return { cleanup: result } satisfies SyncConfigRes
729
+ }
730
+
731
+ if (result === undefined) {
732
+ return {} satisfies SyncConfigRes
733
+ }
734
+
735
+ return result
736
+ }
737
+
738
+ function isTxCommittedPayload(payload: unknown): payload is TxCommitted {
739
+ if (!isRecord(payload) || payload.type !== `tx:committed`) {
740
+ return false
741
+ }
742
+
743
+ if (
744
+ typeof payload.term !== `number` ||
745
+ typeof payload.seq !== `number` ||
746
+ typeof payload.txId !== `string` ||
747
+ typeof payload.latestRowVersion !== `number` ||
748
+ typeof payload.requiresFullReload !== `boolean`
749
+ ) {
750
+ return false
751
+ }
752
+
753
+ if (payload.requiresFullReload) {
754
+ return true
755
+ }
756
+
757
+ return (
758
+ Array.isArray(payload.changedRows) &&
759
+ Array.isArray(payload.deletedKeys) &&
760
+ (payload.rowMetadataMutations === undefined ||
761
+ Array.isArray(payload.rowMetadataMutations)) &&
762
+ (payload.collectionMetadataMutations === undefined ||
763
+ Array.isArray(payload.collectionMetadataMutations))
764
+ )
765
+ }
766
+
767
+ function isCollectionResetPayload(
768
+ payload: unknown,
769
+ ): payload is CollectionReset {
770
+ return (
771
+ isRecord(payload) &&
772
+ payload.type === `collection:reset` &&
773
+ typeof payload.schemaVersion === `number` &&
774
+ typeof payload.resetEpoch === `number`
775
+ )
776
+ }
777
+
778
+ function toPersistedMutationEnvelope(
779
+ mutation: PendingMutation<Record<string, unknown>>,
780
+ ): PersistedMutationEnvelope {
781
+ const key = mutation.key as string | number
782
+ const value =
783
+ mutation.type === `delete`
784
+ ? (mutation.original as Record<string, unknown>)
785
+ : mutation.modified
786
+
787
+ return {
788
+ mutationId: mutation.mutationId,
789
+ type: mutation.type,
790
+ key,
791
+ value,
792
+ }
793
+ }
794
+
795
+ class PersistedCollectionRuntime<
796
+ T extends object,
797
+ TKey extends string | number,
798
+ > {
799
+ private readonly applyMutex = new ApplyMutex()
800
+ private readonly activeSubsets = new Map<string, LoadSubsetOptions>()
801
+ private readonly pendingRemoteSubsetEnsures = new Map<
802
+ string,
803
+ LoadSubsetOptions
804
+ >()
805
+ private readonly queuedHydrationTransactions: Array<
806
+ BufferedSyncTransaction<T, TKey>
807
+ > = []
808
+ private readonly queuedTxCommitted: Array<TxCommitted> = []
809
+ private readonly subscriptionIds = new WeakMap<object, string>()
810
+
811
+ private collection: Collection<T, TKey, PersistedCollectionUtils> | null =
812
+ null
813
+ private syncControls: SyncControlFns<T, TKey> = {
814
+ begin: null,
815
+ write: null,
816
+ commit: null,
817
+ truncate: null,
818
+ metadata: null,
819
+ }
820
+ private started = false
821
+ private startupMetadataPromise: Promise<void> | null = null
822
+ private startPromise: Promise<void> | null = null
823
+ private internalApplyDepth = 0
824
+ private isHydrating = false
825
+ private coordinatorUnsubscribe: (() => void) | null = null
826
+ private indexAddedUnsubscribe: (() => void) | null = null
827
+ private indexRemovedUnsubscribe: (() => void) | null = null
828
+ private remoteEnsureRetryTimer: ReturnType<typeof setTimeout> | null = null
829
+ private nextSubscriptionId = 0
830
+
831
+ private latestTerm = 0
832
+ private latestSeq = 0
833
+ private latestRowVersion = 0
834
+ private localTerm = 1
835
+ private localSeq = 0
836
+ private localRowVersion = 0
837
+
838
+ constructor(
839
+ private readonly mode: PersistedMode,
840
+ private readonly collectionId: string,
841
+ private readonly persistence: PersistedResolvedPersistence<T, TKey>,
842
+ private readonly syncMode: `eager` | `on-demand`,
843
+ private readonly dbName: string,
844
+ ) {}
845
+
846
+ setSyncControls(syncControls: SyncControlFns<T, TKey>): void {
847
+ this.syncControls = syncControls
848
+ }
849
+
850
+ clearSyncControls(): void {
851
+ this.syncControls = {
852
+ begin: null,
853
+ write: null,
854
+ commit: null,
855
+ truncate: null,
856
+ metadata: null,
857
+ }
858
+ }
859
+
860
+ isHydratingNow(): boolean {
861
+ return this.isHydrating
862
+ }
863
+
864
+ isApplyingInternally(): boolean {
865
+ return this.internalApplyDepth > 0
866
+ }
867
+
868
+ setCollection(
869
+ collection: Collection<T, TKey, PersistedCollectionUtils>,
870
+ ): void {
871
+ if (this.collection === collection) {
872
+ return
873
+ }
874
+
875
+ this.collection = collection
876
+ this.attachCoordinatorSubscription()
877
+ }
878
+
879
+ getLeadershipState(): PersistedCollectionLeadershipState {
880
+ return {
881
+ nodeId: this.persistence.coordinator.getNodeId(),
882
+ isLeader: this.persistence.coordinator.isLeader(this.collectionId),
883
+ }
884
+ }
885
+
886
+ async ensureStarted(): Promise<void> {
887
+ if (this.startPromise) {
888
+ return this.startPromise
889
+ }
890
+
891
+ this.startPromise = this.startInternal()
892
+ return this.startPromise
893
+ }
894
+
895
+ async ensureStartupMetadataLoaded(): Promise<void> {
896
+ if (this.startupMetadataPromise) {
897
+ return this.startupMetadataPromise
898
+ }
899
+
900
+ this.startupMetadataPromise = this.loadStartupMetadataInternal()
901
+ return this.startupMetadataPromise
902
+ }
903
+
904
+ private async startInternal(): Promise<void> {
905
+ if (this.started) {
906
+ return
907
+ }
908
+
909
+ this.started = true
910
+
911
+ await this.ensureStartupMetadataLoaded()
912
+
913
+ const indexBootstrapSnapshot = this.collection?.getIndexMetadata() ?? []
914
+ this.attachIndexLifecycleListeners()
915
+ await this.bootstrapPersistedIndexes(indexBootstrapSnapshot)
916
+
917
+ if (this.syncMode !== `on-demand`) {
918
+ this.activeSubsets.set(this.getSubsetKey({}), {})
919
+ await this.applyMutex.run(() =>
920
+ this.hydrateSubsetUnsafe({}, { requestRemoteEnsure: false }),
921
+ )
922
+ }
923
+ }
924
+
925
+ private async loadStartupMetadataInternal(): Promise<void> {
926
+ // Restore stream position from the database so that new mutations
927
+ // don't collide with previously applied transactions.
928
+ if (this.persistence.adapter.getStreamPosition) {
929
+ const position = await this.persistence.adapter.getStreamPosition(
930
+ this.collectionId,
931
+ )
932
+ this.observeStreamPosition(
933
+ position.latestTerm,
934
+ position.latestSeq,
935
+ position.latestRowVersion,
936
+ )
937
+ }
938
+
939
+ await this.loadCollectionMetadataIntoCollection()
940
+ }
941
+
942
+ private async loadCollectionMetadataIntoCollection(): Promise<void> {
943
+ const collectionMetadata = await this.loadCollectionMetadataSnapshot()
944
+ this.replaceCollectionMetadataSnapshot(collectionMetadata)
945
+ }
946
+
947
+ private async loadCollectionMetadataSnapshot(): Promise<
948
+ Array<{ key: string; value: unknown }>
949
+ > {
950
+ if (!this.persistence.adapter.loadCollectionMetadata) {
951
+ return []
952
+ }
953
+
954
+ return this.persistence.adapter.loadCollectionMetadata(this.collectionId)
955
+ }
956
+
957
+ private replaceCollectionMetadataSnapshot(
958
+ collectionMetadata: Array<{ key: string; value: unknown }>,
959
+ ): void {
960
+ if (
961
+ !this.syncControls.begin ||
962
+ !this.syncControls.commit ||
963
+ !this.syncControls.metadata
964
+ ) {
965
+ return
966
+ }
967
+
968
+ const nextMetadata = new Map(
969
+ collectionMetadata.map(({ key, value }) => [key, value]),
970
+ )
971
+ const currentKeys = this.syncControls.metadata.collection
972
+ .list()
973
+ .map(({ key }) => key)
974
+
975
+ this.withInternalApply(() => {
976
+ this.syncControls.begin?.({ immediate: true })
977
+
978
+ currentKeys.forEach((key) => {
979
+ if (!nextMetadata.has(key)) {
980
+ this.syncControls.metadata?.collection.delete(key)
981
+ }
982
+ })
983
+
984
+ nextMetadata.forEach((value, key) => {
985
+ this.syncControls.metadata?.collection.set(key, value)
986
+ })
987
+
988
+ this.syncControls.commit?.()
989
+ })
990
+ }
991
+
992
+ async loadSubset(
993
+ options: LoadSubsetOptions,
994
+ upstreamLoadSubset?: (options: LoadSubsetOptions) => true | Promise<void>,
995
+ ): Promise<void> {
996
+ this.activeSubsets.set(this.getSubsetKey(options), options)
997
+
998
+ await this.applyMutex.run(() =>
999
+ this.hydrateSubsetUnsafe(options, {
1000
+ requestRemoteEnsure: this.mode === `sync-present`,
1001
+ }),
1002
+ )
1003
+
1004
+ if (upstreamLoadSubset) {
1005
+ try {
1006
+ const maybePromise = upstreamLoadSubset(options)
1007
+ if (maybePromise instanceof Promise) {
1008
+ maybePromise.catch((error) => {
1009
+ console.warn(
1010
+ `Failed to load remote subset in persisted wrapper:`,
1011
+ error,
1012
+ )
1013
+ this.queueRemoteSubsetEnsure(options)
1014
+ })
1015
+ }
1016
+ } catch (error) {
1017
+ console.warn(`Failed to trigger remote subset load:`, error)
1018
+ this.queueRemoteSubsetEnsure(options)
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ unloadSubset(
1024
+ options: LoadSubsetOptions,
1025
+ upstreamUnloadSubset?: (options: LoadSubsetOptions) => void,
1026
+ ): void {
1027
+ this.activeSubsets.delete(this.getSubsetKey(options))
1028
+ upstreamUnloadSubset?.(options)
1029
+ }
1030
+
1031
+ async forceReloadSubset(options: LoadSubsetOptions): Promise<void> {
1032
+ this.activeSubsets.set(this.getSubsetKey(options), options)
1033
+ await this.applyMutex.run(() =>
1034
+ this.hydrateSubsetUnsafe(options, { requestRemoteEnsure: false }),
1035
+ )
1036
+ }
1037
+
1038
+ queueHydrationBufferedTransaction(
1039
+ transaction: BufferedSyncTransaction<T, TKey>,
1040
+ ): void {
1041
+ this.queuedHydrationTransactions.push(transaction)
1042
+ }
1043
+
1044
+ async persistAndBroadcastExternalSyncTransaction(
1045
+ transaction: BufferedSyncTransaction<T, TKey>,
1046
+ ): Promise<void> {
1047
+ await this.applyMutex.run(() =>
1048
+ this.persistAndBroadcastExternalSyncTransactionUnsafe(transaction),
1049
+ )
1050
+ }
1051
+
1052
+ normalizeSyncWriteMessage(
1053
+ message: ChangeMessageOrDeleteKeyMessage<T, TKey>,
1054
+ ): SyncWriteNormalization<T, TKey> {
1055
+ if (!this.collection) {
1056
+ throw new InvalidPersistedCollectionConfigError(
1057
+ `collection must be attached before sync writes are processed`,
1058
+ )
1059
+ }
1060
+
1061
+ if (`key` in message) {
1062
+ const key = message.key
1063
+ const previousValue = this.collection.get(key) ?? ({} as T)
1064
+
1065
+ return {
1066
+ forwardMessage: {
1067
+ type: `delete`,
1068
+ key,
1069
+ },
1070
+ operation: {
1071
+ type: `delete`,
1072
+ key,
1073
+ value: previousValue,
1074
+ },
1075
+ }
1076
+ }
1077
+
1078
+ // Handle delete messages that include the full value instead of just a key
1079
+ // (e.g. from queryCollectionOptions which sends { type: 'delete', value: oldItem })
1080
+ if (message.type === `delete`) {
1081
+ const key = this.collection.getKeyFromItem(message.value)
1082
+ const previousValue = this.collection.get(key) ?? message.value
1083
+
1084
+ return {
1085
+ forwardMessage: {
1086
+ type: `delete`,
1087
+ key,
1088
+ },
1089
+ operation: {
1090
+ type: `delete`,
1091
+ key,
1092
+ value: previousValue,
1093
+ },
1094
+ }
1095
+ }
1096
+
1097
+ const key = this.collection.getKeyFromItem(message.value)
1098
+ return {
1099
+ forwardMessage: {
1100
+ type: `update`,
1101
+ value: message.value,
1102
+ metadata: message.metadata,
1103
+ },
1104
+ operation: {
1105
+ type: `update`,
1106
+ key,
1107
+ value: message.value,
1108
+ metadata: message.metadata,
1109
+ },
1110
+ }
1111
+ }
1112
+
1113
+ async persistAndConfirmCollectionMutations(
1114
+ mutations: Array<PendingMutation<T>>,
1115
+ ): Promise<void> {
1116
+ if (mutations.length === 0) {
1117
+ return
1118
+ }
1119
+
1120
+ await this.applyMutex.run(async () => {
1121
+ const acceptedMutationIds =
1122
+ await this.persistCollectionMutationsUnsafe(mutations)
1123
+ const acceptedMutationIdSet = new Set(acceptedMutationIds)
1124
+ const acceptedMutations = mutations.filter((mutation) =>
1125
+ acceptedMutationIdSet.has(mutation.mutationId),
1126
+ )
1127
+
1128
+ if (acceptedMutations.length !== mutations.length) {
1129
+ throw new Error(
1130
+ `persistence coordinator accepted ${acceptedMutations.length} of ${mutations.length} mutations; partial acceptance is not supported`,
1131
+ )
1132
+ }
1133
+
1134
+ this.confirmMutationsSyncUnsafe(acceptedMutations)
1135
+ })
1136
+ }
1137
+
1138
+ async acceptTransactionMutations(transaction: {
1139
+ mutations: Array<PendingMutation<Record<string, unknown>>>
1140
+ }): Promise<void> {
1141
+ const collectionMutations = this.filterMutationsForCollection(
1142
+ transaction.mutations,
1143
+ )
1144
+
1145
+ if (collectionMutations.length === 0) {
1146
+ return
1147
+ }
1148
+
1149
+ await this.persistAndConfirmCollectionMutations(collectionMutations)
1150
+ }
1151
+
1152
+ cleanup(): void {
1153
+ this.coordinatorUnsubscribe?.()
1154
+ this.coordinatorUnsubscribe = null
1155
+
1156
+ this.indexAddedUnsubscribe?.()
1157
+ this.indexAddedUnsubscribe = null
1158
+
1159
+ this.indexRemovedUnsubscribe?.()
1160
+ this.indexRemovedUnsubscribe = null
1161
+
1162
+ if (this.remoteEnsureRetryTimer !== null) {
1163
+ clearTimeout(this.remoteEnsureRetryTimer)
1164
+ this.remoteEnsureRetryTimer = null
1165
+ }
1166
+
1167
+ this.pendingRemoteSubsetEnsures.clear()
1168
+ this.activeSubsets.clear()
1169
+ this.queuedHydrationTransactions.length = 0
1170
+ this.queuedTxCommitted.length = 0
1171
+ this.clearSyncControls()
1172
+ }
1173
+
1174
+ private withInternalApply(task: () => void): void {
1175
+ this.internalApplyDepth++
1176
+ try {
1177
+ task()
1178
+ } finally {
1179
+ this.internalApplyDepth--
1180
+ }
1181
+ }
1182
+
1183
+ private getRequiredIndexSignatures(): ReadonlyArray<string> {
1184
+ if (!this.collection) {
1185
+ return []
1186
+ }
1187
+
1188
+ return this.collection
1189
+ .getIndexMetadata()
1190
+ .map((metadata) => metadata.signature)
1191
+ }
1192
+
1193
+ private loadSubsetRowsUnsafe(
1194
+ options: LoadSubsetOptions,
1195
+ ): Promise<Array<{ key: TKey; value: T; metadata?: unknown }>> {
1196
+ return this.persistence.adapter.loadSubset(this.collectionId, options, {
1197
+ requiredIndexSignatures: this.getRequiredIndexSignatures(),
1198
+ })
1199
+ }
1200
+
1201
+ private async scanPersistedRowsUnsafe(
1202
+ options?: PersistedRowScanOptions,
1203
+ ): Promise<Array<PersistedScannedRow<T, TKey>>> {
1204
+ if (!this.persistence.adapter.scanRows) {
1205
+ return []
1206
+ }
1207
+
1208
+ return this.persistence.adapter.scanRows(this.collectionId, options)
1209
+ }
1210
+
1211
+ async scanPersistedRows(
1212
+ options?: PersistedRowScanOptions,
1213
+ ): Promise<Array<PersistedScannedRow<T, TKey>>> {
1214
+ return this.applyMutex.run(() => this.scanPersistedRowsUnsafe(options))
1215
+ }
1216
+
1217
+ private async hydrateSubsetUnsafe(
1218
+ options: LoadSubsetOptions,
1219
+ config: {
1220
+ requestRemoteEnsure: boolean
1221
+ },
1222
+ ): Promise<void> {
1223
+ this.isHydrating = true
1224
+ try {
1225
+ const rows = await this.loadSubsetRowsUnsafe(options)
1226
+
1227
+ this.applyRowsToCollection(rows)
1228
+ } finally {
1229
+ this.isHydrating = false
1230
+ }
1231
+
1232
+ await this.flushQueuedHydrationTransactionsUnsafe()
1233
+ await this.flushQueuedTxCommittedUnsafe()
1234
+
1235
+ if (config.requestRemoteEnsure) {
1236
+ this.queueRemoteSubsetEnsure(options)
1237
+ }
1238
+ }
1239
+
1240
+ private applyRowsToCollection(
1241
+ rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
1242
+ ): void {
1243
+ if (
1244
+ !this.syncControls.begin ||
1245
+ !this.syncControls.write ||
1246
+ !this.syncControls.commit
1247
+ ) {
1248
+ return
1249
+ }
1250
+
1251
+ this.withInternalApply(() => {
1252
+ this.syncControls.begin?.({ immediate: true })
1253
+
1254
+ for (const row of rows) {
1255
+ this.syncControls.write?.({
1256
+ type: `update`,
1257
+ value: row.value,
1258
+ metadata: row.metadata as Record<string, unknown> | undefined,
1259
+ })
1260
+ }
1261
+
1262
+ this.syncControls.commit?.()
1263
+ })
1264
+ }
1265
+
1266
+ private replaceCollectionSnapshot(
1267
+ rows: Array<{ key: TKey; value: T; metadata?: unknown }>,
1268
+ collectionMetadata: Array<{ key: string; value: unknown }>,
1269
+ ): void {
1270
+ if (
1271
+ !this.syncControls.begin ||
1272
+ !this.syncControls.write ||
1273
+ !this.syncControls.commit ||
1274
+ !this.syncControls.metadata
1275
+ ) {
1276
+ return
1277
+ }
1278
+
1279
+ const nextMetadata = new Map(
1280
+ collectionMetadata.map(({ key, value }) => [key, value]),
1281
+ )
1282
+ const currentKeys = this.syncControls.metadata.collection
1283
+ .list()
1284
+ .map(({ key }) => key)
1285
+
1286
+ this.withInternalApply(() => {
1287
+ this.syncControls.begin?.({ immediate: true })
1288
+ this.syncControls.truncate?.()
1289
+
1290
+ for (const row of rows) {
1291
+ this.syncControls.write?.({
1292
+ type: `update`,
1293
+ value: row.value,
1294
+ metadata: row.metadata as Record<string, unknown> | undefined,
1295
+ })
1296
+ }
1297
+
1298
+ currentKeys.forEach((key) => {
1299
+ if (!nextMetadata.has(key)) {
1300
+ this.syncControls.metadata?.collection.delete(key)
1301
+ }
1302
+ })
1303
+
1304
+ nextMetadata.forEach((value, key) => {
1305
+ this.syncControls.metadata?.collection.set(key, value)
1306
+ })
1307
+
1308
+ this.syncControls.commit?.()
1309
+ })
1310
+ }
1311
+
1312
+ private async flushQueuedHydrationTransactionsUnsafe(): Promise<void> {
1313
+ while (this.queuedHydrationTransactions.length > 0) {
1314
+ const transaction = this.queuedHydrationTransactions.shift()
1315
+ if (!transaction) {
1316
+ continue
1317
+ }
1318
+ await this.applyBufferedSyncTransactionUnsafe(transaction)
1319
+ }
1320
+ }
1321
+
1322
+ private async applyBufferedSyncTransactionUnsafe(
1323
+ transaction: BufferedSyncTransaction<T, TKey>,
1324
+ ): Promise<void> {
1325
+ if (
1326
+ !this.syncControls.begin ||
1327
+ !this.syncControls.write ||
1328
+ !this.syncControls.commit
1329
+ ) {
1330
+ return
1331
+ }
1332
+
1333
+ const applyToCollection = () => {
1334
+ this.syncControls.begin?.()
1335
+
1336
+ if (transaction.truncate) {
1337
+ this.syncControls.truncate?.()
1338
+ }
1339
+
1340
+ for (const operation of transaction.operations) {
1341
+ if (operation.type === `delete`) {
1342
+ this.syncControls.write?.({
1343
+ type: `delete`,
1344
+ key: operation.key,
1345
+ })
1346
+ } else {
1347
+ this.syncControls.write?.({
1348
+ type: `update`,
1349
+ value: operation.value,
1350
+ metadata: operation.metadata,
1351
+ })
1352
+ }
1353
+ }
1354
+
1355
+ for (const [key, metadataWrite] of transaction.rowMetadataWrites) {
1356
+ if (metadataWrite.type === `delete`) {
1357
+ this.syncControls.metadata?.row.delete(key)
1358
+ } else {
1359
+ this.syncControls.metadata?.row.set(key, metadataWrite.value)
1360
+ }
1361
+ }
1362
+
1363
+ for (const [key, metadataWrite] of transaction.collectionMetadataWrites) {
1364
+ if (metadataWrite.type === `delete`) {
1365
+ this.syncControls.metadata?.collection.delete(key)
1366
+ } else {
1367
+ this.syncControls.metadata?.collection.set(key, metadataWrite.value)
1368
+ }
1369
+ }
1370
+
1371
+ this.syncControls.commit?.()
1372
+ }
1373
+
1374
+ if (transaction.internal) {
1375
+ this.withInternalApply(applyToCollection)
1376
+ return
1377
+ }
1378
+
1379
+ applyToCollection()
1380
+ await this.persistAndBroadcastExternalSyncTransactionUnsafe(transaction)
1381
+ }
1382
+
1383
+ private async persistAndBroadcastExternalSyncTransactionUnsafe(
1384
+ transaction: BufferedSyncTransaction<T, TKey>,
1385
+ ): Promise<void> {
1386
+ if (transaction.internal) {
1387
+ return
1388
+ }
1389
+
1390
+ const streamPosition = this.nextLocalStreamPosition()
1391
+
1392
+ if (
1393
+ !transaction.truncate &&
1394
+ transaction.operations.length === 0 &&
1395
+ transaction.rowMetadataWrites.size === 0 &&
1396
+ transaction.collectionMetadataWrites.size === 0
1397
+ ) {
1398
+ this.publishTxCommittedEvent(
1399
+ this.createTxCommittedPayload({
1400
+ term: streamPosition.term,
1401
+ seq: streamPosition.seq,
1402
+ txId: crypto.randomUUID(),
1403
+ latestRowVersion: streamPosition.rowVersion,
1404
+ changedRows: [],
1405
+ deletedKeys: [],
1406
+ requiresFullReload: true,
1407
+ }),
1408
+ )
1409
+ return
1410
+ }
1411
+
1412
+ const tx = this.createPersistedTxFromOperations(transaction, streamPosition)
1413
+
1414
+ await this.persistence.adapter.applyCommittedTx(this.collectionId, tx)
1415
+ this.publishTxCommittedEvent(
1416
+ this.createTxCommittedPayload({
1417
+ term: tx.term,
1418
+ seq: tx.seq,
1419
+ txId: tx.txId,
1420
+ latestRowVersion: tx.rowVersion,
1421
+ requiresFullReload: transaction.truncate,
1422
+ changedRows: transaction.operations
1423
+ .filter((operation) => operation.type === `update`)
1424
+ .map((operation) => ({ key: operation.key, value: operation.value })),
1425
+ deletedKeys: transaction.operations
1426
+ .filter((operation) => operation.type === `delete`)
1427
+ .map((operation) => operation.key),
1428
+ rowMetadataMutations: tx.rowMetadataMutations,
1429
+ collectionMetadataMutations: tx.collectionMetadataMutations,
1430
+ }),
1431
+ )
1432
+ }
1433
+
1434
+ private createPersistedTxFromOperations(
1435
+ transaction: BufferedSyncTransaction<T, TKey>,
1436
+ streamPosition: { term: number; seq: number; rowVersion: number },
1437
+ ): PersistedTx<T, TKey> {
1438
+ return {
1439
+ txId: crypto.randomUUID(),
1440
+ term: streamPosition.term,
1441
+ seq: streamPosition.seq,
1442
+ rowVersion: streamPosition.rowVersion,
1443
+ truncate: transaction.truncate,
1444
+ mutations: transaction.operations.map((operation) =>
1445
+ operation.type === `update`
1446
+ ? {
1447
+ type: `update`,
1448
+ key: operation.key,
1449
+ value: operation.value,
1450
+ }
1451
+ : {
1452
+ type: `delete`,
1453
+ key: operation.key,
1454
+ value: operation.value,
1455
+ },
1456
+ ),
1457
+ rowMetadataMutations: Array.from(
1458
+ transaction.rowMetadataWrites.entries(),
1459
+ ).map(([key, metadataWrite]) =>
1460
+ metadataWrite.type === `delete`
1461
+ ? { type: `delete`, key }
1462
+ : { type: `set`, key, value: metadataWrite.value },
1463
+ ),
1464
+ collectionMetadataMutations: Array.from(
1465
+ transaction.collectionMetadataWrites.entries(),
1466
+ ).map(([key, metadataWrite]) =>
1467
+ metadataWrite.type === `delete`
1468
+ ? { type: `delete`, key }
1469
+ : { type: `set`, key, value: metadataWrite.value },
1470
+ ),
1471
+ }
1472
+ }
1473
+
1474
+ private createPersistedTxFromMutations(
1475
+ mutations: Array<PendingMutation<T>>,
1476
+ streamPosition: { term: number; seq: number; rowVersion: number },
1477
+ ): PersistedTx<T, TKey> {
1478
+ return {
1479
+ txId: crypto.randomUUID(),
1480
+ term: streamPosition.term,
1481
+ seq: streamPosition.seq,
1482
+ rowVersion: streamPosition.rowVersion,
1483
+ mutations: mutations.map((mutation) => {
1484
+ if (mutation.type === `delete`) {
1485
+ return {
1486
+ type: `delete`,
1487
+ key: mutation.key as TKey,
1488
+ value: mutation.original as T,
1489
+ }
1490
+ }
1491
+
1492
+ if (mutation.type === `insert`) {
1493
+ return {
1494
+ type: `insert`,
1495
+ key: mutation.key as TKey,
1496
+ value: mutation.modified,
1497
+ }
1498
+ }
1499
+
1500
+ return {
1501
+ type: `update`,
1502
+ key: mutation.key as TKey,
1503
+ value: mutation.modified,
1504
+ }
1505
+ }),
1506
+ }
1507
+ }
1508
+
1509
+ private confirmMutationsSyncUnsafe(
1510
+ mutations: Array<PendingMutation<T>>,
1511
+ ): void {
1512
+ if (
1513
+ !this.syncControls.begin ||
1514
+ !this.syncControls.write ||
1515
+ !this.syncControls.commit
1516
+ ) {
1517
+ return
1518
+ }
1519
+
1520
+ this.withInternalApply(() => {
1521
+ this.syncControls.begin?.({ immediate: true })
1522
+
1523
+ for (const mutation of mutations) {
1524
+ if (mutation.type === `delete`) {
1525
+ this.syncControls.write?.({
1526
+ type: `delete`,
1527
+ key: mutation.key as TKey,
1528
+ })
1529
+ } else {
1530
+ this.syncControls.write?.({
1531
+ type: `update`,
1532
+ value: mutation.modified,
1533
+ })
1534
+ }
1535
+ }
1536
+
1537
+ this.syncControls.commit?.()
1538
+ })
1539
+ }
1540
+
1541
+ private filterMutationsForCollection(
1542
+ mutations: Array<PendingMutation<Record<string, unknown>>>,
1543
+ ): Array<PendingMutation<T>> {
1544
+ const collection = this.collection
1545
+ return mutations.filter((mutation) => {
1546
+ if (collection) {
1547
+ return mutation.collection === collection
1548
+ }
1549
+ return mutation.collection.id === this.collectionId
1550
+ }) as Array<PendingMutation<T>>
1551
+ }
1552
+
1553
+ private async persistCollectionMutationsUnsafe(
1554
+ mutations: Array<PendingMutation<T>>,
1555
+ ): Promise<Array<string>> {
1556
+ // When a coordinator with requestApplyLocalMutations is available, always
1557
+ // route through it — even on the leader tab. This ensures the coordinator's
1558
+ // seq/rowVersion counters stay in sync with actual writes. Without this,
1559
+ // the leader's direct-path writes would increment the runtime's localSeq
1560
+ // but leave the coordinator's state.latestSeq stale, causing seq collisions
1561
+ // when follower RPCs later arrive.
1562
+ if (this.persistence.coordinator.requestApplyLocalMutations) {
1563
+ const envelopeMutations = mutations.map((mutation) =>
1564
+ toPersistedMutationEnvelope(
1565
+ mutation as unknown as PendingMutation<Record<string, unknown>>,
1566
+ ),
1567
+ )
1568
+
1569
+ const response =
1570
+ await this.persistence.coordinator.requestApplyLocalMutations(
1571
+ this.collectionId,
1572
+ envelopeMutations,
1573
+ )
1574
+
1575
+ if (!response.ok) {
1576
+ throw new Error(
1577
+ `failed to apply local mutations through coordinator: ${response.error}`,
1578
+ )
1579
+ }
1580
+
1581
+ this.observeStreamPosition(
1582
+ response.term,
1583
+ response.seq,
1584
+ response.latestRowVersion,
1585
+ )
1586
+
1587
+ const uniqueAcceptedMutationIds = Array.from(
1588
+ new Set(response.acceptedMutationIds),
1589
+ )
1590
+ const submittedMutationIds = new Set(
1591
+ mutations.map((mutation) => mutation.mutationId),
1592
+ )
1593
+ const hasUnknownAcceptedMutationId = uniqueAcceptedMutationIds.some(
1594
+ (mutationId) => !submittedMutationIds.has(mutationId),
1595
+ )
1596
+
1597
+ if (hasUnknownAcceptedMutationId) {
1598
+ throw new Error(
1599
+ `persistence coordinator returned unknown mutation ids in applyLocalMutations response`,
1600
+ )
1601
+ }
1602
+
1603
+ return uniqueAcceptedMutationIds
1604
+ }
1605
+
1606
+ // Fallback: no coordinator with requestApplyLocalMutations (e.g.
1607
+ // SingleProcessCoordinator). Apply directly and broadcast.
1608
+ const streamPosition = this.nextLocalStreamPosition()
1609
+ const tx = this.createPersistedTxFromMutations(mutations, streamPosition)
1610
+ await this.persistence.adapter.applyCommittedTx(this.collectionId, tx)
1611
+
1612
+ this.publishTxCommittedEvent(
1613
+ this.createTxCommittedPayload({
1614
+ term: tx.term,
1615
+ seq: tx.seq,
1616
+ txId: tx.txId,
1617
+ latestRowVersion: tx.rowVersion,
1618
+ changedRows: mutations
1619
+ .filter((mutation) => mutation.type !== `delete`)
1620
+ .map((mutation) => ({
1621
+ key: mutation.key as TKey,
1622
+ value: mutation.modified,
1623
+ })),
1624
+ deletedKeys: mutations
1625
+ .filter((mutation) => mutation.type === `delete`)
1626
+ .map((mutation) => mutation.key as TKey),
1627
+ rowMetadataMutations: tx.rowMetadataMutations,
1628
+ collectionMetadataMutations: tx.collectionMetadataMutations,
1629
+ }),
1630
+ )
1631
+
1632
+ return mutations.map((mutation) => mutation.mutationId)
1633
+ }
1634
+
1635
+ private createTxCommittedPayload(args: {
1636
+ term: number
1637
+ seq: number
1638
+ txId: string
1639
+ latestRowVersion: number
1640
+ changedRows: Array<{ key: TKey; value: T }>
1641
+ deletedKeys: Array<TKey>
1642
+ rowMetadataMutations?: Array<PersistedRowMetadataMutation<TKey>>
1643
+ collectionMetadataMutations?: Array<PersistedCollectionMetadataMutation>
1644
+ hasMetadataChanges?: boolean
1645
+ requiresFullReload?: boolean
1646
+ }): TxCommitted {
1647
+ const rowMetadataMutations = args.rowMetadataMutations ?? []
1648
+ const collectionMetadataMutations = args.collectionMetadataMutations ?? []
1649
+ const requiresFullReload =
1650
+ args.requiresFullReload === true ||
1651
+ args.changedRows.length +
1652
+ args.deletedKeys.length +
1653
+ rowMetadataMutations.length +
1654
+ collectionMetadataMutations.length >
1655
+ TARGETED_INVALIDATION_KEY_LIMIT
1656
+
1657
+ if (requiresFullReload) {
1658
+ return {
1659
+ type: `tx:committed`,
1660
+ term: args.term,
1661
+ seq: args.seq,
1662
+ txId: args.txId,
1663
+ latestRowVersion: args.latestRowVersion,
1664
+ requiresFullReload: true,
1665
+ }
1666
+ }
1667
+
1668
+ return {
1669
+ type: `tx:committed`,
1670
+ term: args.term,
1671
+ seq: args.seq,
1672
+ txId: args.txId,
1673
+ latestRowVersion: args.latestRowVersion,
1674
+ requiresFullReload: false,
1675
+ changedRows: args.changedRows as Array<{
1676
+ key: string | number
1677
+ value: Record<string, unknown>
1678
+ }>,
1679
+ deletedKeys: args.deletedKeys,
1680
+ rowMetadataMutations: rowMetadataMutations as Array<
1681
+ PersistedRowMetadataMutation<string | number>
1682
+ >,
1683
+ collectionMetadataMutations,
1684
+ }
1685
+ }
1686
+
1687
+ private publishTxCommittedEvent(txCommitted: TxCommitted): void {
1688
+ this.observeStreamPosition(
1689
+ txCommitted.term,
1690
+ txCommitted.seq,
1691
+ txCommitted.latestRowVersion,
1692
+ )
1693
+
1694
+ const envelope: ProtocolEnvelope<TxCommitted> = {
1695
+ v: 1,
1696
+ dbName: this.dbName,
1697
+ collectionId: this.collectionId,
1698
+ senderId: this.persistence.coordinator.getNodeId(),
1699
+ ts: Date.now(),
1700
+ payload: txCommitted,
1701
+ }
1702
+ this.persistence.coordinator.publish(this.collectionId, envelope)
1703
+ }
1704
+
1705
+ private observeStreamPosition(
1706
+ term: number,
1707
+ seq: number,
1708
+ rowVersion: number,
1709
+ ): void {
1710
+ if (
1711
+ term > this.latestTerm ||
1712
+ (term === this.latestTerm && seq > this.latestSeq)
1713
+ ) {
1714
+ this.latestTerm = term
1715
+ this.latestSeq = seq
1716
+ }
1717
+ if (rowVersion > this.latestRowVersion) {
1718
+ this.latestRowVersion = rowVersion
1719
+ }
1720
+
1721
+ if (term > this.localTerm) {
1722
+ this.localTerm = term
1723
+ this.localSeq = seq
1724
+ } else if (term === this.localTerm && seq > this.localSeq) {
1725
+ this.localSeq = seq
1726
+ }
1727
+ if (rowVersion > this.localRowVersion) {
1728
+ this.localRowVersion = rowVersion
1729
+ }
1730
+ }
1731
+
1732
+ private nextLocalStreamPosition(): {
1733
+ term: number
1734
+ seq: number
1735
+ rowVersion: number
1736
+ } {
1737
+ this.localTerm = Math.max(this.localTerm, this.latestTerm || 1)
1738
+ this.localSeq = Math.max(this.localSeq, this.latestSeq) + 1
1739
+ this.localRowVersion =
1740
+ Math.max(this.localRowVersion, this.latestRowVersion) + 1
1741
+
1742
+ return {
1743
+ term: this.localTerm,
1744
+ seq: this.localSeq,
1745
+ rowVersion: this.localRowVersion,
1746
+ }
1747
+ }
1748
+
1749
+ private getSubsetKey(options: LoadSubsetOptions): string {
1750
+ const subscription = options.subscription as object | undefined
1751
+ if (subscription && typeof subscription === `object`) {
1752
+ const existingId = this.subscriptionIds.get(subscription)
1753
+ if (existingId) {
1754
+ return existingId
1755
+ }
1756
+
1757
+ this.nextSubscriptionId++
1758
+ const id = `sub:${this.nextSubscriptionId}`
1759
+ this.subscriptionIds.set(subscription, id)
1760
+ return id
1761
+ }
1762
+
1763
+ return `opts:${stableSerialize(normalizeSubsetOptionsForKey(options))}`
1764
+ }
1765
+
1766
+ private queueRemoteSubsetEnsure(options: LoadSubsetOptions): void {
1767
+ if (
1768
+ this.mode !== `sync-present` ||
1769
+ !this.persistence.coordinator.requestEnsureRemoteSubset
1770
+ ) {
1771
+ return
1772
+ }
1773
+
1774
+ this.pendingRemoteSubsetEnsures.set(this.getSubsetKey(options), options)
1775
+ void this.flushPendingRemoteSubsetEnsures()
1776
+ }
1777
+
1778
+ private scheduleRemoteEnsureRetry(): void {
1779
+ if (
1780
+ this.mode !== `sync-present` ||
1781
+ !this.persistence.coordinator.requestEnsureRemoteSubset
1782
+ ) {
1783
+ return
1784
+ }
1785
+
1786
+ if (
1787
+ this.pendingRemoteSubsetEnsures.size === 0 ||
1788
+ this.remoteEnsureRetryTimer !== null
1789
+ ) {
1790
+ return
1791
+ }
1792
+
1793
+ this.remoteEnsureRetryTimer = setTimeout(() => {
1794
+ this.remoteEnsureRetryTimer = null
1795
+ void this.flushPendingRemoteSubsetEnsures()
1796
+ }, REMOTE_ENSURE_RETRY_DELAY_MS)
1797
+ }
1798
+
1799
+ private async flushPendingRemoteSubsetEnsures(): Promise<void> {
1800
+ if (
1801
+ this.mode !== `sync-present` ||
1802
+ !this.persistence.coordinator.requestEnsureRemoteSubset
1803
+ ) {
1804
+ return
1805
+ }
1806
+
1807
+ if (this.remoteEnsureRetryTimer !== null) {
1808
+ clearTimeout(this.remoteEnsureRetryTimer)
1809
+ this.remoteEnsureRetryTimer = null
1810
+ }
1811
+
1812
+ for (const [subsetKey, options] of this.pendingRemoteSubsetEnsures) {
1813
+ try {
1814
+ await this.persistence.coordinator.requestEnsureRemoteSubset(
1815
+ this.collectionId,
1816
+ options,
1817
+ )
1818
+ this.pendingRemoteSubsetEnsures.delete(subsetKey)
1819
+ } catch (error) {
1820
+ console.warn(`Failed to ensure remote subset:`, error)
1821
+ }
1822
+ }
1823
+
1824
+ this.scheduleRemoteEnsureRetry()
1825
+ }
1826
+
1827
+ private attachCoordinatorSubscription(): void {
1828
+ if (this.coordinatorUnsubscribe) {
1829
+ return
1830
+ }
1831
+
1832
+ this.coordinatorUnsubscribe = this.persistence.coordinator.subscribe(
1833
+ this.collectionId,
1834
+ (message) => {
1835
+ this.onCoordinatorMessage(message)
1836
+ },
1837
+ )
1838
+ }
1839
+
1840
+ private onCoordinatorMessage(message: ProtocolEnvelope<unknown>): void {
1841
+ if (message.collectionId !== this.collectionId) {
1842
+ return
1843
+ }
1844
+
1845
+ const { payload } = message
1846
+ const isSelf = message.senderId === this.persistence.coordinator.getNodeId()
1847
+
1848
+ // Allow tx:committed from self — the coordinator produces these on behalf
1849
+ // of both local and remote mutations. The seq dedup in
1850
+ // processCommittedTxUnsafe prevents double-processing of our own writes.
1851
+ if (isTxCommittedPayload(payload)) {
1852
+ if (this.isHydrating) {
1853
+ this.queuedTxCommitted.push(payload)
1854
+ return
1855
+ }
1856
+
1857
+ void this.applyMutex
1858
+ .run(() => this.processCommittedTxUnsafe(payload))
1859
+ .catch((error) => {
1860
+ console.warn(`Failed to process tx:committed message:`, error)
1861
+ })
1862
+ return
1863
+ }
1864
+
1865
+ // Skip remaining message types from self (e.g. heartbeats, resets)
1866
+ if (isSelf) {
1867
+ return
1868
+ }
1869
+
1870
+ if (isCollectionResetPayload(payload)) {
1871
+ void this.applyMutex
1872
+ .run(() => this.truncateAndReloadUnsafe())
1873
+ .catch((error) => {
1874
+ console.warn(`Failed to process collection reset message:`, error)
1875
+ })
1876
+ }
1877
+ }
1878
+
1879
+ private async flushQueuedTxCommittedUnsafe(): Promise<void> {
1880
+ while (this.queuedTxCommitted.length > 0) {
1881
+ const queued = this.queuedTxCommitted.shift()
1882
+ if (!queued) {
1883
+ continue
1884
+ }
1885
+ await this.processCommittedTxUnsafe(queued)
1886
+ }
1887
+ }
1888
+
1889
+ private async processCommittedTxUnsafe(
1890
+ txCommitted: TxCommitted,
1891
+ ): Promise<void> {
1892
+ if (txCommitted.term < this.latestTerm) {
1893
+ return
1894
+ }
1895
+
1896
+ if (
1897
+ txCommitted.term === this.latestTerm &&
1898
+ txCommitted.seq <= this.latestSeq
1899
+ ) {
1900
+ return
1901
+ }
1902
+
1903
+ const hasGapInCurrentTerm =
1904
+ txCommitted.term === this.latestTerm &&
1905
+ txCommitted.seq > this.latestSeq + 1
1906
+ const hasGapAcrossTerms =
1907
+ txCommitted.term > this.latestTerm && txCommitted.seq > 1
1908
+ const hasGap = hasGapInCurrentTerm || hasGapAcrossTerms
1909
+
1910
+ if (hasGap) {
1911
+ await this.recoverFromSeqGapUnsafe()
1912
+ if (
1913
+ txCommitted.term < this.latestTerm ||
1914
+ (txCommitted.term === this.latestTerm &&
1915
+ txCommitted.seq <= this.latestSeq)
1916
+ ) {
1917
+ return
1918
+ }
1919
+ }
1920
+
1921
+ this.observeStreamPosition(
1922
+ txCommitted.term,
1923
+ txCommitted.seq,
1924
+ txCommitted.latestRowVersion,
1925
+ )
1926
+
1927
+ await this.invalidateFromCommittedTxUnsafe(txCommitted)
1928
+ }
1929
+
1930
+ private async recoverFromSeqGapUnsafe(): Promise<void> {
1931
+ if (this.persistence.coordinator.pullSince && this.latestRowVersion >= 0) {
1932
+ try {
1933
+ const pullResponse = await this.persistence.coordinator.pullSince(
1934
+ this.collectionId,
1935
+ this.latestRowVersion,
1936
+ )
1937
+
1938
+ if (pullResponse.ok) {
1939
+ this.observeStreamPosition(
1940
+ pullResponse.latestTerm,
1941
+ pullResponse.latestSeq,
1942
+ pullResponse.latestRowVersion,
1943
+ )
1944
+ if (pullResponse.requiresFullReload || !pullResponse.deltas) {
1945
+ await this.reloadActiveSubsetsUnsafe()
1946
+ return
1947
+ }
1948
+
1949
+ for (const delta of pullResponse.deltas) {
1950
+ await this.invalidateFromCommittedTxUnsafe({
1951
+ type: `tx:committed`,
1952
+ term: pullResponse.latestTerm,
1953
+ seq: pullResponse.latestSeq,
1954
+ txId: delta.txId,
1955
+ latestRowVersion: delta.latestRowVersion,
1956
+ requiresFullReload: false,
1957
+ changedRows: delta.changedRows,
1958
+ deletedKeys: delta.deletedKeys,
1959
+ rowMetadataMutations: delta.rowMetadataMutations,
1960
+ collectionMetadataMutations: delta.collectionMetadataMutations,
1961
+ })
1962
+ }
1963
+ return
1964
+ }
1965
+ } catch (error) {
1966
+ console.warn(`Failed pullSince recovery attempt:`, error)
1967
+ }
1968
+ }
1969
+
1970
+ await this.truncateAndReloadUnsafe()
1971
+
1972
+ if (this.mode === `sync-present`) {
1973
+ for (const options of this.activeSubsets.values()) {
1974
+ this.queueRemoteSubsetEnsure(options)
1975
+ }
1976
+ }
1977
+ }
1978
+
1979
+ private async truncateAndReloadUnsafe(): Promise<void> {
1980
+ if (this.syncControls.begin && this.syncControls.commit) {
1981
+ this.withInternalApply(() => {
1982
+ this.syncControls.begin?.({ immediate: true })
1983
+ this.syncControls.truncate?.()
1984
+ this.syncControls.commit?.()
1985
+ })
1986
+ }
1987
+
1988
+ await this.reloadActiveSubsetsUnsafe()
1989
+ }
1990
+
1991
+ private async invalidateFromCommittedTxUnsafe(
1992
+ txCommitted: TxCommitted,
1993
+ ): Promise<void> {
1994
+ if (txCommitted.requiresFullReload) {
1995
+ await this.reloadActiveSubsetsUnsafe()
1996
+ return
1997
+ }
1998
+
1999
+ const changedKeyCount =
2000
+ txCommitted.changedRows.length + txCommitted.deletedKeys.length
2001
+ if (changedKeyCount > TARGETED_INVALIDATION_KEY_LIMIT) {
2002
+ await this.reloadActiveSubsetsUnsafe()
2003
+ return
2004
+ }
2005
+
2006
+ const hasPaginatedSubset = Array.from(this.activeSubsets.values()).some(
2007
+ (opt) => opt.limit != null || opt.offset != null || opt.cursor != null,
2008
+ )
2009
+
2010
+ if (!hasPaginatedSubset || changedKeyCount === 0) {
2011
+ await this.applyTargetedInvalidationUnsafe(txCommitted)
2012
+ return
2013
+ }
2014
+
2015
+ // Has paginated subsets — fall back to full reload.
2016
+ // Targeted invalidation for paginated subsets is deferred to a future iteration.
2017
+ await this.reloadActiveSubsetsUnsafe()
2018
+ }
2019
+
2020
+ private async applyTargetedInvalidationUnsafe(
2021
+ txCommitted: TxCommitted & { requiresFullReload: false },
2022
+ ): Promise<void> {
2023
+ const subsetEvaluators = Array.from(this.activeSubsets.values()).map(
2024
+ (opt) => (opt.where ? compileSingleRowExpression(opt.where) : null),
2025
+ )
2026
+
2027
+ this.withInternalApply(() => {
2028
+ this.syncControls.begin?.({ immediate: true })
2029
+
2030
+ for (const {
2031
+ key: changedKey,
2032
+ value: newValue,
2033
+ } of txCommitted.changedRows) {
2034
+ const matchesAnySubset = subsetEvaluators.some((evaluator) => {
2035
+ if (!evaluator) return true
2036
+ return toBooleanPredicate(evaluator(newValue) as boolean | null)
2037
+ })
2038
+
2039
+ if (matchesAnySubset) {
2040
+ this.syncControls.write?.({ type: `update`, value: newValue as T })
2041
+ } else if (this.collection?.get(changedKey as TKey) !== undefined) {
2042
+ this.syncControls.write?.({ type: `delete`, key: changedKey as TKey })
2043
+ }
2044
+ }
2045
+
2046
+ for (const deletedKey of txCommitted.deletedKeys) {
2047
+ this.syncControls.write?.({ type: `delete`, key: deletedKey as TKey })
2048
+ }
2049
+
2050
+ txCommitted.rowMetadataMutations?.forEach((mutation) => {
2051
+ if (mutation.type === `delete`) {
2052
+ this.syncControls.metadata?.row.delete(mutation.key as TKey)
2053
+ } else {
2054
+ this.syncControls.metadata?.row.set(
2055
+ mutation.key as TKey,
2056
+ mutation.value,
2057
+ )
2058
+ }
2059
+ })
2060
+
2061
+ txCommitted.collectionMetadataMutations?.forEach((mutation) => {
2062
+ if (mutation.type === `delete`) {
2063
+ this.syncControls.metadata?.collection.delete(mutation.key)
2064
+ } else {
2065
+ this.syncControls.metadata?.collection.set(
2066
+ mutation.key,
2067
+ mutation.value,
2068
+ )
2069
+ }
2070
+ })
2071
+
2072
+ this.syncControls.commit?.()
2073
+ })
2074
+ }
2075
+
2076
+ private async reloadActiveSubsetsUnsafe(): Promise<void> {
2077
+ const activeSubsetOptions =
2078
+ this.activeSubsets.size > 0
2079
+ ? Array.from(this.activeSubsets.values())
2080
+ : [{}]
2081
+
2082
+ this.isHydrating = true
2083
+ try {
2084
+ const mergedRows = new Map<TKey, { value: T; metadata?: unknown }>()
2085
+ const collectionMetadata = await this.loadCollectionMetadataSnapshot()
2086
+ for (const options of activeSubsetOptions) {
2087
+ const subsetRows = await this.loadSubsetRowsUnsafe(options)
2088
+ for (const row of subsetRows) {
2089
+ mergedRows.set(row.key, {
2090
+ value: row.value,
2091
+ metadata: row.metadata,
2092
+ })
2093
+ }
2094
+ }
2095
+
2096
+ this.replaceCollectionSnapshot(
2097
+ Array.from(mergedRows.entries()).map(([key, row]) => ({
2098
+ key,
2099
+ value: row.value,
2100
+ metadata: row.metadata,
2101
+ })),
2102
+ collectionMetadata,
2103
+ )
2104
+ } finally {
2105
+ this.isHydrating = false
2106
+ }
2107
+
2108
+ await this.flushQueuedHydrationTransactionsUnsafe()
2109
+ await this.flushQueuedTxCommittedUnsafe()
2110
+ }
2111
+
2112
+ private attachIndexLifecycleListeners(): void {
2113
+ if (
2114
+ !this.collection ||
2115
+ this.indexAddedUnsubscribe ||
2116
+ this.indexRemovedUnsubscribe
2117
+ ) {
2118
+ return
2119
+ }
2120
+
2121
+ this.indexAddedUnsubscribe = this.collection.on(`index:added`, (event) => {
2122
+ void this.ensurePersistedIndex(event.index)
2123
+ })
2124
+ this.indexRemovedUnsubscribe = this.collection.on(
2125
+ `index:removed`,
2126
+ (event) => {
2127
+ void this.markIndexRemoved(event.index)
2128
+ },
2129
+ )
2130
+ }
2131
+
2132
+ private async bootstrapPersistedIndexes(
2133
+ indexMetadataSnapshot?: Array<CollectionIndexMetadata>,
2134
+ ): Promise<void> {
2135
+ const collection = this.collection
2136
+ if (!collection && !indexMetadataSnapshot) {
2137
+ return
2138
+ }
2139
+
2140
+ const indexMetadata =
2141
+ indexMetadataSnapshot ?? collection?.getIndexMetadata() ?? []
2142
+ for (const metadata of indexMetadata) {
2143
+ await this.ensurePersistedIndex(metadata)
2144
+ }
2145
+ }
2146
+
2147
+ private buildPersistedIndexSpec(
2148
+ index: CollectionIndexMetadata,
2149
+ ): PersistedIndexSpec {
2150
+ return {
2151
+ expressionSql: [stableSerialize(index.expression)],
2152
+ metadata: {
2153
+ name: index.name ?? null,
2154
+ resolver: toStableSerializable(index.resolver),
2155
+ options: toStableSerializable(index.options),
2156
+ },
2157
+ }
2158
+ }
2159
+
2160
+ private async ensurePersistedIndex(
2161
+ indexMetadata: CollectionIndexMetadata,
2162
+ ): Promise<void> {
2163
+ const spec = this.buildPersistedIndexSpec(indexMetadata)
2164
+
2165
+ try {
2166
+ await this.persistence.adapter.ensureIndex(
2167
+ this.collectionId,
2168
+ indexMetadata.signature,
2169
+ spec,
2170
+ )
2171
+ } catch (error) {
2172
+ console.warn(`Failed to ensure persisted index in adapter:`, error)
2173
+ }
2174
+
2175
+ try {
2176
+ await this.persistence.coordinator.requestEnsurePersistedIndex(
2177
+ this.collectionId,
2178
+ indexMetadata.signature,
2179
+ spec,
2180
+ )
2181
+ } catch (error) {
2182
+ console.warn(
2183
+ `Failed to ensure persisted index through coordinator:`,
2184
+ error,
2185
+ )
2186
+ }
2187
+ }
2188
+
2189
+ private async markIndexRemoved(
2190
+ indexMetadata: CollectionIndexMetadata,
2191
+ ): Promise<void> {
2192
+ if (!this.persistence.adapter.markIndexRemoved) {
2193
+ return
2194
+ }
2195
+
2196
+ try {
2197
+ await this.persistence.adapter.markIndexRemoved(
2198
+ this.collectionId,
2199
+ indexMetadata.signature,
2200
+ )
2201
+ } catch (error) {
2202
+ console.warn(`Failed to mark persisted index removed:`, error)
2203
+ }
2204
+ }
2205
+ }
2206
+
2207
+ function createWrappedSyncConfig<
2208
+ T extends object,
2209
+ TKey extends string | number,
2210
+ >(
2211
+ sourceSyncConfig: SyncConfig<T, TKey>,
2212
+ runtime: PersistedCollectionRuntime<T, TKey>,
2213
+ ): SyncConfig<T, TKey> {
2214
+ return {
2215
+ ...sourceSyncConfig,
2216
+ sync: (params) => {
2217
+ const transactionStack: Array<OpenSyncTransaction<T, TKey>> = []
2218
+ const getOpenTransaction = () =>
2219
+ transactionStack[transactionStack.length - 1]
2220
+ let fullStartPromise: Promise<void> | null = null
2221
+ const cancelledLoadKeys = new Set<string>()
2222
+ const loadSubscriptionIds = new WeakMap<object, string>()
2223
+ let nextLoadSubscriptionId = 0
2224
+ const getLoadKey = (options: LoadSubsetOptions) => {
2225
+ const subscription = options.subscription as object | undefined
2226
+ if (subscription && typeof subscription === `object`) {
2227
+ const existingId = loadSubscriptionIds.get(subscription)
2228
+ if (existingId) {
2229
+ return `sub:${existingId}`
2230
+ }
2231
+ nextLoadSubscriptionId++
2232
+ const nextId = String(nextLoadSubscriptionId)
2233
+ loadSubscriptionIds.set(subscription, nextId)
2234
+ return `sub:${nextId}`
2235
+ }
2236
+
2237
+ return `opts:${stableSerialize(normalizeSubsetOptionsForKey(options))}`
2238
+ }
2239
+ runtime.setSyncControls({
2240
+ begin: params.begin,
2241
+ write: params.write as SyncControlFns<T, TKey>[`write`],
2242
+ commit: params.commit,
2243
+ truncate: params.truncate,
2244
+ metadata: params.metadata ?? null,
2245
+ })
2246
+ runtime.setCollection(
2247
+ params.collection as Collection<T, TKey, PersistedCollectionUtils>,
2248
+ )
2249
+
2250
+ const wrappedParams = {
2251
+ ...params,
2252
+ markReady: () => {
2253
+ void (fullStartPromise ?? runtime.ensureStarted())
2254
+ .then(() => {
2255
+ params.markReady()
2256
+ })
2257
+ .catch((error) => {
2258
+ console.warn(
2259
+ `Failed persisted sync startup before markReady:`,
2260
+ error,
2261
+ )
2262
+ params.markReady()
2263
+ })
2264
+ },
2265
+ begin: (options?: { immediate?: boolean }) => {
2266
+ const transaction: OpenSyncTransaction<T, TKey> = {
2267
+ operations: [],
2268
+ rowMetadataWrites: new Map(),
2269
+ collectionMetadataWrites: new Map(),
2270
+ truncate: false,
2271
+ internal: runtime.isApplyingInternally(),
2272
+ queuedBecauseHydrating:
2273
+ !runtime.isApplyingInternally() && runtime.isHydratingNow(),
2274
+ }
2275
+ transactionStack.push(transaction)
2276
+
2277
+ if (!transaction.queuedBecauseHydrating) {
2278
+ params.begin(options)
2279
+ }
2280
+ },
2281
+ write: (message: ChangeMessageOrDeleteKeyMessage<T, TKey>) => {
2282
+ const normalization = runtime.normalizeSyncWriteMessage(message)
2283
+ const openTransaction = getOpenTransaction()
2284
+
2285
+ if (!openTransaction) {
2286
+ params.write(normalization.forwardMessage)
2287
+ return
2288
+ }
2289
+
2290
+ openTransaction.operations.push(normalization.operation)
2291
+ if (normalization.operation.type === `delete`) {
2292
+ openTransaction.rowMetadataWrites.set(normalization.operation.key, {
2293
+ type: `delete`,
2294
+ })
2295
+ } else if (
2296
+ message.type === `insert` &&
2297
+ normalization.operation.metadata === undefined
2298
+ ) {
2299
+ openTransaction.rowMetadataWrites.set(normalization.operation.key, {
2300
+ type: `delete`,
2301
+ })
2302
+ } else if (normalization.operation.metadata !== undefined) {
2303
+ openTransaction.rowMetadataWrites.set(normalization.operation.key, {
2304
+ type: `set`,
2305
+ value: normalization.operation.metadata,
2306
+ })
2307
+ }
2308
+ if (!openTransaction.queuedBecauseHydrating) {
2309
+ params.write(normalization.forwardMessage)
2310
+ }
2311
+ },
2312
+ metadata: params.metadata
2313
+ ? {
2314
+ row: {
2315
+ get: (key: TKey) => {
2316
+ const openTransaction = getOpenTransaction()
2317
+ const pendingWrite =
2318
+ openTransaction?.rowMetadataWrites.get(key)
2319
+ if (pendingWrite) {
2320
+ return pendingWrite.type === `delete`
2321
+ ? undefined
2322
+ : pendingWrite.value
2323
+ }
2324
+ if (openTransaction?.truncate) {
2325
+ return undefined
2326
+ }
2327
+ return params.metadata!.row.get(key)
2328
+ },
2329
+ scanPersisted: (options?: PersistedRowScanOptions) =>
2330
+ runtime.scanPersistedRows(options),
2331
+ set: (key: TKey, value: unknown) => {
2332
+ const openTransaction = getOpenTransaction()
2333
+ if (!openTransaction) {
2334
+ throw new InvalidPersistedCollectionConfigError(
2335
+ `metadata.row.set must be called within an open sync transaction`,
2336
+ )
2337
+ }
2338
+ openTransaction.rowMetadataWrites.set(key, {
2339
+ type: `set`,
2340
+ value,
2341
+ })
2342
+ if (!openTransaction.queuedBecauseHydrating) {
2343
+ params.metadata!.row.set(key, value)
2344
+ }
2345
+ },
2346
+ delete: (key: TKey) => {
2347
+ const openTransaction = getOpenTransaction()
2348
+ if (!openTransaction) {
2349
+ throw new InvalidPersistedCollectionConfigError(
2350
+ `metadata.row.delete must be called within an open sync transaction`,
2351
+ )
2352
+ }
2353
+ openTransaction.rowMetadataWrites.set(key, {
2354
+ type: `delete`,
2355
+ })
2356
+ if (!openTransaction.queuedBecauseHydrating) {
2357
+ params.metadata!.row.delete(key)
2358
+ }
2359
+ },
2360
+ },
2361
+ collection: {
2362
+ get: (key: string) => {
2363
+ const openTransaction = getOpenTransaction()
2364
+ const pendingWrite =
2365
+ openTransaction?.collectionMetadataWrites.get(key)
2366
+ if (pendingWrite) {
2367
+ return pendingWrite.type === `delete`
2368
+ ? undefined
2369
+ : pendingWrite.value
2370
+ }
2371
+ return params.metadata!.collection.get(key)
2372
+ },
2373
+ set: (key: string, value: unknown) => {
2374
+ const openTransaction = getOpenTransaction()
2375
+ if (!openTransaction) {
2376
+ throw new InvalidPersistedCollectionConfigError(
2377
+ `metadata.collection.set must be called within an open sync transaction`,
2378
+ )
2379
+ }
2380
+ openTransaction.collectionMetadataWrites.set(key, {
2381
+ type: `set`,
2382
+ value,
2383
+ })
2384
+ if (!openTransaction.queuedBecauseHydrating) {
2385
+ params.metadata!.collection.set(key, value)
2386
+ }
2387
+ },
2388
+ delete: (key: string) => {
2389
+ const openTransaction = getOpenTransaction()
2390
+ if (!openTransaction) {
2391
+ throw new InvalidPersistedCollectionConfigError(
2392
+ `metadata.collection.delete must be called within an open sync transaction`,
2393
+ )
2394
+ }
2395
+ openTransaction.collectionMetadataWrites.set(key, {
2396
+ type: `delete`,
2397
+ })
2398
+ if (!openTransaction.queuedBecauseHydrating) {
2399
+ params.metadata!.collection.delete(key)
2400
+ }
2401
+ },
2402
+ list: (prefix?: string) => {
2403
+ const merged = new Map(
2404
+ params
2405
+ .metadata!.collection.list()
2406
+ .map(({ key, value }) => [key, value]),
2407
+ )
2408
+ const openTransaction = getOpenTransaction()
2409
+ if (openTransaction) {
2410
+ for (const [
2411
+ key,
2412
+ metadataWrite,
2413
+ ] of openTransaction.collectionMetadataWrites) {
2414
+ if (metadataWrite.type === `delete`) {
2415
+ merged.delete(key)
2416
+ } else {
2417
+ merged.set(key, metadataWrite.value)
2418
+ }
2419
+ }
2420
+ }
2421
+
2422
+ return Array.from(merged.entries())
2423
+ .filter(([key]) => (prefix ? key.startsWith(prefix) : true))
2424
+ .map(([key, value]) => ({
2425
+ key,
2426
+ value,
2427
+ }))
2428
+ },
2429
+ },
2430
+ }
2431
+ : undefined,
2432
+ truncate: () => {
2433
+ const openTransaction = getOpenTransaction()
2434
+ if (!openTransaction) {
2435
+ params.truncate()
2436
+ return
2437
+ }
2438
+
2439
+ openTransaction.operations = []
2440
+ openTransaction.rowMetadataWrites.clear()
2441
+ // Intentionally preserve collectionMetadataWrites across truncate.
2442
+ // Callers (for example electric resume/reset handling) may stage
2443
+ // collection-scoped metadata before truncating row data, and those
2444
+ // writes must commit atomically with the truncate transaction.
2445
+ openTransaction.truncate = true
2446
+ if (!openTransaction.queuedBecauseHydrating) {
2447
+ params.truncate()
2448
+ }
2449
+ },
2450
+ commit: () => {
2451
+ const openTransaction = transactionStack.pop()
2452
+ if (!openTransaction) {
2453
+ params.commit()
2454
+ return
2455
+ }
2456
+
2457
+ if (openTransaction.queuedBecauseHydrating) {
2458
+ runtime.queueHydrationBufferedTransaction({
2459
+ operations: openTransaction.operations,
2460
+ rowMetadataWrites: openTransaction.rowMetadataWrites,
2461
+ collectionMetadataWrites:
2462
+ openTransaction.collectionMetadataWrites,
2463
+ truncate: openTransaction.truncate,
2464
+ internal: openTransaction.internal,
2465
+ })
2466
+ return
2467
+ }
2468
+
2469
+ params.commit()
2470
+ if (!openTransaction.internal) {
2471
+ void runtime
2472
+ .persistAndBroadcastExternalSyncTransaction({
2473
+ operations: openTransaction.operations,
2474
+ rowMetadataWrites: openTransaction.rowMetadataWrites,
2475
+ collectionMetadataWrites:
2476
+ openTransaction.collectionMetadataWrites,
2477
+ truncate: openTransaction.truncate,
2478
+ internal: false,
2479
+ })
2480
+ .catch((error) => {
2481
+ console.warn(
2482
+ `Failed to persist wrapped sync transaction:`,
2483
+ error,
2484
+ )
2485
+ })
2486
+ }
2487
+ },
2488
+ }
2489
+
2490
+ let sourceResult: SyncConfigRes = {}
2491
+ const startupState = { cleanedUp: false }
2492
+ fullStartPromise = runtime.ensureStarted()
2493
+ const sourceResultPromise = (async () => {
2494
+ await runtime.ensureStartupMetadataLoaded()
2495
+
2496
+ if (startupState.cleanedUp) {
2497
+ return sourceResult
2498
+ }
2499
+
2500
+ sourceResult = normalizeSyncFnResult(
2501
+ sourceSyncConfig.sync(wrappedParams),
2502
+ )
2503
+ return sourceResult
2504
+ })()
2505
+
2506
+ return {
2507
+ cleanup: () => {
2508
+ startupState.cleanedUp = true
2509
+ sourceResult.cleanup?.()
2510
+ runtime.cleanup()
2511
+ runtime.clearSyncControls()
2512
+ },
2513
+ loadSubset: async (options: LoadSubsetOptions) => {
2514
+ const loadKey = getLoadKey(options)
2515
+ cancelledLoadKeys.delete(loadKey)
2516
+ await fullStartPromise
2517
+ const resolvedSourceResult = await sourceResultPromise
2518
+ if (startupState.cleanedUp || cancelledLoadKeys.has(loadKey)) {
2519
+ return
2520
+ }
2521
+ await runtime.loadSubset(options, resolvedSourceResult.loadSubset)
2522
+ },
2523
+ unloadSubset: (options: LoadSubsetOptions) => {
2524
+ cancelledLoadKeys.add(getLoadKey(options))
2525
+ runtime.unloadSubset(options, sourceResult.unloadSubset)
2526
+ },
2527
+ }
2528
+ },
2529
+ }
2530
+ }
2531
+
2532
+ function createLoopbackSyncConfig<
2533
+ T extends object,
2534
+ TKey extends string | number,
2535
+ >(runtime: PersistedCollectionRuntime<T, TKey>): SyncConfig<T, TKey> {
2536
+ return {
2537
+ sync: (params) => {
2538
+ runtime.setSyncControls({
2539
+ begin: params.begin,
2540
+ write: params.write as SyncControlFns<T, TKey>[`write`],
2541
+ commit: params.commit,
2542
+ truncate: params.truncate,
2543
+ metadata: params.metadata ?? null,
2544
+ })
2545
+ runtime.setCollection(
2546
+ params.collection as Collection<T, TKey, PersistedCollectionUtils>,
2547
+ )
2548
+
2549
+ void runtime
2550
+ .ensureStarted()
2551
+ .then(() => {
2552
+ params.markReady()
2553
+ })
2554
+ .catch((error) => {
2555
+ console.warn(`Failed persisted loopback startup:`, error)
2556
+ params.markReady()
2557
+ })
2558
+
2559
+ return {
2560
+ cleanup: () => {
2561
+ runtime.cleanup()
2562
+ runtime.clearSyncControls()
2563
+ },
2564
+ loadSubset: (options: LoadSubsetOptions) => runtime.loadSubset(options),
2565
+ unloadSubset: (options: LoadSubsetOptions) =>
2566
+ runtime.unloadSubset(options),
2567
+ }
2568
+ },
2569
+ getSyncMetadata: () => ({
2570
+ source: `persisted-phase-2-loopback`,
2571
+ }),
2572
+ }
2573
+ }
2574
+
2575
+ export function persistedCollectionOptions<
2576
+ T extends object,
2577
+ TKey extends string | number,
2578
+ TSchema extends StandardSchemaV1 = never,
2579
+ TUtils extends UtilsRecord = UtilsRecord,
2580
+ >(
2581
+ options: PersistedSyncWrappedOptions<T, TKey, TSchema, TUtils>,
2582
+ ): PersistedSyncOptionsResult<T, TKey, TSchema, TUtils>
2583
+
2584
+ export function persistedCollectionOptions<
2585
+ T extends object,
2586
+ TKey extends string | number,
2587
+ TSchema extends StandardSchemaV1 = never,
2588
+ TUtils extends UtilsRecord = UtilsRecord,
2589
+ >(
2590
+ options: PersistedLocalOnlyOptions<T, TKey, TSchema, TUtils>,
2591
+ ): PersistedLocalOnlyOptionsResult<T, TKey, TSchema, TUtils>
2592
+
2593
+ export function persistedCollectionOptions<
2594
+ T extends object,
2595
+ TKey extends string | number,
2596
+ TSchema extends StandardSchemaV1 = never,
2597
+ TUtils extends UtilsRecord = UtilsRecord,
2598
+ >(
2599
+ options:
2600
+ | PersistedSyncWrappedOptions<T, TKey, TSchema, TUtils>
2601
+ | PersistedLocalOnlyOptions<T, TKey, TSchema, TUtils>,
2602
+ ):
2603
+ | PersistedSyncOptionsResult<T, TKey, TSchema, TUtils>
2604
+ | PersistedLocalOnlyOptionsResult<T, TKey, TSchema, TUtils> {
2605
+ if (!isRecord(options.persistence)) {
2606
+ throw new InvalidPersistedCollectionConfigError(
2607
+ `persistedCollectionOptions requires a persistence adapter`,
2608
+ )
2609
+ }
2610
+
2611
+ if (hasOwnSyncKey(options)) {
2612
+ if (!isValidSyncConfig(options.sync)) {
2613
+ throw new InvalidSyncConfigError(
2614
+ `when the "sync" key is present it must provide a callable sync function`,
2615
+ )
2616
+ }
2617
+
2618
+ const { schemaVersion, ...syncOptions } = options
2619
+ const collectionId =
2620
+ syncOptions.id ?? `persisted-collection:${crypto.randomUUID()}`
2621
+ const persistence = resolvePersistenceForCollection(
2622
+ syncOptions.persistence,
2623
+ {
2624
+ collectionId,
2625
+ mode: `sync-present`,
2626
+ schemaVersion,
2627
+ },
2628
+ )
2629
+
2630
+ const runtime = new PersistedCollectionRuntime<T, TKey>(
2631
+ `sync-present`,
2632
+ collectionId,
2633
+ persistence,
2634
+ syncOptions.syncMode ?? `eager`,
2635
+ collectionId,
2636
+ )
2637
+
2638
+ return {
2639
+ ...syncOptions,
2640
+ id: collectionId,
2641
+ sync: createWrappedSyncConfig<T, TKey>(syncOptions.sync, runtime),
2642
+ persistence,
2643
+ }
2644
+ }
2645
+
2646
+ const { schemaVersion, ...localOnlyOptions } = options
2647
+ const collectionId =
2648
+ localOnlyOptions.id ?? `persisted-collection:${crypto.randomUUID()}`
2649
+ const persistence = resolvePersistenceForCollection(
2650
+ localOnlyOptions.persistence,
2651
+ {
2652
+ collectionId,
2653
+ mode: `sync-absent`,
2654
+ schemaVersion,
2655
+ },
2656
+ )
2657
+ const runtime = new PersistedCollectionRuntime<T, TKey>(
2658
+ `sync-absent`,
2659
+ collectionId,
2660
+ persistence,
2661
+ localOnlyOptions.syncMode ?? `eager`,
2662
+ localOnlyOptions.id ?? DEFAULT_DB_NAME,
2663
+ )
2664
+
2665
+ const wrappedOnInsert = async (
2666
+ params: InsertMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2667
+ ) => {
2668
+ const handlerResult = localOnlyOptions.onInsert
2669
+ ? await localOnlyOptions.onInsert(
2670
+ params as unknown as InsertMutationFnParams<T, TKey, TUtils>,
2671
+ )
2672
+ : undefined
2673
+
2674
+ await runtime.persistAndConfirmCollectionMutations(
2675
+ params.transaction.mutations as Array<PendingMutation<T>>,
2676
+ )
2677
+
2678
+ return handlerResult ?? {}
2679
+ }
2680
+
2681
+ const wrappedOnUpdate = async (
2682
+ params: UpdateMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2683
+ ) => {
2684
+ const handlerResult = localOnlyOptions.onUpdate
2685
+ ? await localOnlyOptions.onUpdate(
2686
+ params as unknown as UpdateMutationFnParams<T, TKey, TUtils>,
2687
+ )
2688
+ : undefined
2689
+
2690
+ await runtime.persistAndConfirmCollectionMutations(
2691
+ params.transaction.mutations as Array<PendingMutation<T>>,
2692
+ )
2693
+
2694
+ return handlerResult ?? {}
2695
+ }
2696
+
2697
+ const wrappedOnDelete = async (
2698
+ params: DeleteMutationFnParams<T, TKey, TUtils & PersistedCollectionUtils>,
2699
+ ) => {
2700
+ const handlerResult = localOnlyOptions.onDelete
2701
+ ? await localOnlyOptions.onDelete(
2702
+ params as unknown as DeleteMutationFnParams<T, TKey, TUtils>,
2703
+ )
2704
+ : undefined
2705
+
2706
+ await runtime.persistAndConfirmCollectionMutations(
2707
+ params.transaction.mutations as Array<PendingMutation<T>>,
2708
+ )
2709
+
2710
+ return handlerResult ?? {}
2711
+ }
2712
+
2713
+ const acceptMutations = async (transaction: {
2714
+ mutations: Array<PendingMutation<Record<string, unknown>>>
2715
+ }) => {
2716
+ await runtime.acceptTransactionMutations(transaction)
2717
+ }
2718
+
2719
+ const persistedUtils: PersistedCollectionUtils = {
2720
+ acceptMutations,
2721
+ getLeadershipState: () => runtime.getLeadershipState(),
2722
+ forceReloadSubset: (subsetOptions: LoadSubsetOptions) =>
2723
+ runtime.forceReloadSubset(subsetOptions),
2724
+ }
2725
+
2726
+ const mergedUtils = {
2727
+ ...(localOnlyOptions.utils ?? ({} as TUtils)),
2728
+ ...persistedUtils,
2729
+ }
2730
+
2731
+ return {
2732
+ ...localOnlyOptions,
2733
+ id: collectionId,
2734
+ persistence,
2735
+ sync: createLoopbackSyncConfig(runtime),
2736
+ onInsert: wrappedOnInsert,
2737
+ onUpdate: wrappedOnUpdate,
2738
+ onDelete: wrappedOnDelete,
2739
+ utils: mergedUtils,
2740
+ startSync: true,
2741
+ gcTime: localOnlyOptions.gcTime ?? 0,
2742
+ }
2743
+ }
2744
+
2745
+ export function encodePersistedStorageKey(key: string | number): string {
2746
+ if (typeof key === `number`) {
2747
+ if (!Number.isFinite(key)) {
2748
+ throw new InvalidPersistedStorageKeyError(key)
2749
+ }
2750
+
2751
+ if (Object.is(key, -0)) {
2752
+ return `n:-0`
2753
+ }
2754
+
2755
+ return `n:${key}`
2756
+ }
2757
+
2758
+ return `s:${key}`
2759
+ }
2760
+
2761
+ export function decodePersistedStorageKey(encoded: string): string | number {
2762
+ if (encoded === `n:-0`) {
2763
+ return -0
2764
+ }
2765
+
2766
+ if (encoded.startsWith(`n:`)) {
2767
+ return Number(encoded.slice(2))
2768
+ }
2769
+
2770
+ if (encoded.startsWith(`s:`)) {
2771
+ return encoded.slice(2)
2772
+ }
2773
+
2774
+ throw new InvalidPersistedStorageKeyEncodingError(encoded)
2775
+ }
2776
+
2777
+ const PERSISTED_TABLE_NAME_ALPHABET = `abcdefghijklmnopqrstuvwxyz234567`
2778
+
2779
+ function hashCollectionId(collectionId: string): number {
2780
+ let hash = 0x811c9dc5
2781
+
2782
+ for (let index = 0; index < collectionId.length; index++) {
2783
+ hash ^= collectionId.charCodeAt(index)
2784
+ hash = Math.imul(hash, 0x01000193)
2785
+ }
2786
+
2787
+ return hash >>> 0
2788
+ }
2789
+
2790
+ function toBase32(input: number): string {
2791
+ if (input === 0) {
2792
+ return PERSISTED_TABLE_NAME_ALPHABET.charAt(0)
2793
+ }
2794
+
2795
+ let value = input >>> 0
2796
+ let output = ``
2797
+
2798
+ while (value > 0) {
2799
+ output = `${PERSISTED_TABLE_NAME_ALPHABET.charAt(value % 32)}${output}`
2800
+ value = Math.floor(value / 32)
2801
+ }
2802
+
2803
+ return output
2804
+ }
2805
+
2806
+ export function createPersistedTableName(
2807
+ collectionId: string,
2808
+ prefix: `c` | `t` = `c`,
2809
+ ): string {
2810
+ if (!collectionId) {
2811
+ throw new InvalidPersistedCollectionConfigError(
2812
+ `collectionId is required to derive a persisted table name`,
2813
+ )
2814
+ }
2815
+
2816
+ const hashPart = toBase32(hashCollectionId(collectionId))
2817
+ const lengthPart = collectionId.length.toString(36)
2818
+
2819
+ return `${prefix}_${hashPart}_${lengthPart}`
2820
+ }