@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/dist/index.cjs +7 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +122 -4
- package/dist/index.d.ts +122 -4
- package/dist/index.mjs +7 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +878 -72
- package/src/types.ts +22 -4
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
|
|
65
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
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
|
|
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(
|
|
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): {
|
|
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(
|
|
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(
|
|
182
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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 }>(
|
|
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 (?)`, [
|
|
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(
|
|
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
|
-
? {
|
|
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 (
|
|
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 = {
|
|
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 {
|
|
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 =
|
|
462
|
-
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
|
|
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(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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(
|
|
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(
|
|
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 (?)`, [
|
|
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
|
-
|
|
662
|
-
|
|
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: {
|
|
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 = {
|
|
1232
|
+
lower = {
|
|
1233
|
+
value: lower.value,
|
|
1234
|
+
inclusive: lower.inclusive && candidate.inclusive,
|
|
1235
|
+
};
|
|
677
1236
|
}
|
|
678
1237
|
};
|
|
679
1238
|
|
|
680
|
-
const applyUpper = (candidate: {
|
|
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 = {
|
|
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
|
-
? (
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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<{
|
|
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({
|
|
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(
|
|
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(
|
|
1515
|
+
exportJson(
|
|
1516
|
+
from: VersionVector,
|
|
1517
|
+
pruneTombstonesBefore: number,
|
|
1518
|
+
): Promise<ExportBundle>;
|
|
898
1519
|
exportJson(options: ExportOptions): Promise<ExportBundle>;
|
|
899
|
-
exportJson(
|
|
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<{
|
|
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
|
|
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)
|
|
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({
|
|
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 {
|
|
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(
|
|
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 };
|