@loro-dev/flock-sqlite 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -29,8 +29,9 @@ import type {
29
29
  ScanOptions,
30
30
  ScanRow,
31
31
  Value,
32
- VersionVector,
32
+ VersionVector as VersionVectorType,
33
33
  VersionVectorEntry,
34
+ EntryInfo,
34
35
  } from "./types";
35
36
 
36
37
  type ClockRow = {
@@ -60,9 +61,20 @@ type PutOperation = {
60
61
  eventSink?: Array<{ key: KeyPart[]; payload: ExportPayload; source: string }>;
61
62
  };
62
63
 
64
+ type EncodableVersionVectorEntry = {
65
+ peer: string;
66
+ peerBytes: Uint8Array;
67
+ timestamp: number;
68
+ counter: number;
69
+ };
70
+
71
+ export interface VersionVector extends VersionVectorType {}
72
+
63
73
  const textEncoder = new TextEncoder();
64
- const structuredCloneFn: (<T>(value: T) => T) | undefined =
65
- (globalThis as typeof globalThis & { structuredClone?: <T>(value: T) => T }).structuredClone;
74
+ const textDecoder = new TextDecoder();
75
+ const structuredCloneFn: (<T>(value: T) => T) | undefined = (
76
+ globalThis as typeof globalThis & { structuredClone?: <T>(value: T) => T }
77
+ ).structuredClone;
66
78
 
67
79
  function utf8ByteLength(value: string): number {
68
80
  return textEncoder.encode(value).length;
@@ -74,11 +86,18 @@ function isValidPeerId(peerId: unknown): peerId is string {
74
86
 
75
87
  function createRandomPeerId(): string {
76
88
  const id = new Uint8Array(32);
77
- const cryptoAny = typeof crypto !== "undefined" ? (crypto as any) : undefined;
78
- if (cryptoAny?.getRandomValues) {
79
- cryptoAny.getRandomValues(id);
80
- } else if (cryptoAny?.randomBytes) {
81
- const buf: Uint8Array = cryptoAny.randomBytes(32);
89
+ type CryptoLike = {
90
+ getRandomValues?: (buffer: Uint8Array) => Uint8Array;
91
+ randomBytes?: (len: number) => Uint8Array;
92
+ };
93
+ const cryptoLike: CryptoLike | undefined =
94
+ typeof crypto !== "undefined"
95
+ ? (crypto as unknown as CryptoLike)
96
+ : undefined;
97
+ if (cryptoLike?.getRandomValues) {
98
+ cryptoLike.getRandomValues(id);
99
+ } else if (cryptoLike?.randomBytes) {
100
+ const buf: Uint8Array = cryptoLike.randomBytes(32);
82
101
  id.set(buf);
83
102
  } else {
84
103
  for (let i = 0; i < 32; i += 1) {
@@ -115,7 +134,15 @@ function cloneMetadata(metadata: unknown): MetadataMap | undefined {
115
134
  return cloneJson(metadata as MetadataMap);
116
135
  }
117
136
 
118
- function assignPayload(target: ExportPayload, source?: ExportPayload | void): void {
137
+ function normalizeMetadataMap(metadata: unknown): MetadataMap {
138
+ const cloned = cloneMetadata(metadata);
139
+ return cloned ?? {};
140
+ }
141
+
142
+ function assignPayload(
143
+ target: ExportPayload,
144
+ source?: ExportPayload | void,
145
+ ): void {
119
146
  if (!source || typeof source !== "object") {
120
147
  return;
121
148
  }
@@ -134,12 +161,253 @@ function clonePayload(payload: ExportPayload | undefined): ExportPayload {
134
161
  return result;
135
162
  }
136
163
 
137
- function mergePayload(base: ExportPayload, update?: ExportPayload | void): ExportPayload {
164
+ function mergePayload(
165
+ base: ExportPayload,
166
+ update?: ExportPayload | void,
167
+ ): ExportPayload {
138
168
  const result = clonePayload(base);
139
169
  assignPayload(result, update);
140
170
  return result;
141
171
  }
142
172
 
173
+ function comparePeerBytes(a: Uint8Array, b: Uint8Array): number {
174
+ if (a === b) {
175
+ return 0;
176
+ }
177
+ const limit = Math.min(a.length, b.length);
178
+ for (let i = 0; i < limit; i += 1) {
179
+ const diff = a[i] - b[i];
180
+ if (diff !== 0) {
181
+ return diff;
182
+ }
183
+ }
184
+ return a.length - b.length;
185
+ }
186
+
187
+ function collectEncodableVersionVectorEntries(
188
+ vv?: VersionVector,
189
+ ): EncodableVersionVectorEntry[] {
190
+ if (!vv || typeof vv !== "object") {
191
+ return [];
192
+ }
193
+ const entries: EncodableVersionVectorEntry[] = [];
194
+ for (const [peer, entry] of Object.entries(vv)) {
195
+ if (!entry || !isValidPeerId(peer)) {
196
+ continue;
197
+ }
198
+ const { physicalTime, logicalCounter } = entry;
199
+ if (
200
+ typeof physicalTime !== "number" ||
201
+ !Number.isFinite(physicalTime) ||
202
+ typeof logicalCounter !== "number" ||
203
+ !Number.isFinite(logicalCounter)
204
+ ) {
205
+ continue;
206
+ }
207
+ const peerBytes = textEncoder.encode(peer);
208
+ entries.push({
209
+ peer,
210
+ peerBytes,
211
+ timestamp: Math.trunc(physicalTime),
212
+ counter: Math.max(0, Math.trunc(logicalCounter)),
213
+ });
214
+ }
215
+ entries.sort((a, b) => {
216
+ if (a.timestamp !== b.timestamp) {
217
+ return a.timestamp - b.timestamp;
218
+ }
219
+ const peerCmp = comparePeerBytes(a.peerBytes, b.peerBytes);
220
+ if (peerCmp !== 0) {
221
+ return peerCmp;
222
+ }
223
+ return a.counter - b.counter;
224
+ });
225
+ return entries;
226
+ }
227
+
228
+ function writeUnsignedLeb128(value: number, target: number[]): void {
229
+ if (!Number.isFinite(value) || value < 0) {
230
+ throw new TypeError("leb128 values must be finite and non-negative");
231
+ }
232
+ let remaining = Math.trunc(value);
233
+ if (remaining === 0) {
234
+ target.push(0);
235
+ return;
236
+ }
237
+ while (remaining > 0) {
238
+ const byte = remaining % 0x80;
239
+ remaining = Math.floor(remaining / 0x80);
240
+ target.push(remaining > 0 ? byte | 0x80 : byte);
241
+ }
242
+ }
243
+
244
+ function writeVarStringBytes(bytes: Uint8Array, target: number[]): void {
245
+ writeUnsignedLeb128(bytes.length, target);
246
+ for (let i = 0; i < bytes.length; i += 1) {
247
+ target.push(bytes[i]);
248
+ }
249
+ }
250
+
251
+ const VERSION_VECTOR_MAGIC = new Uint8Array([86, 69, 86, 69]); // "VEVE"
252
+
253
+ function encodeVersionVectorBinary(vv?: VersionVector): Uint8Array {
254
+ const entries = collectEncodableVersionVectorEntries(vv);
255
+ const buffer: number[] = Array.from(VERSION_VECTOR_MAGIC);
256
+ if (entries.length === 0) {
257
+ return Uint8Array.from(buffer);
258
+ }
259
+
260
+ let lastTimestamp = 0;
261
+ for (let i = 0; i < entries.length; i += 1) {
262
+ const entry = entries[i];
263
+ if (entry.timestamp < 0) {
264
+ throw new TypeError("timestamp must be non-negative");
265
+ }
266
+ if (i === 0) {
267
+ writeUnsignedLeb128(entry.timestamp, buffer);
268
+ lastTimestamp = entry.timestamp;
269
+ } else {
270
+ const delta = entry.timestamp - lastTimestamp;
271
+ if (delta < 0) {
272
+ throw new TypeError("version vector timestamps must be non-decreasing");
273
+ }
274
+ writeUnsignedLeb128(delta, buffer);
275
+ lastTimestamp = entry.timestamp;
276
+ }
277
+
278
+ writeUnsignedLeb128(entry.counter, buffer);
279
+ writeVarStringBytes(entry.peerBytes, buffer);
280
+ }
281
+
282
+ return Uint8Array.from(buffer);
283
+ }
284
+
285
+ function decodeUnsignedLeb128(
286
+ bytes: Uint8Array,
287
+ offset: number,
288
+ ): [number, number] {
289
+ let result = 0;
290
+ let multiplier = 1;
291
+ let consumed = 0;
292
+ while (offset + consumed < bytes.length) {
293
+ const byte = bytes[offset + consumed];
294
+ consumed += 1;
295
+ // Use arithmetic instead of bitwise operations to avoid 32-bit overflow.
296
+ // JavaScript bitwise operators convert to 32-bit signed integers,
297
+ // which breaks for values >= 2^31.
298
+ result += (byte & 0x7f) * multiplier;
299
+ if ((byte & 0x80) === 0) {
300
+ break;
301
+ }
302
+ multiplier *= 128;
303
+ }
304
+ return [result, consumed];
305
+ }
306
+
307
+ function decodeVarString(bytes: Uint8Array, offset: number): [string, number] {
308
+ const [length, used] = decodeUnsignedLeb128(bytes, offset);
309
+ const start = offset + used;
310
+ const end = start + length;
311
+ if (end > bytes.length) {
312
+ throw new TypeError("varString length exceeds buffer");
313
+ }
314
+ const slice = bytes.subarray(start, end);
315
+ return [textDecoder.decode(slice), used + length];
316
+ }
317
+
318
+ function hasMagic(bytes: Uint8Array): boolean {
319
+ return (
320
+ bytes.length >= 4 &&
321
+ bytes[0] === VERSION_VECTOR_MAGIC[0] &&
322
+ bytes[1] === VERSION_VECTOR_MAGIC[1] &&
323
+ bytes[2] === VERSION_VECTOR_MAGIC[2] &&
324
+ bytes[3] === VERSION_VECTOR_MAGIC[3]
325
+ );
326
+ }
327
+
328
+ function decodeLegacyVersionVector(bytes: Uint8Array): VersionVector {
329
+ let offset = 0;
330
+ const [count, usedCount] = decodeUnsignedLeb128(bytes, offset);
331
+ offset += usedCount;
332
+ const [baseTimestamp, usedBase] = decodeUnsignedLeb128(bytes, offset);
333
+ offset += usedBase;
334
+ const vv: VersionVector = {};
335
+ for (let i = 0; i < count; i += 1) {
336
+ const [peer, usedPeer] = decodeVarString(bytes, offset);
337
+ offset += usedPeer;
338
+ if (!isValidPeerId(peer)) {
339
+ throw new TypeError("invalid peer id in encoded version vector");
340
+ }
341
+ const [delta, usedDelta] = decodeUnsignedLeb128(bytes, offset);
342
+ offset += usedDelta;
343
+ const [counter, usedCounter] = decodeUnsignedLeb128(bytes, offset);
344
+ offset += usedCounter;
345
+ vv[peer] = {
346
+ physicalTime: baseTimestamp + delta,
347
+ logicalCounter: counter,
348
+ };
349
+ }
350
+ return vv;
351
+ }
352
+
353
+ function decodeNewVersionVector(bytes: Uint8Array): VersionVector {
354
+ let offset = 4;
355
+ const vv: VersionVector = {};
356
+ if (offset === bytes.length) {
357
+ return vv;
358
+ }
359
+
360
+ const [firstTimestamp, usedTs] = decodeUnsignedLeb128(bytes, offset);
361
+ offset += usedTs;
362
+ const [firstCounter, usedCounter] = decodeUnsignedLeb128(bytes, offset);
363
+ offset += usedCounter;
364
+ const [firstPeer, usedPeer] = decodeVarString(bytes, offset);
365
+ offset += usedPeer;
366
+ if (!isValidPeerId(firstPeer)) {
367
+ throw new TypeError("invalid peer id in encoded version vector");
368
+ }
369
+ vv[firstPeer] = {
370
+ physicalTime: firstTimestamp,
371
+ logicalCounter: firstCounter,
372
+ };
373
+
374
+ let lastTimestamp = firstTimestamp;
375
+ while (offset < bytes.length) {
376
+ const [delta, usedDelta] = decodeUnsignedLeb128(bytes, offset);
377
+ offset += usedDelta;
378
+ const [counter, usedCtr] = decodeUnsignedLeb128(bytes, offset);
379
+ offset += usedCtr;
380
+ const [peer, usedPeerLen] = decodeVarString(bytes, offset);
381
+ offset += usedPeerLen;
382
+ if (!isValidPeerId(peer)) {
383
+ throw new TypeError("invalid peer id in encoded version vector");
384
+ }
385
+ const timestamp = lastTimestamp + delta;
386
+ if (timestamp < lastTimestamp) {
387
+ throw new TypeError("version vector timestamps must be non-decreasing");
388
+ }
389
+ vv[peer] = { physicalTime: timestamp, logicalCounter: counter };
390
+ lastTimestamp = timestamp;
391
+ }
392
+
393
+ return vv;
394
+ }
395
+
396
+ function decodeVersionVectorBinary(bytes: Uint8Array): VersionVector {
397
+ if (hasMagic(bytes)) {
398
+ return decodeNewVersionVector(bytes);
399
+ }
400
+ return decodeLegacyVersionVector(bytes);
401
+ }
402
+
403
+ export function encodeVersionVector(vector: VersionVector): Uint8Array {
404
+ return encodeVersionVectorBinary(vector);
405
+ }
406
+
407
+ export function decodeVersionVector(bytes: Uint8Array): VersionVector {
408
+ return decodeVersionVectorBinary(bytes);
409
+ }
410
+
143
411
  function buildRecord(clock: EntryClock, payload: ExportPayload): ExportRecord {
144
412
  const record: ExportRecord = {
145
413
  c: formatClock(clock),
@@ -154,7 +422,10 @@ function buildRecord(clock: EntryClock, payload: ExportPayload): ExportRecord {
154
422
  return record;
155
423
  }
156
424
 
157
- function normalizeImportDecision(decision: ImportDecision): { accept: boolean; reason?: string } {
425
+ function normalizeImportDecision(decision: ImportDecision): {
426
+ accept: boolean;
427
+ reason?: string;
428
+ } {
158
429
  if (!decision || typeof decision !== "object") {
159
430
  return { accept: true };
160
431
  }
@@ -167,7 +438,9 @@ function normalizeImportDecision(decision: ImportDecision): { accept: boolean; r
167
438
  return { accept: true };
168
439
  }
169
440
 
170
- function isExportOptions(arg: VersionVector | ExportOptions | undefined): arg is ExportOptions {
441
+ function isExportOptions(
442
+ arg: VersionVector | ExportOptions | undefined,
443
+ ): arg is ExportOptions {
171
444
  return (
172
445
  typeof arg === "object" &&
173
446
  arg !== null &&
@@ -178,8 +451,14 @@ function isExportOptions(arg: VersionVector | ExportOptions | undefined): arg is
178
451
  );
179
452
  }
180
453
 
181
- function isImportOptions(arg: ExportBundle | ImportOptions): arg is ImportOptions {
182
- return typeof arg === "object" && arg !== null && Object.prototype.hasOwnProperty.call(arg, "bundle");
454
+ function isImportOptions(
455
+ arg: ExportBundle | ImportOptions,
456
+ ): arg is ImportOptions {
457
+ return (
458
+ typeof arg === "object" &&
459
+ arg !== null &&
460
+ Object.prototype.hasOwnProperty.call(arg, "bundle")
461
+ );
183
462
  }
184
463
 
185
464
  function parseMetadata(json: string | null): MetadataMap | undefined {
@@ -219,6 +498,31 @@ function parseClockString(raw: string): EntryClock {
219
498
  };
220
499
  }
221
500
 
501
+ function normalizeRowClock(
502
+ physical: unknown,
503
+ logical: unknown,
504
+ peer: unknown,
505
+ ): EntryClock | undefined {
506
+ if (
507
+ typeof physical !== "number" ||
508
+ typeof logical !== "number" ||
509
+ typeof peer !== "string"
510
+ ) {
511
+ return undefined;
512
+ }
513
+ if (!isValidPeerId(peer)) {
514
+ return undefined;
515
+ }
516
+ if (!Number.isFinite(physical) || !Number.isFinite(logical)) {
517
+ return undefined;
518
+ }
519
+ return {
520
+ physicalTime: physical,
521
+ logicalCounter: Math.trunc(logical),
522
+ peerId: peer,
523
+ };
524
+ }
525
+
222
526
  function formatClock(clock: EntryClock): string {
223
527
  return `${clock.physicalTime},${clock.logicalCounter},${clock.peerId}`;
224
528
  }
@@ -236,7 +540,9 @@ function compareClock(a: EntryClock, b: EntryClock): number {
236
540
  return a.peerId > b.peerId ? 1 : -1;
237
541
  }
238
542
 
239
- function normalizeVersionEntry(entry?: VersionVectorEntry): VersionVectorEntry | undefined {
543
+ function normalizeVersionEntry(
544
+ entry?: VersionVectorEntry,
545
+ ): VersionVectorEntry | undefined {
240
546
  if (!entry) return undefined;
241
547
  const { physicalTime, logicalCounter } = entry;
242
548
  if (!Number.isFinite(physicalTime) || !Number.isFinite(logicalCounter)) {
@@ -296,7 +602,9 @@ function normalizeTablePrefix(prefix?: string): string {
296
602
  throw new TypeError("tablePrefix must be a string");
297
603
  }
298
604
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) {
299
- throw new TypeError("tablePrefix must start with a letter/underscore and use only letters, digits, or underscores");
605
+ throw new TypeError(
606
+ "tablePrefix must start with a letter/underscore and use only letters, digits, or underscores",
607
+ );
300
608
  }
301
609
  return prefix;
302
610
  }
@@ -319,14 +627,38 @@ export class FlockSQLite {
319
627
  private maxHlc: { physicalTime: number; logicalCounter: number };
320
628
  private listeners: Set<EventListener>;
321
629
  private tables: TableNames;
322
-
323
- private constructor(db: UniStoreConnection, peerId: string, vv: Map<string, VersionVectorEntry>, maxHlc: { physicalTime: number; logicalCounter: number }, tables: TableNames) {
630
+ /** Transaction state: undefined when not in transaction, array when accumulating */
631
+ private txnEventSink:
632
+ | Array<{ key: KeyPart[]; payload: ExportPayload; source: string }>
633
+ | undefined;
634
+ /** Debounce state for autoDebounceCommit */
635
+ private debounceState:
636
+ | {
637
+ timeout: number;
638
+ timerId: ReturnType<typeof setTimeout> | undefined;
639
+ pendingEvents: Array<{
640
+ key: KeyPart[];
641
+ payload: ExportPayload;
642
+ source: string;
643
+ }>;
644
+ }
645
+ | undefined;
646
+
647
+ private constructor(
648
+ db: UniStoreConnection,
649
+ peerId: string,
650
+ vv: Map<string, VersionVectorEntry>,
651
+ maxHlc: { physicalTime: number; logicalCounter: number },
652
+ tables: TableNames,
653
+ ) {
324
654
  this.db = db;
325
655
  this.peerIdValue = peerId;
326
656
  this.vv = vv;
327
657
  this.maxHlc = maxHlc;
328
658
  this.listeners = new Set();
329
659
  this.tables = tables;
660
+ this.txnEventSink = undefined;
661
+ this.debounceState = undefined;
330
662
  }
331
663
 
332
664
  static async open(options: FlockSQLiteOptions): Promise<FlockSQLite> {
@@ -339,17 +671,36 @@ export class FlockSQLite {
339
671
  return new FlockSQLite(db, peerId, vv, maxHlc, tables);
340
672
  }
341
673
 
342
- static async fromJson(options: FlockSQLiteOptions & { bundle: ExportBundle }): Promise<FlockSQLite> {
674
+ static async fromJson(
675
+ options: FlockSQLiteOptions & { bundle: ExportBundle },
676
+ ): Promise<FlockSQLite> {
343
677
  const flock = await FlockSQLite.open(options);
344
678
  await flock.importJson(options.bundle);
345
679
  return flock;
346
680
  }
347
681
 
348
682
  async close(): Promise<void> {
683
+ // Commit any pending debounced events
684
+ if (this.debounceState !== undefined) {
685
+ this.disableAutoDebounceCommit();
686
+ }
687
+
688
+ // Commit any transaction events (edge case: close during txn)
689
+ if (this.txnEventSink !== undefined) {
690
+ const pending = this.txnEventSink;
691
+ this.txnEventSink = undefined;
692
+ if (pending.length > 0) {
693
+ this.emitEvents("local", pending);
694
+ }
695
+ }
696
+
349
697
  await this.db.close();
350
698
  }
351
699
 
352
- private static async ensureSchema(db: UniStoreConnection, tables: TableNames): Promise<void> {
700
+ private static async ensureSchema(
701
+ db: UniStoreConnection,
702
+ tables: TableNames,
703
+ ): Promise<void> {
353
704
  await db.exec(`
354
705
  CREATE TABLE IF NOT EXISTS ${tables.kv} (
355
706
  key BLOB PRIMARY KEY,
@@ -382,9 +733,15 @@ export class FlockSQLite {
382
733
  );
383
734
  }
384
735
 
385
- private static async resolvePeerId(db: UniStoreConnection, tables: TableNames, provided?: string): Promise<string> {
736
+ private static async resolvePeerId(
737
+ db: UniStoreConnection,
738
+ tables: TableNames,
739
+ provided?: string,
740
+ ): Promise<string> {
386
741
  const normalized = normalizePeerId(provided);
387
- const rows = await db.query<{ peer_id: string }>(`SELECT peer_id FROM ${tables.meta} LIMIT 1`);
742
+ const rows = await db.query<{ peer_id: string }>(
743
+ `SELECT peer_id FROM ${tables.meta} LIMIT 1`,
744
+ );
388
745
  if (rows.length > 0 && typeof rows[0]?.peer_id === "string") {
389
746
  const existing = rows[0].peer_id;
390
747
  if (provided && existing !== normalized) {
@@ -394,11 +751,19 @@ export class FlockSQLite {
394
751
  return normalizePeerId(existing);
395
752
  }
396
753
  await db.exec(`DELETE FROM ${tables.meta}`);
397
- await db.run(`INSERT INTO ${tables.meta}(peer_id) VALUES (?)`, [normalized]);
754
+ await db.run(`INSERT INTO ${tables.meta}(peer_id) VALUES (?)`, [
755
+ normalized,
756
+ ]);
398
757
  return normalized;
399
758
  }
400
759
 
401
- private static async loadVersionState(db: UniStoreConnection, tables: TableNames): Promise<{ vv: Map<string, VersionVectorEntry>; maxHlc: { physicalTime: number; logicalCounter: number } }> {
760
+ private static async loadVersionState(
761
+ db: UniStoreConnection,
762
+ tables: TableNames,
763
+ ): Promise<{
764
+ vv: Map<string, VersionVectorEntry>;
765
+ maxHlc: { physicalTime: number; logicalCounter: number };
766
+ }> {
402
767
  const vv = new Map<string, VersionVectorEntry>();
403
768
  const rows = await db.query<ClockRow>(
404
769
  `SELECT peer, MAX(physical) AS physical, MAX(logical) AS logical FROM ${tables.kv} GROUP BY peer`,
@@ -419,14 +784,20 @@ export class FlockSQLite {
419
784
  const first = maxRow[0];
420
785
  const maxHlc =
421
786
  first && Number.isFinite(first.physical) && Number.isFinite(first.logical)
422
- ? { physicalTime: Number(first.physical), logicalCounter: Number(first.logical) }
787
+ ? {
788
+ physicalTime: Number(first.physical),
789
+ logicalCounter: Number(first.logical),
790
+ }
423
791
  : { physicalTime: 0, logicalCounter: 0 };
424
792
  return { vv, maxHlc };
425
793
  }
426
794
 
427
795
  private bumpVersion(clock: EntryClock): void {
428
796
  const current = this.vv.get(clock.peerId);
429
- if (!current || compareClock(clock, { ...current, peerId: clock.peerId }) > 0) {
797
+ if (
798
+ !current ||
799
+ compareClock(clock, { ...current, peerId: clock.peerId }) > 0
800
+ ) {
430
801
  this.vv.set(clock.peerId, {
431
802
  physicalTime: clock.physicalTime,
432
803
  logicalCounter: clock.logicalCounter,
@@ -437,7 +808,10 @@ export class FlockSQLite {
437
808
  (this.maxHlc.physicalTime === clock.physicalTime &&
438
809
  this.maxHlc.logicalCounter < clock.logicalCounter)
439
810
  ) {
440
- this.maxHlc = { physicalTime: clock.physicalTime, logicalCounter: clock.logicalCounter };
811
+ this.maxHlc = {
812
+ physicalTime: clock.physicalTime,
813
+ logicalCounter: clock.logicalCounter,
814
+ };
441
815
  }
442
816
  }
443
817
 
@@ -451,16 +825,22 @@ export class FlockSQLite {
451
825
  } else {
452
826
  logical = logical + 1;
453
827
  }
454
- return { physicalTime: physical, logicalCounter: logical, peerId: this.peerIdValue };
828
+ return {
829
+ physicalTime: physical,
830
+ logicalCounter: logical,
831
+ peerId: this.peerIdValue,
832
+ };
455
833
  }
456
834
 
457
835
  private async applyOperation(operation: PutOperation): Promise<boolean> {
458
836
  const keyBytes = encodeKeyParts(operation.key);
459
- const clock = operation.clock ?? this.allocateClock(operation.now);
460
837
  const payload = mergePayload(operation.payload, {});
461
- const dataJson = payload.data === undefined ? null : JSON.stringify(payload.data);
462
- const metadataJson = payload.metadata === undefined ? null : JSON.stringify(payload.metadata);
838
+ const dataJson =
839
+ payload.data === undefined ? null : JSON.stringify(payload.data);
840
+ const metadataJson =
841
+ payload.metadata === undefined ? null : JSON.stringify(payload.metadata);
463
842
  let applied = false;
843
+ let usedClock: EntryClock | undefined;
464
844
 
465
845
  await this.db.asyncTransaction(async (tx) => {
466
846
  const existingRows = await tx.query<KvRow>(
@@ -469,12 +849,6 @@ export class FlockSQLite {
469
849
  );
470
850
  if (existingRows.length > 0) {
471
851
  const existing = existingRows[0];
472
- const existingClock: EntryClock = {
473
- physicalTime: Number(existing.physical ?? 0),
474
- logicalCounter: Number(existing.logical ?? 0),
475
- peerId: String(existing.peer ?? ""),
476
- };
477
- const cmp = compareClock(clock, existingClock);
478
852
  const existingData = existing.data ?? null;
479
853
  const existingMeta = existing.metadata ?? null;
480
854
  const samePayload =
@@ -484,10 +858,38 @@ export class FlockSQLite {
484
858
  if (samePayload) {
485
859
  return;
486
860
  }
861
+ } else if (
862
+ operation.skipSameValue &&
863
+ dataJson === null &&
864
+ metadataJson === null
865
+ ) {
866
+ // Key doesn't exist and we're trying to delete - skip
867
+ return;
868
+ }
869
+
870
+ // Now allocate clock only if we're going to apply the operation
871
+ const clock = operation.clock ?? this.allocateClock(operation.now);
872
+ usedClock = clock;
873
+
874
+ if (existingRows.length > 0) {
875
+ const existing = existingRows[0];
876
+ const existingClock: EntryClock = {
877
+ physicalTime: Number(existing.physical ?? 0),
878
+ logicalCounter: Number(existing.logical ?? 0),
879
+ peerId: String(existing.peer ?? ""),
880
+ };
881
+ const cmp = compareClock(clock, existingClock);
487
882
  if (cmp < 0) {
488
883
  await tx.run(
489
884
  `INSERT INTO ${this.tables.overridden}(key, data, metadata, physical, logical, peer) VALUES (?, ?, ?, ?, ?, ?)`,
490
- [keyBytes, dataJson, metadataJson, clock.physicalTime, clock.logicalCounter, clock.peerId],
885
+ [
886
+ keyBytes,
887
+ dataJson,
888
+ metadataJson,
889
+ clock.physicalTime,
890
+ clock.logicalCounter,
891
+ clock.peerId,
892
+ ],
491
893
  );
492
894
  return;
493
895
  }
@@ -516,12 +918,21 @@ export class FlockSQLite {
516
918
  physical=excluded.physical,
517
919
  logical=excluded.logical,
518
920
  peer=excluded.peer`,
519
- [keyBytes, dataJson, metadataJson, clock.physicalTime, clock.logicalCounter, clock.peerId],
921
+ [
922
+ keyBytes,
923
+ dataJson,
924
+ metadataJson,
925
+ clock.physicalTime,
926
+ clock.logicalCounter,
927
+ clock.peerId,
928
+ ],
520
929
  );
521
930
  applied = true;
522
931
  });
523
932
 
524
- this.bumpVersion(clock);
933
+ if (usedClock) {
934
+ this.bumpVersion(usedClock);
935
+ }
525
936
  if (applied) {
526
937
  const eventPayload = {
527
938
  key: operation.key.slice(),
@@ -529,14 +940,37 @@ export class FlockSQLite {
529
940
  source: operation.source,
530
941
  };
531
942
  if (operation.eventSink) {
943
+ // Explicit event sink provided (e.g., import)
532
944
  operation.eventSink.push(eventPayload);
945
+ } else if (this.txnEventSink) {
946
+ // In transaction: accumulate events
947
+ this.txnEventSink.push(eventPayload);
948
+ } else if (this.debounceState) {
949
+ // Debounce active: accumulate and reset timer
950
+ this.debounceState.pendingEvents.push(eventPayload);
951
+ this.resetDebounceTimer();
533
952
  } else {
953
+ // Normal: emit immediately
534
954
  this.emitEvents(operation.source, [eventPayload]);
535
955
  }
536
956
  }
537
957
  return applied;
538
958
  }
539
959
 
960
+ private resetDebounceTimer(): void {
961
+ if (this.debounceState === undefined) {
962
+ return;
963
+ }
964
+
965
+ if (this.debounceState.timerId !== undefined) {
966
+ clearTimeout(this.debounceState.timerId);
967
+ }
968
+
969
+ this.debounceState.timerId = setTimeout(() => {
970
+ this.commit();
971
+ }, this.debounceState.timeout);
972
+ }
973
+
540
974
  private emitEvents(
541
975
  source: string,
542
976
  events: Array<{ key: KeyPart[]; payload: ExportPayload }>,
@@ -546,12 +980,17 @@ export class FlockSQLite {
546
980
  }
547
981
  const batch: EventBatch = {
548
982
  source,
549
- events: events.map((event): Event => ({
550
- key: cloneJson(event.key),
551
- value: event.payload.data !== undefined ? cloneJson(event.payload.data) : undefined,
552
- metadata: cloneMetadata(event.payload.metadata),
553
- payload: clonePayload(event.payload),
554
- })),
983
+ events: events.map(
984
+ (event): Event => ({
985
+ key: cloneJson(event.key),
986
+ value:
987
+ event.payload.data !== undefined
988
+ ? cloneJson(event.payload.data)
989
+ : undefined,
990
+ metadata: cloneMetadata(event.payload.metadata),
991
+ payload: clonePayload(event.payload),
992
+ }),
993
+ ),
555
994
  };
556
995
  this.listeners.forEach((listener) => {
557
996
  listener(batch);
@@ -568,7 +1007,11 @@ export class FlockSQLite {
568
1007
  });
569
1008
  }
570
1009
 
571
- async putWithMeta(key: KeyPart[], value: Value, options: PutWithMetaOptions = {}): Promise<void> {
1010
+ async putWithMeta(
1011
+ key: KeyPart[],
1012
+ value: Value,
1013
+ options: PutWithMetaOptions = {},
1014
+ ): Promise<void> {
572
1015
  const basePayload: ExportPayload = { data: cloneJson(value) };
573
1016
  if (options.metadata) {
574
1017
  basePayload.metadata = cloneMetadata(options.metadata);
@@ -576,7 +1019,10 @@ export class FlockSQLite {
576
1019
  const hooks = options.hooks?.transform;
577
1020
  if (hooks) {
578
1021
  const working = clonePayload(basePayload);
579
- const transformed = await hooks({ key: key.slice(), now: options.now }, working);
1022
+ const transformed = await hooks(
1023
+ { key: key.slice(), now: options.now },
1024
+ working,
1025
+ );
580
1026
  const finalPayload = mergePayload(basePayload, transformed ?? working);
581
1027
  if (finalPayload.data === undefined) {
582
1028
  throw new TypeError("putWithMeta requires a data value");
@@ -609,6 +1055,76 @@ export class FlockSQLite {
609
1055
  });
610
1056
  }
611
1057
 
1058
+ /**
1059
+ * Force put a value even if it's the same as the current value.
1060
+ * This will refresh the timestamp.
1061
+ */
1062
+ async forcePut(key: KeyPart[], value: Value, now?: number): Promise<void> {
1063
+ await this.applyOperation({
1064
+ key,
1065
+ payload: { data: cloneJson(value) },
1066
+ now,
1067
+ skipSameValue: false,
1068
+ source: "local",
1069
+ });
1070
+ }
1071
+
1072
+ /**
1073
+ * Force put a value with metadata even if it's the same as the current value.
1074
+ * This will refresh the timestamp.
1075
+ */
1076
+ async forcePutWithMeta(
1077
+ key: KeyPart[],
1078
+ value: Value,
1079
+ options: PutWithMetaOptions = {},
1080
+ ): Promise<void> {
1081
+ const basePayload: ExportPayload = { data: cloneJson(value) };
1082
+ if (options.metadata) {
1083
+ basePayload.metadata = cloneMetadata(options.metadata);
1084
+ }
1085
+ const hooks = options.hooks?.transform;
1086
+ if (hooks) {
1087
+ const working = clonePayload(basePayload);
1088
+ const transformed = await hooks(
1089
+ { key: key.slice(), now: options.now },
1090
+ working,
1091
+ );
1092
+ const finalPayload = mergePayload(basePayload, transformed ?? working);
1093
+ if (finalPayload.data === undefined) {
1094
+ throw new TypeError("forcePutWithMeta requires a data value");
1095
+ }
1096
+ await this.applyOperation({
1097
+ key,
1098
+ payload: finalPayload,
1099
+ now: options.now,
1100
+ skipSameValue: false,
1101
+ source: "local",
1102
+ });
1103
+ return;
1104
+ }
1105
+ await this.applyOperation({
1106
+ key,
1107
+ payload: basePayload,
1108
+ now: options.now,
1109
+ skipSameValue: false,
1110
+ source: "local",
1111
+ });
1112
+ }
1113
+
1114
+ /**
1115
+ * Force delete a key even if it's already deleted.
1116
+ * This will refresh the timestamp.
1117
+ */
1118
+ async forceDelete(key: KeyPart[], now?: number): Promise<void> {
1119
+ await this.applyOperation({
1120
+ key,
1121
+ payload: {},
1122
+ now,
1123
+ skipSameValue: false,
1124
+ source: "local",
1125
+ });
1126
+ }
1127
+
612
1128
  async set(key: KeyPart[], value: Value, now?: number): Promise<void> {
613
1129
  await this.put(key, value, now);
614
1130
  }
@@ -616,7 +1132,9 @@ export class FlockSQLite {
616
1132
  async setPeerId(peerId: string): Promise<void> {
617
1133
  const normalized = normalizePeerId(peerId);
618
1134
  await this.db.exec(`DELETE FROM ${this.tables.meta}`);
619
- await this.db.run(`INSERT INTO ${this.tables.meta}(peer_id) VALUES (?)`, [normalized]);
1135
+ await this.db.run(`INSERT INTO ${this.tables.meta}(peer_id) VALUES (?)`, [
1136
+ normalized,
1137
+ ]);
620
1138
  this.peerIdValue = normalized;
621
1139
  }
622
1140
 
@@ -631,6 +1149,38 @@ export class FlockSQLite {
631
1149
  return parseData(row.data);
632
1150
  }
633
1151
 
1152
+ /**
1153
+ * Returns the full entry payload (data, metadata, and clock) for a key.
1154
+ *
1155
+ * Compared to `get`, this preserves tombstone information: a deleted entry
1156
+ * still returns its clock and an empty metadata object with `data` omitted.
1157
+ * Missing or invalid keys return `undefined`. Metadata is cloned and
1158
+ * normalized to `{}` when absent.
1159
+ */
1160
+ async getEntry(key: KeyPart[]): Promise<EntryInfo | undefined> {
1161
+ let keyBytes: Uint8Array;
1162
+ try {
1163
+ keyBytes = encodeKeyParts(key);
1164
+ } catch {
1165
+ return undefined;
1166
+ }
1167
+ const rows = await this.db.query<KvRow>(
1168
+ `SELECT data, metadata, physical, logical, peer FROM ${this.tables.kv} WHERE key = ? LIMIT 1`,
1169
+ [keyBytes],
1170
+ );
1171
+ const row = rows[0];
1172
+ if (!row) return undefined;
1173
+ const clock = normalizeRowClock(row.physical, row.logical, row.peer);
1174
+ if (!clock) return undefined;
1175
+ const metadata = normalizeMetadataMap(parseMetadata(row.metadata));
1176
+ const data = parseData(row.data);
1177
+ const info: EntryInfo = { metadata, clock };
1178
+ if (data !== undefined) {
1179
+ info.data = data;
1180
+ }
1181
+ return info;
1182
+ }
1183
+
634
1184
  async getMvr(key: KeyPart[]): Promise<Value[]> {
635
1185
  const rows = await this.scan({ prefix: key });
636
1186
  const values: Value[] = [];
@@ -657,14 +1207,20 @@ export class FlockSQLite {
657
1207
  await this.put(composite, true, now);
658
1208
  }
659
1209
 
660
- private buildScanBounds(
661
- options: ScanOptions,
662
- ): { where: string[]; params: unknown[]; empty?: boolean; postFilter?: (bytes: Uint8Array) => boolean } {
1210
+ private buildScanBounds(options: ScanOptions): {
1211
+ where: string[];
1212
+ params: unknown[];
1213
+ empty?: boolean;
1214
+ postFilter?: (bytes: Uint8Array) => boolean;
1215
+ } {
663
1216
  let lower: { value: Uint8Array; inclusive: boolean } | undefined;
664
1217
  let upper: { value: Uint8Array; inclusive: boolean } | undefined;
665
1218
  let prefixFilter: Uint8Array | undefined;
666
1219
 
667
- const applyLower = (candidate: { value: Uint8Array; inclusive: boolean }) => {
1220
+ const applyLower = (candidate: {
1221
+ value: Uint8Array;
1222
+ inclusive: boolean;
1223
+ }) => {
668
1224
  if (!lower) {
669
1225
  lower = candidate;
670
1226
  return;
@@ -673,11 +1229,17 @@ export class FlockSQLite {
673
1229
  if (cmp > 0) {
674
1230
  lower = candidate;
675
1231
  } else if (cmp === 0) {
676
- lower = { value: lower.value, inclusive: lower.inclusive && candidate.inclusive };
1232
+ lower = {
1233
+ value: lower.value,
1234
+ inclusive: lower.inclusive && candidate.inclusive,
1235
+ };
677
1236
  }
678
1237
  };
679
1238
 
680
- const applyUpper = (candidate: { value: Uint8Array; inclusive: boolean }) => {
1239
+ const applyUpper = (candidate: {
1240
+ value: Uint8Array;
1241
+ inclusive: boolean;
1242
+ }) => {
681
1243
  if (!upper) {
682
1244
  upper = candidate;
683
1245
  return;
@@ -686,7 +1248,10 @@ export class FlockSQLite {
686
1248
  if (cmp < 0) {
687
1249
  upper = candidate;
688
1250
  } else if (cmp === 0) {
689
- upper = { value: upper.value, inclusive: upper.inclusive && candidate.inclusive };
1251
+ upper = {
1252
+ value: upper.value,
1253
+ inclusive: upper.inclusive && candidate.inclusive,
1254
+ };
690
1255
  }
691
1256
  };
692
1257
 
@@ -727,7 +1292,10 @@ export class FlockSQLite {
727
1292
  params.push(upper.value);
728
1293
  }
729
1294
  const postFilter = prefixFilter
730
- ? ((pf: Uint8Array) => (bytes: Uint8Array) => keyMatchesPrefix(bytes, pf))(prefixFilter)
1295
+ ? (
1296
+ (pf: Uint8Array) => (bytes: Uint8Array) =>
1297
+ keyMatchesPrefix(bytes, pf)
1298
+ )(prefixFilter)
731
1299
  : undefined;
732
1300
  return { where, params, postFilter };
733
1301
  }
@@ -737,7 +1305,8 @@ export class FlockSQLite {
737
1305
  if (bounds.empty) {
738
1306
  return [];
739
1307
  }
740
- const clauses = bounds.where.length > 0 ? `WHERE ${bounds.where.join(" AND ")}` : "";
1308
+ const clauses =
1309
+ bounds.where.length > 0 ? `WHERE ${bounds.where.join(" AND ")}` : "";
741
1310
  const rows = await this.db.query<KvRow>(
742
1311
  `SELECT key, data, metadata, physical, logical, peer FROM ${this.tables.kv} ${clauses} ORDER BY key ASC`,
743
1312
  bounds.params as [],
@@ -765,7 +1334,42 @@ export class FlockSQLite {
765
1334
  return result;
766
1335
  }
767
1336
 
768
- version(): VersionVector {
1337
+ /**
1338
+ * Returns the exclusive version vector, which only includes peers that have
1339
+ * at least one entry in the current state. This is consistent with the state
1340
+ * after export and re-import.
1341
+ *
1342
+ * Use this version when sending to other peers for incremental sync.
1343
+ */
1344
+ async version(): Promise<VersionVector> {
1345
+ // Find the maximum clock per peer. The clock ordering is (physical, logical).
1346
+ // We need the row with the highest (physical, logical) pair for each peer,
1347
+ // not MAX(physical) and MAX(logical) independently which could mix values from different rows.
1348
+ // Use a window function with lexicographic ordering to avoid floating-point precision issues.
1349
+ const rows = await this.db.query<{ peer: string; physical: number; logical: number }>(
1350
+ `SELECT peer, physical, logical FROM (
1351
+ SELECT peer, physical, logical,
1352
+ ROW_NUMBER() OVER (PARTITION BY peer ORDER BY physical DESC, logical DESC) as rn
1353
+ FROM ${this.tables.kv}
1354
+ ) WHERE rn = 1`,
1355
+ );
1356
+ const vv: VersionVector = {};
1357
+ for (const row of rows) {
1358
+ vv[row.peer] = {
1359
+ physicalTime: row.physical,
1360
+ logicalCounter: row.logical,
1361
+ };
1362
+ }
1363
+ return vv;
1364
+ }
1365
+
1366
+ /**
1367
+ * Returns the inclusive version vector, which includes all peers ever seen,
1368
+ * even if their entries have been overridden by other peers.
1369
+ *
1370
+ * Use this version when checking if you have received all data from another peer.
1371
+ */
1372
+ inclusiveVersion(): VersionVector {
769
1373
  const vv: VersionVector = {};
770
1374
  for (const [peer, clock] of this.vv.entries()) {
771
1375
  vv[peer] = { ...clock };
@@ -781,7 +1385,11 @@ export class FlockSQLite {
781
1385
  return this.maxHlc.physicalTime;
782
1386
  }
783
1387
 
784
- private async exportInternal(from?: VersionVector, pruneTombstonesBefore?: number, peerId?: string): Promise<ExportBundle> {
1388
+ private async exportInternal(
1389
+ from?: VersionVector,
1390
+ pruneTombstonesBefore?: number,
1391
+ peerId?: string,
1392
+ ): Promise<ExportBundle> {
785
1393
  const normalizedFrom = new Map<string, VersionVectorEntry>();
786
1394
  if (from) {
787
1395
  for (const [peer, entry] of Object.entries(from)) {
@@ -795,7 +1403,10 @@ export class FlockSQLite {
795
1403
  const entries: Record<string, ExportRecord> = {};
796
1404
 
797
1405
  const peers = peerId ? [peerId] : Array.from(this.vv.keys());
798
- const peersToExport: Array<{ peer: string; fromEntry?: VersionVectorEntry }> = [];
1406
+ const peersToExport: Array<{
1407
+ peer: string;
1408
+ fromEntry?: VersionVectorEntry;
1409
+ }> = [];
799
1410
  for (const peer of peers) {
800
1411
  const localEntry = this.vv.get(peer);
801
1412
  const fromEntry = normalizedFrom.get(peer);
@@ -813,7 +1424,10 @@ export class FlockSQLite {
813
1424
  }
814
1425
 
815
1426
  if (peerId && peersToExport.every((p) => p.peer !== peerId)) {
816
- peersToExport.push({ peer: peerId, fromEntry: normalizedFrom.get(peerId) });
1427
+ peersToExport.push({
1428
+ peer: peerId,
1429
+ fromEntry: normalizedFrom.get(peerId),
1430
+ });
817
1431
  }
818
1432
 
819
1433
  if (peersToExport.length === 0) {
@@ -867,7 +1481,11 @@ export class FlockSQLite {
867
1481
  }
868
1482
 
869
1483
  private async exportWithHooks(options: ExportOptions): Promise<ExportBundle> {
870
- const base = await this.exportInternal(options.from, options.pruneTombstonesBefore, options.peerId);
1484
+ const base = await this.exportInternal(
1485
+ options.from,
1486
+ options.pruneTombstonesBefore,
1487
+ options.peerId,
1488
+ );
871
1489
  const transform = options.hooks?.transform;
872
1490
  if (!transform) {
873
1491
  return base;
@@ -894,9 +1512,15 @@ export class FlockSQLite {
894
1512
 
895
1513
  exportJson(): Promise<ExportBundle>;
896
1514
  exportJson(from: VersionVector): Promise<ExportBundle>;
897
- exportJson(from: VersionVector, pruneTombstonesBefore: number): Promise<ExportBundle>;
1515
+ exportJson(
1516
+ from: VersionVector,
1517
+ pruneTombstonesBefore: number,
1518
+ ): Promise<ExportBundle>;
898
1519
  exportJson(options: ExportOptions): Promise<ExportBundle>;
899
- exportJson(arg?: VersionVector | ExportOptions, pruneTombstonesBefore?: number): Promise<ExportBundle> {
1520
+ exportJson(
1521
+ arg?: VersionVector | ExportOptions,
1522
+ pruneTombstonesBefore?: number,
1523
+ ): Promise<ExportBundle> {
900
1524
  if (isExportOptions(arg)) {
901
1525
  return this.exportWithHooks(arg);
902
1526
  }
@@ -904,12 +1528,33 @@ export class FlockSQLite {
904
1528
  }
905
1529
 
906
1530
  private async importInternal(bundle: ExportBundle): Promise<ImportReport> {
1531
+ // Force commit if in transaction - this is an error condition
1532
+ if (this.txnEventSink !== undefined) {
1533
+ const pending = this.txnEventSink;
1534
+ this.txnEventSink = undefined;
1535
+ if (pending.length > 0) {
1536
+ this.emitEvents("local", pending);
1537
+ }
1538
+ throw new Error(
1539
+ "import called during transaction - transaction was auto-committed",
1540
+ );
1541
+ }
1542
+
1543
+ // Force commit if in debounce mode
1544
+ if (this.debounceState !== undefined) {
1545
+ this.commit();
1546
+ }
1547
+
907
1548
  if (bundle.version !== 0) {
908
1549
  throw new TypeError("Unsupported bundle version");
909
1550
  }
910
1551
  let accepted = 0;
911
1552
  const skipped: Array<{ key: KeyPart[]; reason: string }> = [];
912
- const appliedEvents: Array<{ key: KeyPart[]; payload: ExportPayload; source: string }> = [];
1553
+ const appliedEvents: Array<{
1554
+ key: KeyPart[];
1555
+ payload: ExportPayload;
1556
+ source: string;
1557
+ }> = [];
913
1558
  for (const [keyString, record] of Object.entries(bundle.entries)) {
914
1559
  let keyParts: KeyPart[];
915
1560
  try {
@@ -945,14 +1590,18 @@ export class FlockSQLite {
945
1590
  async importJson(arg: ExportBundle | ImportOptions): Promise<ImportReport> {
946
1591
  if (isImportOptions(arg)) {
947
1592
  const preprocess = arg.hooks?.preprocess;
948
- const working = preprocess ? { version: arg.bundle.version, entries: { ...arg.bundle.entries } } : arg.bundle;
1593
+ const working = preprocess
1594
+ ? { version: arg.bundle.version, entries: { ...arg.bundle.entries } }
1595
+ : arg.bundle;
949
1596
  const skipped: Array<{ key: KeyPart[]; reason: string }> = [];
950
1597
  if (preprocess) {
951
1598
  for (const [key, record] of Object.entries(working.entries)) {
952
1599
  const contextKey = JSON.parse(key) as KeyPart[];
953
1600
  const clock = parseClockString(record.c);
954
1601
  const payload: ExportPayload = {};
955
- if (record.d !== undefined) payload.data = cloneJson(record.d);
1602
+ if (record.d !== undefined) {
1603
+ payload.data = cloneJson(record.d);
1604
+ }
956
1605
  const metadata = cloneMetadata(record.m);
957
1606
  if (metadata !== undefined) payload.metadata = metadata;
958
1607
  const decision = await preprocess(
@@ -961,7 +1610,10 @@ export class FlockSQLite {
961
1610
  );
962
1611
  const normalized = normalizeImportDecision(decision);
963
1612
  if (!normalized.accept) {
964
- skipped.push({ key: contextKey, reason: normalized.reason ?? "rejected" });
1613
+ skipped.push({
1614
+ key: contextKey,
1615
+ reason: normalized.reason ?? "rejected",
1616
+ });
965
1617
  delete working.entries[key];
966
1618
  } else {
967
1619
  working.entries[key] = buildRecord(clock, payload);
@@ -969,7 +1621,10 @@ export class FlockSQLite {
969
1621
  }
970
1622
  }
971
1623
  const baseReport = await this.importInternal(working);
972
- return { accepted: baseReport.accepted, skipped: skipped.concat(baseReport.skipped) };
1624
+ return {
1625
+ accepted: baseReport.accepted,
1626
+ skipped: skipped.concat(baseReport.skipped),
1627
+ };
973
1628
  }
974
1629
  return this.importInternal(arg);
975
1630
  }
@@ -996,7 +1651,10 @@ export class FlockSQLite {
996
1651
  await this.importJson(bundle);
997
1652
  }
998
1653
 
999
- static async checkConsistency(a: FlockSQLite, b: FlockSQLite): Promise<boolean> {
1654
+ static async checkConsistency(
1655
+ a: FlockSQLite,
1656
+ b: FlockSQLite,
1657
+ ): Promise<boolean> {
1000
1658
  const [digestA, digestB] = await Promise.all([a.digest(), b.digest()]);
1001
1659
  return digestA === digestB;
1002
1660
  }
@@ -1011,6 +1669,154 @@ export class FlockSQLite {
1011
1669
  this.listeners.delete(listener);
1012
1670
  };
1013
1671
  }
1672
+
1673
+ /**
1674
+ * Execute operations within a transaction. All put/delete operations inside
1675
+ * the callback will be batched and emitted as a single EventBatch when the
1676
+ * transaction commits successfully.
1677
+ *
1678
+ * If the callback throws or rejects, the transaction is rolled back and no
1679
+ * events are emitted. Note: Database operations are NOT rolled back - only
1680
+ * event emission is affected.
1681
+ *
1682
+ * @param callback - Async function containing put/delete operations
1683
+ * @returns The return value of the callback
1684
+ * @throws Error if nested transaction attempted
1685
+ * @throws Error if called while autoDebounceCommit is active
1686
+ *
1687
+ * @example
1688
+ * ```ts
1689
+ * await flock.txn(async () => {
1690
+ * await flock.put(["a"], 1);
1691
+ * await flock.put(["b"], 2);
1692
+ * await flock.put(["c"], 3);
1693
+ * });
1694
+ * // Subscribers receive a single EventBatch with 3 events
1695
+ * ```
1696
+ */
1697
+ async txn<T>(callback: () => Promise<T>): Promise<T> {
1698
+ if (this.txnEventSink !== undefined) {
1699
+ throw new Error("Nested transactions are not supported");
1700
+ }
1701
+ if (this.debounceState !== undefined) {
1702
+ throw new Error(
1703
+ "Cannot start transaction while autoDebounceCommit is active",
1704
+ );
1705
+ }
1706
+
1707
+ const eventSink: Array<{
1708
+ key: KeyPart[];
1709
+ payload: ExportPayload;
1710
+ source: string;
1711
+ }> = [];
1712
+ this.txnEventSink = eventSink;
1713
+
1714
+ try {
1715
+ const result = await callback();
1716
+ // Commit: emit all accumulated events as single batch
1717
+ if (eventSink.length > 0) {
1718
+ this.emitEvents("local", eventSink);
1719
+ }
1720
+ return result;
1721
+ } finally {
1722
+ this.txnEventSink = undefined;
1723
+ }
1724
+ }
1725
+
1726
+ /**
1727
+ * Check if a transaction is currently active.
1728
+ */
1729
+ isInTxn(): boolean {
1730
+ return this.txnEventSink !== undefined;
1731
+ }
1732
+
1733
+ /**
1734
+ * Enable auto-debounce mode. Events will be accumulated and emitted after
1735
+ * the specified timeout of inactivity. Each new operation resets the timer.
1736
+ *
1737
+ * Use `commit()` to force immediate emission of pending events.
1738
+ * Use `disableAutoDebounceCommit()` to disable and emit pending events.
1739
+ *
1740
+ * Import operations will automatically call `commit()` before proceeding.
1741
+ *
1742
+ * @param timeout - Debounce timeout in milliseconds
1743
+ * @throws Error if called while a transaction is active
1744
+ * @throws Error if autoDebounceCommit is already active
1745
+ *
1746
+ * @example
1747
+ * ```ts
1748
+ * flock.autoDebounceCommit(100);
1749
+ * await flock.put(["a"], 1);
1750
+ * await flock.put(["b"], 2);
1751
+ * // No events emitted yet...
1752
+ * // After 100ms of inactivity, subscribers receive single EventBatch
1753
+ * ```
1754
+ */
1755
+ autoDebounceCommit(timeout: number): void {
1756
+ if (this.txnEventSink !== undefined) {
1757
+ throw new Error(
1758
+ "Cannot enable autoDebounceCommit while transaction is active",
1759
+ );
1760
+ }
1761
+ if (this.debounceState !== undefined) {
1762
+ throw new Error("autoDebounceCommit is already active");
1763
+ }
1764
+
1765
+ this.debounceState = {
1766
+ timeout,
1767
+ timerId: undefined,
1768
+ pendingEvents: [],
1769
+ };
1770
+ }
1771
+
1772
+ /**
1773
+ * Disable auto-debounce mode and emit any pending events immediately.
1774
+ * No-op if autoDebounceCommit is not active.
1775
+ */
1776
+ disableAutoDebounceCommit(): void {
1777
+ if (this.debounceState === undefined) {
1778
+ return;
1779
+ }
1780
+
1781
+ const { timerId, pendingEvents } = this.debounceState;
1782
+ if (timerId !== undefined) {
1783
+ clearTimeout(timerId);
1784
+ }
1785
+ this.debounceState = undefined;
1786
+
1787
+ if (pendingEvents.length > 0) {
1788
+ this.emitEvents("local", pendingEvents);
1789
+ }
1790
+ }
1791
+
1792
+ /**
1793
+ * Force immediate emission of any pending debounced events.
1794
+ * Does not disable auto-debounce mode - new operations will continue to be debounced.
1795
+ * No-op if autoDebounceCommit is not active or no events are pending.
1796
+ */
1797
+ commit(): void {
1798
+ if (this.debounceState === undefined) {
1799
+ return;
1800
+ }
1801
+
1802
+ const { timerId, pendingEvents } = this.debounceState;
1803
+ if (timerId !== undefined) {
1804
+ clearTimeout(timerId);
1805
+ this.debounceState.timerId = undefined;
1806
+ }
1807
+
1808
+ if (pendingEvents.length > 0) {
1809
+ this.emitEvents("local", pendingEvents);
1810
+ this.debounceState.pendingEvents = [];
1811
+ }
1812
+ }
1813
+
1814
+ /**
1815
+ * Check if auto-debounce mode is currently active.
1816
+ */
1817
+ isAutoDebounceActive(): boolean {
1818
+ return this.debounceState !== undefined;
1819
+ }
1014
1820
  }
1015
1821
 
1016
1822
  export type {
@@ -1032,8 +1838,8 @@ export type {
1032
1838
  ScanOptions,
1033
1839
  ScanRow,
1034
1840
  Value,
1035
- VersionVector,
1036
1841
  VersionVectorEntry,
1842
+ EntryInfo,
1037
1843
  };
1038
1844
 
1039
1845
  export { FlockSQLite as Flock };