@loro-dev/flock-sqlite 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  }
@@ -320,7 +628,13 @@ export class FlockSQLite {
320
628
  private listeners: Set<EventListener>;
321
629
  private tables: TableNames;
322
630
 
323
- private constructor(db: UniStoreConnection, peerId: string, vv: Map<string, VersionVectorEntry>, maxHlc: { physicalTime: number; logicalCounter: number }, tables: TableNames) {
631
+ private constructor(
632
+ db: UniStoreConnection,
633
+ peerId: string,
634
+ vv: Map<string, VersionVectorEntry>,
635
+ maxHlc: { physicalTime: number; logicalCounter: number },
636
+ tables: TableNames,
637
+ ) {
324
638
  this.db = db;
325
639
  this.peerIdValue = peerId;
326
640
  this.vv = vv;
@@ -339,7 +653,9 @@ export class FlockSQLite {
339
653
  return new FlockSQLite(db, peerId, vv, maxHlc, tables);
340
654
  }
341
655
 
342
- static async fromJson(options: FlockSQLiteOptions & { bundle: ExportBundle }): Promise<FlockSQLite> {
656
+ static async fromJson(
657
+ options: FlockSQLiteOptions & { bundle: ExportBundle },
658
+ ): Promise<FlockSQLite> {
343
659
  const flock = await FlockSQLite.open(options);
344
660
  await flock.importJson(options.bundle);
345
661
  return flock;
@@ -349,7 +665,10 @@ export class FlockSQLite {
349
665
  await this.db.close();
350
666
  }
351
667
 
352
- private static async ensureSchema(db: UniStoreConnection, tables: TableNames): Promise<void> {
668
+ private static async ensureSchema(
669
+ db: UniStoreConnection,
670
+ tables: TableNames,
671
+ ): Promise<void> {
353
672
  await db.exec(`
354
673
  CREATE TABLE IF NOT EXISTS ${tables.kv} (
355
674
  key BLOB PRIMARY KEY,
@@ -382,9 +701,15 @@ export class FlockSQLite {
382
701
  );
383
702
  }
384
703
 
385
- private static async resolvePeerId(db: UniStoreConnection, tables: TableNames, provided?: string): Promise<string> {
704
+ private static async resolvePeerId(
705
+ db: UniStoreConnection,
706
+ tables: TableNames,
707
+ provided?: string,
708
+ ): Promise<string> {
386
709
  const normalized = normalizePeerId(provided);
387
- const rows = await db.query<{ peer_id: string }>(`SELECT peer_id FROM ${tables.meta} LIMIT 1`);
710
+ const rows = await db.query<{ peer_id: string }>(
711
+ `SELECT peer_id FROM ${tables.meta} LIMIT 1`,
712
+ );
388
713
  if (rows.length > 0 && typeof rows[0]?.peer_id === "string") {
389
714
  const existing = rows[0].peer_id;
390
715
  if (provided && existing !== normalized) {
@@ -394,11 +719,19 @@ export class FlockSQLite {
394
719
  return normalizePeerId(existing);
395
720
  }
396
721
  await db.exec(`DELETE FROM ${tables.meta}`);
397
- await db.run(`INSERT INTO ${tables.meta}(peer_id) VALUES (?)`, [normalized]);
722
+ await db.run(`INSERT INTO ${tables.meta}(peer_id) VALUES (?)`, [
723
+ normalized,
724
+ ]);
398
725
  return normalized;
399
726
  }
400
727
 
401
- private static async loadVersionState(db: UniStoreConnection, tables: TableNames): Promise<{ vv: Map<string, VersionVectorEntry>; maxHlc: { physicalTime: number; logicalCounter: number } }> {
728
+ private static async loadVersionState(
729
+ db: UniStoreConnection,
730
+ tables: TableNames,
731
+ ): Promise<{
732
+ vv: Map<string, VersionVectorEntry>;
733
+ maxHlc: { physicalTime: number; logicalCounter: number };
734
+ }> {
402
735
  const vv = new Map<string, VersionVectorEntry>();
403
736
  const rows = await db.query<ClockRow>(
404
737
  `SELECT peer, MAX(physical) AS physical, MAX(logical) AS logical FROM ${tables.kv} GROUP BY peer`,
@@ -419,14 +752,20 @@ export class FlockSQLite {
419
752
  const first = maxRow[0];
420
753
  const maxHlc =
421
754
  first && Number.isFinite(first.physical) && Number.isFinite(first.logical)
422
- ? { physicalTime: Number(first.physical), logicalCounter: Number(first.logical) }
755
+ ? {
756
+ physicalTime: Number(first.physical),
757
+ logicalCounter: Number(first.logical),
758
+ }
423
759
  : { physicalTime: 0, logicalCounter: 0 };
424
760
  return { vv, maxHlc };
425
761
  }
426
762
 
427
763
  private bumpVersion(clock: EntryClock): void {
428
764
  const current = this.vv.get(clock.peerId);
429
- if (!current || compareClock(clock, { ...current, peerId: clock.peerId }) > 0) {
765
+ if (
766
+ !current ||
767
+ compareClock(clock, { ...current, peerId: clock.peerId }) > 0
768
+ ) {
430
769
  this.vv.set(clock.peerId, {
431
770
  physicalTime: clock.physicalTime,
432
771
  logicalCounter: clock.logicalCounter,
@@ -437,7 +776,10 @@ export class FlockSQLite {
437
776
  (this.maxHlc.physicalTime === clock.physicalTime &&
438
777
  this.maxHlc.logicalCounter < clock.logicalCounter)
439
778
  ) {
440
- this.maxHlc = { physicalTime: clock.physicalTime, logicalCounter: clock.logicalCounter };
779
+ this.maxHlc = {
780
+ physicalTime: clock.physicalTime,
781
+ logicalCounter: clock.logicalCounter,
782
+ };
441
783
  }
442
784
  }
443
785
 
@@ -451,16 +793,22 @@ export class FlockSQLite {
451
793
  } else {
452
794
  logical = logical + 1;
453
795
  }
454
- return { physicalTime: physical, logicalCounter: logical, peerId: this.peerIdValue };
796
+ return {
797
+ physicalTime: physical,
798
+ logicalCounter: logical,
799
+ peerId: this.peerIdValue,
800
+ };
455
801
  }
456
802
 
457
803
  private async applyOperation(operation: PutOperation): Promise<boolean> {
458
804
  const keyBytes = encodeKeyParts(operation.key);
459
- const clock = operation.clock ?? this.allocateClock(operation.now);
460
805
  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);
806
+ const dataJson =
807
+ payload.data === undefined ? null : JSON.stringify(payload.data);
808
+ const metadataJson =
809
+ payload.metadata === undefined ? null : JSON.stringify(payload.metadata);
463
810
  let applied = false;
811
+ let usedClock: EntryClock | undefined;
464
812
 
465
813
  await this.db.asyncTransaction(async (tx) => {
466
814
  const existingRows = await tx.query<KvRow>(
@@ -469,12 +817,6 @@ export class FlockSQLite {
469
817
  );
470
818
  if (existingRows.length > 0) {
471
819
  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
820
  const existingData = existing.data ?? null;
479
821
  const existingMeta = existing.metadata ?? null;
480
822
  const samePayload =
@@ -484,10 +826,38 @@ export class FlockSQLite {
484
826
  if (samePayload) {
485
827
  return;
486
828
  }
829
+ } else if (
830
+ operation.skipSameValue &&
831
+ dataJson === null &&
832
+ metadataJson === null
833
+ ) {
834
+ // Key doesn't exist and we're trying to delete - skip
835
+ return;
836
+ }
837
+
838
+ // Now allocate clock only if we're going to apply the operation
839
+ const clock = operation.clock ?? this.allocateClock(operation.now);
840
+ usedClock = clock;
841
+
842
+ if (existingRows.length > 0) {
843
+ const existing = existingRows[0];
844
+ const existingClock: EntryClock = {
845
+ physicalTime: Number(existing.physical ?? 0),
846
+ logicalCounter: Number(existing.logical ?? 0),
847
+ peerId: String(existing.peer ?? ""),
848
+ };
849
+ const cmp = compareClock(clock, existingClock);
487
850
  if (cmp < 0) {
488
851
  await tx.run(
489
852
  `INSERT INTO ${this.tables.overridden}(key, data, metadata, physical, logical, peer) VALUES (?, ?, ?, ?, ?, ?)`,
490
- [keyBytes, dataJson, metadataJson, clock.physicalTime, clock.logicalCounter, clock.peerId],
853
+ [
854
+ keyBytes,
855
+ dataJson,
856
+ metadataJson,
857
+ clock.physicalTime,
858
+ clock.logicalCounter,
859
+ clock.peerId,
860
+ ],
491
861
  );
492
862
  return;
493
863
  }
@@ -516,12 +886,21 @@ export class FlockSQLite {
516
886
  physical=excluded.physical,
517
887
  logical=excluded.logical,
518
888
  peer=excluded.peer`,
519
- [keyBytes, dataJson, metadataJson, clock.physicalTime, clock.logicalCounter, clock.peerId],
889
+ [
890
+ keyBytes,
891
+ dataJson,
892
+ metadataJson,
893
+ clock.physicalTime,
894
+ clock.logicalCounter,
895
+ clock.peerId,
896
+ ],
520
897
  );
521
898
  applied = true;
522
899
  });
523
900
 
524
- this.bumpVersion(clock);
901
+ if (usedClock) {
902
+ this.bumpVersion(usedClock);
903
+ }
525
904
  if (applied) {
526
905
  const eventPayload = {
527
906
  key: operation.key.slice(),
@@ -546,12 +925,17 @@ export class FlockSQLite {
546
925
  }
547
926
  const batch: EventBatch = {
548
927
  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
- })),
928
+ events: events.map(
929
+ (event): Event => ({
930
+ key: cloneJson(event.key),
931
+ value:
932
+ event.payload.data !== undefined
933
+ ? cloneJson(event.payload.data)
934
+ : undefined,
935
+ metadata: cloneMetadata(event.payload.metadata),
936
+ payload: clonePayload(event.payload),
937
+ }),
938
+ ),
555
939
  };
556
940
  this.listeners.forEach((listener) => {
557
941
  listener(batch);
@@ -568,7 +952,11 @@ export class FlockSQLite {
568
952
  });
569
953
  }
570
954
 
571
- async putWithMeta(key: KeyPart[], value: Value, options: PutWithMetaOptions = {}): Promise<void> {
955
+ async putWithMeta(
956
+ key: KeyPart[],
957
+ value: Value,
958
+ options: PutWithMetaOptions = {},
959
+ ): Promise<void> {
572
960
  const basePayload: ExportPayload = { data: cloneJson(value) };
573
961
  if (options.metadata) {
574
962
  basePayload.metadata = cloneMetadata(options.metadata);
@@ -576,7 +964,10 @@ export class FlockSQLite {
576
964
  const hooks = options.hooks?.transform;
577
965
  if (hooks) {
578
966
  const working = clonePayload(basePayload);
579
- const transformed = await hooks({ key: key.slice(), now: options.now }, working);
967
+ const transformed = await hooks(
968
+ { key: key.slice(), now: options.now },
969
+ working,
970
+ );
580
971
  const finalPayload = mergePayload(basePayload, transformed ?? working);
581
972
  if (finalPayload.data === undefined) {
582
973
  throw new TypeError("putWithMeta requires a data value");
@@ -609,6 +1000,76 @@ export class FlockSQLite {
609
1000
  });
610
1001
  }
611
1002
 
1003
+ /**
1004
+ * Force put a value even if it's the same as the current value.
1005
+ * This will refresh the timestamp.
1006
+ */
1007
+ async forcePut(key: KeyPart[], value: Value, now?: number): Promise<void> {
1008
+ await this.applyOperation({
1009
+ key,
1010
+ payload: { data: cloneJson(value) },
1011
+ now,
1012
+ skipSameValue: false,
1013
+ source: "local",
1014
+ });
1015
+ }
1016
+
1017
+ /**
1018
+ * Force put a value with metadata even if it's the same as the current value.
1019
+ * This will refresh the timestamp.
1020
+ */
1021
+ async forcePutWithMeta(
1022
+ key: KeyPart[],
1023
+ value: Value,
1024
+ options: PutWithMetaOptions = {},
1025
+ ): Promise<void> {
1026
+ const basePayload: ExportPayload = { data: cloneJson(value) };
1027
+ if (options.metadata) {
1028
+ basePayload.metadata = cloneMetadata(options.metadata);
1029
+ }
1030
+ const hooks = options.hooks?.transform;
1031
+ if (hooks) {
1032
+ const working = clonePayload(basePayload);
1033
+ const transformed = await hooks(
1034
+ { key: key.slice(), now: options.now },
1035
+ working,
1036
+ );
1037
+ const finalPayload = mergePayload(basePayload, transformed ?? working);
1038
+ if (finalPayload.data === undefined) {
1039
+ throw new TypeError("forcePutWithMeta requires a data value");
1040
+ }
1041
+ await this.applyOperation({
1042
+ key,
1043
+ payload: finalPayload,
1044
+ now: options.now,
1045
+ skipSameValue: false,
1046
+ source: "local",
1047
+ });
1048
+ return;
1049
+ }
1050
+ await this.applyOperation({
1051
+ key,
1052
+ payload: basePayload,
1053
+ now: options.now,
1054
+ skipSameValue: false,
1055
+ source: "local",
1056
+ });
1057
+ }
1058
+
1059
+ /**
1060
+ * Force delete a key even if it's already deleted.
1061
+ * This will refresh the timestamp.
1062
+ */
1063
+ async forceDelete(key: KeyPart[], now?: number): Promise<void> {
1064
+ await this.applyOperation({
1065
+ key,
1066
+ payload: {},
1067
+ now,
1068
+ skipSameValue: false,
1069
+ source: "local",
1070
+ });
1071
+ }
1072
+
612
1073
  async set(key: KeyPart[], value: Value, now?: number): Promise<void> {
613
1074
  await this.put(key, value, now);
614
1075
  }
@@ -616,7 +1077,9 @@ export class FlockSQLite {
616
1077
  async setPeerId(peerId: string): Promise<void> {
617
1078
  const normalized = normalizePeerId(peerId);
618
1079
  await this.db.exec(`DELETE FROM ${this.tables.meta}`);
619
- await this.db.run(`INSERT INTO ${this.tables.meta}(peer_id) VALUES (?)`, [normalized]);
1080
+ await this.db.run(`INSERT INTO ${this.tables.meta}(peer_id) VALUES (?)`, [
1081
+ normalized,
1082
+ ]);
620
1083
  this.peerIdValue = normalized;
621
1084
  }
622
1085
 
@@ -631,6 +1094,38 @@ export class FlockSQLite {
631
1094
  return parseData(row.data);
632
1095
  }
633
1096
 
1097
+ /**
1098
+ * Returns the full entry payload (data, metadata, and clock) for a key.
1099
+ *
1100
+ * Compared to `get`, this preserves tombstone information: a deleted entry
1101
+ * still returns its clock and an empty metadata object with `data` omitted.
1102
+ * Missing or invalid keys return `undefined`. Metadata is cloned and
1103
+ * normalized to `{}` when absent.
1104
+ */
1105
+ async getEntry(key: KeyPart[]): Promise<EntryInfo | undefined> {
1106
+ let keyBytes: Uint8Array;
1107
+ try {
1108
+ keyBytes = encodeKeyParts(key);
1109
+ } catch {
1110
+ return undefined;
1111
+ }
1112
+ const rows = await this.db.query<KvRow>(
1113
+ `SELECT data, metadata, physical, logical, peer FROM ${this.tables.kv} WHERE key = ? LIMIT 1`,
1114
+ [keyBytes],
1115
+ );
1116
+ const row = rows[0];
1117
+ if (!row) return undefined;
1118
+ const clock = normalizeRowClock(row.physical, row.logical, row.peer);
1119
+ if (!clock) return undefined;
1120
+ const metadata = normalizeMetadataMap(parseMetadata(row.metadata));
1121
+ const data = parseData(row.data);
1122
+ const info: EntryInfo = { metadata, clock };
1123
+ if (data !== undefined) {
1124
+ info.data = data;
1125
+ }
1126
+ return info;
1127
+ }
1128
+
634
1129
  async getMvr(key: KeyPart[]): Promise<Value[]> {
635
1130
  const rows = await this.scan({ prefix: key });
636
1131
  const values: Value[] = [];
@@ -657,14 +1152,20 @@ export class FlockSQLite {
657
1152
  await this.put(composite, true, now);
658
1153
  }
659
1154
 
660
- private buildScanBounds(
661
- options: ScanOptions,
662
- ): { where: string[]; params: unknown[]; empty?: boolean; postFilter?: (bytes: Uint8Array) => boolean } {
1155
+ private buildScanBounds(options: ScanOptions): {
1156
+ where: string[];
1157
+ params: unknown[];
1158
+ empty?: boolean;
1159
+ postFilter?: (bytes: Uint8Array) => boolean;
1160
+ } {
663
1161
  let lower: { value: Uint8Array; inclusive: boolean } | undefined;
664
1162
  let upper: { value: Uint8Array; inclusive: boolean } | undefined;
665
1163
  let prefixFilter: Uint8Array | undefined;
666
1164
 
667
- const applyLower = (candidate: { value: Uint8Array; inclusive: boolean }) => {
1165
+ const applyLower = (candidate: {
1166
+ value: Uint8Array;
1167
+ inclusive: boolean;
1168
+ }) => {
668
1169
  if (!lower) {
669
1170
  lower = candidate;
670
1171
  return;
@@ -673,11 +1174,17 @@ export class FlockSQLite {
673
1174
  if (cmp > 0) {
674
1175
  lower = candidate;
675
1176
  } else if (cmp === 0) {
676
- lower = { value: lower.value, inclusive: lower.inclusive && candidate.inclusive };
1177
+ lower = {
1178
+ value: lower.value,
1179
+ inclusive: lower.inclusive && candidate.inclusive,
1180
+ };
677
1181
  }
678
1182
  };
679
1183
 
680
- const applyUpper = (candidate: { value: Uint8Array; inclusive: boolean }) => {
1184
+ const applyUpper = (candidate: {
1185
+ value: Uint8Array;
1186
+ inclusive: boolean;
1187
+ }) => {
681
1188
  if (!upper) {
682
1189
  upper = candidate;
683
1190
  return;
@@ -686,7 +1193,10 @@ export class FlockSQLite {
686
1193
  if (cmp < 0) {
687
1194
  upper = candidate;
688
1195
  } else if (cmp === 0) {
689
- upper = { value: upper.value, inclusive: upper.inclusive && candidate.inclusive };
1196
+ upper = {
1197
+ value: upper.value,
1198
+ inclusive: upper.inclusive && candidate.inclusive,
1199
+ };
690
1200
  }
691
1201
  };
692
1202
 
@@ -727,7 +1237,10 @@ export class FlockSQLite {
727
1237
  params.push(upper.value);
728
1238
  }
729
1239
  const postFilter = prefixFilter
730
- ? ((pf: Uint8Array) => (bytes: Uint8Array) => keyMatchesPrefix(bytes, pf))(prefixFilter)
1240
+ ? (
1241
+ (pf: Uint8Array) => (bytes: Uint8Array) =>
1242
+ keyMatchesPrefix(bytes, pf)
1243
+ )(prefixFilter)
731
1244
  : undefined;
732
1245
  return { where, params, postFilter };
733
1246
  }
@@ -737,7 +1250,8 @@ export class FlockSQLite {
737
1250
  if (bounds.empty) {
738
1251
  return [];
739
1252
  }
740
- const clauses = bounds.where.length > 0 ? `WHERE ${bounds.where.join(" AND ")}` : "";
1253
+ const clauses =
1254
+ bounds.where.length > 0 ? `WHERE ${bounds.where.join(" AND ")}` : "";
741
1255
  const rows = await this.db.query<KvRow>(
742
1256
  `SELECT key, data, metadata, physical, logical, peer FROM ${this.tables.kv} ${clauses} ORDER BY key ASC`,
743
1257
  bounds.params as [],
@@ -765,7 +1279,42 @@ export class FlockSQLite {
765
1279
  return result;
766
1280
  }
767
1281
 
768
- version(): VersionVector {
1282
+ /**
1283
+ * Returns the exclusive version vector, which only includes peers that have
1284
+ * at least one entry in the current state. This is consistent with the state
1285
+ * after export and re-import.
1286
+ *
1287
+ * Use this version when sending to other peers for incremental sync.
1288
+ */
1289
+ async version(): Promise<VersionVector> {
1290
+ // Find the maximum clock per peer. The clock ordering is (physical, logical).
1291
+ // We need the row with the highest (physical, logical) pair for each peer,
1292
+ // not MAX(physical) and MAX(logical) independently which could mix values from different rows.
1293
+ // Use a window function with lexicographic ordering to avoid floating-point precision issues.
1294
+ const rows = await this.db.query<{ peer: string; physical: number; logical: number }>(
1295
+ `SELECT peer, physical, logical FROM (
1296
+ SELECT peer, physical, logical,
1297
+ ROW_NUMBER() OVER (PARTITION BY peer ORDER BY physical DESC, logical DESC) as rn
1298
+ FROM ${this.tables.kv}
1299
+ ) WHERE rn = 1`,
1300
+ );
1301
+ const vv: VersionVector = {};
1302
+ for (const row of rows) {
1303
+ vv[row.peer] = {
1304
+ physicalTime: row.physical,
1305
+ logicalCounter: row.logical,
1306
+ };
1307
+ }
1308
+ return vv;
1309
+ }
1310
+
1311
+ /**
1312
+ * Returns the inclusive version vector, which includes all peers ever seen,
1313
+ * even if their entries have been overridden by other peers.
1314
+ *
1315
+ * Use this version when checking if you have received all data from another peer.
1316
+ */
1317
+ inclusiveVersion(): VersionVector {
769
1318
  const vv: VersionVector = {};
770
1319
  for (const [peer, clock] of this.vv.entries()) {
771
1320
  vv[peer] = { ...clock };
@@ -781,7 +1330,11 @@ export class FlockSQLite {
781
1330
  return this.maxHlc.physicalTime;
782
1331
  }
783
1332
 
784
- private async exportInternal(from?: VersionVector, pruneTombstonesBefore?: number, peerId?: string): Promise<ExportBundle> {
1333
+ private async exportInternal(
1334
+ from?: VersionVector,
1335
+ pruneTombstonesBefore?: number,
1336
+ peerId?: string,
1337
+ ): Promise<ExportBundle> {
785
1338
  const normalizedFrom = new Map<string, VersionVectorEntry>();
786
1339
  if (from) {
787
1340
  for (const [peer, entry] of Object.entries(from)) {
@@ -795,7 +1348,10 @@ export class FlockSQLite {
795
1348
  const entries: Record<string, ExportRecord> = {};
796
1349
 
797
1350
  const peers = peerId ? [peerId] : Array.from(this.vv.keys());
798
- const peersToExport: Array<{ peer: string; fromEntry?: VersionVectorEntry }> = [];
1351
+ const peersToExport: Array<{
1352
+ peer: string;
1353
+ fromEntry?: VersionVectorEntry;
1354
+ }> = [];
799
1355
  for (const peer of peers) {
800
1356
  const localEntry = this.vv.get(peer);
801
1357
  const fromEntry = normalizedFrom.get(peer);
@@ -813,7 +1369,10 @@ export class FlockSQLite {
813
1369
  }
814
1370
 
815
1371
  if (peerId && peersToExport.every((p) => p.peer !== peerId)) {
816
- peersToExport.push({ peer: peerId, fromEntry: normalizedFrom.get(peerId) });
1372
+ peersToExport.push({
1373
+ peer: peerId,
1374
+ fromEntry: normalizedFrom.get(peerId),
1375
+ });
817
1376
  }
818
1377
 
819
1378
  if (peersToExport.length === 0) {
@@ -867,7 +1426,11 @@ export class FlockSQLite {
867
1426
  }
868
1427
 
869
1428
  private async exportWithHooks(options: ExportOptions): Promise<ExportBundle> {
870
- const base = await this.exportInternal(options.from, options.pruneTombstonesBefore, options.peerId);
1429
+ const base = await this.exportInternal(
1430
+ options.from,
1431
+ options.pruneTombstonesBefore,
1432
+ options.peerId,
1433
+ );
871
1434
  const transform = options.hooks?.transform;
872
1435
  if (!transform) {
873
1436
  return base;
@@ -894,9 +1457,15 @@ export class FlockSQLite {
894
1457
 
895
1458
  exportJson(): Promise<ExportBundle>;
896
1459
  exportJson(from: VersionVector): Promise<ExportBundle>;
897
- exportJson(from: VersionVector, pruneTombstonesBefore: number): Promise<ExportBundle>;
1460
+ exportJson(
1461
+ from: VersionVector,
1462
+ pruneTombstonesBefore: number,
1463
+ ): Promise<ExportBundle>;
898
1464
  exportJson(options: ExportOptions): Promise<ExportBundle>;
899
- exportJson(arg?: VersionVector | ExportOptions, pruneTombstonesBefore?: number): Promise<ExportBundle> {
1465
+ exportJson(
1466
+ arg?: VersionVector | ExportOptions,
1467
+ pruneTombstonesBefore?: number,
1468
+ ): Promise<ExportBundle> {
900
1469
  if (isExportOptions(arg)) {
901
1470
  return this.exportWithHooks(arg);
902
1471
  }
@@ -909,7 +1478,11 @@ export class FlockSQLite {
909
1478
  }
910
1479
  let accepted = 0;
911
1480
  const skipped: Array<{ key: KeyPart[]; reason: string }> = [];
912
- const appliedEvents: Array<{ key: KeyPart[]; payload: ExportPayload; source: string }> = [];
1481
+ const appliedEvents: Array<{
1482
+ key: KeyPart[];
1483
+ payload: ExportPayload;
1484
+ source: string;
1485
+ }> = [];
913
1486
  for (const [keyString, record] of Object.entries(bundle.entries)) {
914
1487
  let keyParts: KeyPart[];
915
1488
  try {
@@ -945,14 +1518,18 @@ export class FlockSQLite {
945
1518
  async importJson(arg: ExportBundle | ImportOptions): Promise<ImportReport> {
946
1519
  if (isImportOptions(arg)) {
947
1520
  const preprocess = arg.hooks?.preprocess;
948
- const working = preprocess ? { version: arg.bundle.version, entries: { ...arg.bundle.entries } } : arg.bundle;
1521
+ const working = preprocess
1522
+ ? { version: arg.bundle.version, entries: { ...arg.bundle.entries } }
1523
+ : arg.bundle;
949
1524
  const skipped: Array<{ key: KeyPart[]; reason: string }> = [];
950
1525
  if (preprocess) {
951
1526
  for (const [key, record] of Object.entries(working.entries)) {
952
1527
  const contextKey = JSON.parse(key) as KeyPart[];
953
1528
  const clock = parseClockString(record.c);
954
1529
  const payload: ExportPayload = {};
955
- if (record.d !== undefined) payload.data = cloneJson(record.d);
1530
+ if (record.d !== undefined) {
1531
+ payload.data = cloneJson(record.d);
1532
+ }
956
1533
  const metadata = cloneMetadata(record.m);
957
1534
  if (metadata !== undefined) payload.metadata = metadata;
958
1535
  const decision = await preprocess(
@@ -961,7 +1538,10 @@ export class FlockSQLite {
961
1538
  );
962
1539
  const normalized = normalizeImportDecision(decision);
963
1540
  if (!normalized.accept) {
964
- skipped.push({ key: contextKey, reason: normalized.reason ?? "rejected" });
1541
+ skipped.push({
1542
+ key: contextKey,
1543
+ reason: normalized.reason ?? "rejected",
1544
+ });
965
1545
  delete working.entries[key];
966
1546
  } else {
967
1547
  working.entries[key] = buildRecord(clock, payload);
@@ -969,7 +1549,10 @@ export class FlockSQLite {
969
1549
  }
970
1550
  }
971
1551
  const baseReport = await this.importInternal(working);
972
- return { accepted: baseReport.accepted, skipped: skipped.concat(baseReport.skipped) };
1552
+ return {
1553
+ accepted: baseReport.accepted,
1554
+ skipped: skipped.concat(baseReport.skipped),
1555
+ };
973
1556
  }
974
1557
  return this.importInternal(arg);
975
1558
  }
@@ -996,7 +1579,10 @@ export class FlockSQLite {
996
1579
  await this.importJson(bundle);
997
1580
  }
998
1581
 
999
- static async checkConsistency(a: FlockSQLite, b: FlockSQLite): Promise<boolean> {
1582
+ static async checkConsistency(
1583
+ a: FlockSQLite,
1584
+ b: FlockSQLite,
1585
+ ): Promise<boolean> {
1000
1586
  const [digestA, digestB] = await Promise.all([a.digest(), b.digest()]);
1001
1587
  return digestA === digestB;
1002
1588
  }
@@ -1032,8 +1618,8 @@ export type {
1032
1618
  ScanOptions,
1033
1619
  ScanRow,
1034
1620
  Value,
1035
- VersionVector,
1036
1621
  VersionVectorEntry,
1622
+ EntryInfo,
1037
1623
  };
1038
1624
 
1039
1625
  export { FlockSQLite as Flock };