@backloghq/opslog 0.3.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/dist/delta.d.ts +30 -0
- package/dist/delta.js +75 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/store.d.ts +18 -0
- package/dist/store.js +75 -0
- package/package.json +1 -1
package/dist/delta.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delta encoding for operations.
|
|
3
|
+
* Instead of storing the full previous record, store only the diff.
|
|
4
|
+
*
|
|
5
|
+
* Uses a simplified JSON Patch-like format:
|
|
6
|
+
* - Only tracks changed/added/removed top-level keys
|
|
7
|
+
* - prev becomes a patch object: { $set: {...}, $unset: [...] }
|
|
8
|
+
*/
|
|
9
|
+
export interface DeltaPatch {
|
|
10
|
+
/** Fields that were changed or added (old values). */
|
|
11
|
+
$set?: Record<string, unknown>;
|
|
12
|
+
/** Fields that were removed. */
|
|
13
|
+
$unset?: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Create a delta patch from an old record to a new record.
|
|
17
|
+
* The patch, when applied to the new record, produces the old record.
|
|
18
|
+
* This is the "reverse patch" — stored as `prev` so undo can apply it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createDelta(oldRecord: Record<string, unknown> | null, newRecord: Record<string, unknown>): DeltaPatch | null;
|
|
21
|
+
/**
|
|
22
|
+
* Apply a delta patch to a record to produce the previous version.
|
|
23
|
+
* Used during undo: apply the reverse patch to current record → get old record.
|
|
24
|
+
*/
|
|
25
|
+
export declare function applyDelta(record: Record<string, unknown>, patch: DeltaPatch): Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* Check if a delta patch is smaller than the full record.
|
|
28
|
+
* Used to decide whether to use delta or full encoding.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isDeltaSmaller(patch: DeltaPatch | null, fullRecord: Record<string, unknown> | null): boolean;
|
package/dist/delta.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delta encoding for operations.
|
|
3
|
+
* Instead of storing the full previous record, store only the diff.
|
|
4
|
+
*
|
|
5
|
+
* Uses a simplified JSON Patch-like format:
|
|
6
|
+
* - Only tracks changed/added/removed top-level keys
|
|
7
|
+
* - prev becomes a patch object: { $set: {...}, $unset: [...] }
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Create a delta patch from an old record to a new record.
|
|
11
|
+
* The patch, when applied to the new record, produces the old record.
|
|
12
|
+
* This is the "reverse patch" — stored as `prev` so undo can apply it.
|
|
13
|
+
*/
|
|
14
|
+
export function createDelta(oldRecord, newRecord) {
|
|
15
|
+
if (oldRecord === null)
|
|
16
|
+
return null; // Create operation — no previous
|
|
17
|
+
const patch = {};
|
|
18
|
+
const $set = {};
|
|
19
|
+
const $unset = [];
|
|
20
|
+
// Fields in old that differ from new (changed or removed in new)
|
|
21
|
+
for (const [key, oldVal] of Object.entries(oldRecord)) {
|
|
22
|
+
if (!(key in newRecord)) {
|
|
23
|
+
// Field was removed in new → to restore old, we need to $set it
|
|
24
|
+
$set[key] = oldVal;
|
|
25
|
+
}
|
|
26
|
+
else if (JSON.stringify(oldVal) !== JSON.stringify(newRecord[key])) {
|
|
27
|
+
// Field changed → store old value
|
|
28
|
+
$set[key] = oldVal;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Fields in new that weren't in old (added in new)
|
|
32
|
+
for (const key of Object.keys(newRecord)) {
|
|
33
|
+
if (!(key in oldRecord)) {
|
|
34
|
+
// Field was added in new → to restore old, we need to $unset it
|
|
35
|
+
$unset.push(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Object.keys($set).length > 0)
|
|
39
|
+
patch.$set = $set;
|
|
40
|
+
if ($unset.length > 0)
|
|
41
|
+
patch.$unset = $unset;
|
|
42
|
+
// If no changes, return empty patch
|
|
43
|
+
if (!patch.$set && !patch.$unset)
|
|
44
|
+
return null;
|
|
45
|
+
return patch;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Apply a delta patch to a record to produce the previous version.
|
|
49
|
+
* Used during undo: apply the reverse patch to current record → get old record.
|
|
50
|
+
*/
|
|
51
|
+
export function applyDelta(record, patch) {
|
|
52
|
+
const result = { ...record };
|
|
53
|
+
if (patch.$set) {
|
|
54
|
+
for (const [key, val] of Object.entries(patch.$set)) {
|
|
55
|
+
result[key] = val;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (patch.$unset) {
|
|
59
|
+
for (const key of patch.$unset) {
|
|
60
|
+
delete result[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Check if a delta patch is smaller than the full record.
|
|
67
|
+
* Used to decide whether to use delta or full encoding.
|
|
68
|
+
*/
|
|
69
|
+
export function isDeltaSmaller(patch, fullRecord) {
|
|
70
|
+
if (patch === null || fullRecord === null)
|
|
71
|
+
return false;
|
|
72
|
+
const patchSize = JSON.stringify(patch).length;
|
|
73
|
+
const fullSize = JSON.stringify(fullRecord).length;
|
|
74
|
+
return patchSize < fullSize;
|
|
75
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { Store } from "./store.js";
|
|
2
2
|
export { FsBackend } from "./backend.js";
|
|
3
3
|
export { LamportClock } from "./clock.js";
|
|
4
|
+
export { createDelta, applyDelta, isDeltaSmaller } from "./delta.js";
|
|
5
|
+
export type { DeltaPatch } from "./delta.js";
|
|
4
6
|
export { acquireLock, releaseLock } from "./lock.js";
|
|
5
7
|
export { validateOp, validateManifest, validateSnapshot, validateArchiveSegment, } from "./validate.js";
|
|
6
8
|
export type { Operation, Snapshot, Manifest, ManifestStats, ArchiveSegment, StoreOptions, StoreStats, StorageBackend, LockHandle, } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { Store } from "./store.js";
|
|
2
2
|
export { FsBackend } from "./backend.js";
|
|
3
3
|
export { LamportClock } from "./clock.js";
|
|
4
|
+
export { createDelta, applyDelta, isDeltaSmaller } from "./delta.js";
|
|
4
5
|
export { acquireLock, releaseLock } from "./lock.js";
|
|
5
6
|
export { validateOp, validateManifest, validateSnapshot, validateArchiveSegment, } from "./validate.js";
|
package/dist/store.d.ts
CHANGED
|
@@ -52,6 +52,24 @@ export declare class Store<T = Record<string, unknown>> {
|
|
|
52
52
|
* Use this to pick up writes from other agents.
|
|
53
53
|
*/
|
|
54
54
|
refresh(): Promise<void>;
|
|
55
|
+
private watchTimer;
|
|
56
|
+
private watchCallback;
|
|
57
|
+
/**
|
|
58
|
+
* Tail the WAL for new operations. Re-reads the active ops file
|
|
59
|
+
* and replays any new operations since the last known count.
|
|
60
|
+
* Returns the newly applied operations.
|
|
61
|
+
* Works in any mode (single-writer readOnly, multi-writer, etc).
|
|
62
|
+
*/
|
|
63
|
+
tail(): Promise<Operation<T>[]>;
|
|
64
|
+
/**
|
|
65
|
+
* Watch for new operations on an interval.
|
|
66
|
+
* Calls the callback with new operations whenever they appear.
|
|
67
|
+
* @param callback Called with new operations
|
|
68
|
+
* @param intervalMs Polling interval in milliseconds (default: 1000)
|
|
69
|
+
*/
|
|
70
|
+
watch(callback: (ops: Operation<T>[]) => void, intervalMs?: number): void;
|
|
71
|
+
/** Stop watching for new operations. */
|
|
72
|
+
unwatch(): void;
|
|
55
73
|
private makeOp;
|
|
56
74
|
private _set;
|
|
57
75
|
private _setSync;
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createDefaultManifest } from "./manifest.js";
|
|
2
2
|
import { FsBackend } from "./backend.js";
|
|
3
3
|
import { LamportClock } from "./clock.js";
|
|
4
|
+
import { createDelta, applyDelta, isDeltaSmaller } from "./delta.js";
|
|
4
5
|
export class Store {
|
|
5
6
|
dir = "";
|
|
6
7
|
records = new Map();
|
|
@@ -181,6 +182,7 @@ export class Store {
|
|
|
181
182
|
}
|
|
182
183
|
async close() {
|
|
183
184
|
this.ensureOpen();
|
|
185
|
+
this.unwatch();
|
|
184
186
|
if (!this.coreOpts.readOnly &&
|
|
185
187
|
this.coreOpts.checkpointOnClose &&
|
|
186
188
|
this.ops.length > 0) {
|
|
@@ -308,6 +310,60 @@ export class Store {
|
|
|
308
310
|
}
|
|
309
311
|
return this.serialize(() => this._refresh());
|
|
310
312
|
}
|
|
313
|
+
// --- WAL tailing ---
|
|
314
|
+
watchTimer = null;
|
|
315
|
+
watchCallback = null;
|
|
316
|
+
/**
|
|
317
|
+
* Tail the WAL for new operations. Re-reads the active ops file
|
|
318
|
+
* and replays any new operations since the last known count.
|
|
319
|
+
* Returns the newly applied operations.
|
|
320
|
+
* Works in any mode (single-writer readOnly, multi-writer, etc).
|
|
321
|
+
*/
|
|
322
|
+
async tail() {
|
|
323
|
+
this.ensureOpen();
|
|
324
|
+
const prevCount = this.ops.length;
|
|
325
|
+
// Re-read the ops file for new entries
|
|
326
|
+
const allOps = (await this.backend.readOps(this.activeOpsPath));
|
|
327
|
+
if (allOps.length <= prevCount)
|
|
328
|
+
return [];
|
|
329
|
+
const newOps = allOps.slice(prevCount);
|
|
330
|
+
for (const op of newOps) {
|
|
331
|
+
this.applyOp(op);
|
|
332
|
+
}
|
|
333
|
+
this.ops.push(...newOps);
|
|
334
|
+
return newOps;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Watch for new operations on an interval.
|
|
338
|
+
* Calls the callback with new operations whenever they appear.
|
|
339
|
+
* @param callback Called with new operations
|
|
340
|
+
* @param intervalMs Polling interval in milliseconds (default: 1000)
|
|
341
|
+
*/
|
|
342
|
+
watch(callback, intervalMs = 1000) {
|
|
343
|
+
this.ensureOpen();
|
|
344
|
+
if (this.watchTimer)
|
|
345
|
+
this.unwatch();
|
|
346
|
+
this.watchCallback = callback;
|
|
347
|
+
this.watchTimer = setInterval(async () => {
|
|
348
|
+
try {
|
|
349
|
+
const newOps = await this.tail();
|
|
350
|
+
if (newOps.length > 0 && this.watchCallback) {
|
|
351
|
+
this.watchCallback(newOps);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// Silently ignore tail errors during watch
|
|
356
|
+
}
|
|
357
|
+
}, intervalMs);
|
|
358
|
+
}
|
|
359
|
+
/** Stop watching for new operations. */
|
|
360
|
+
unwatch() {
|
|
361
|
+
if (this.watchTimer) {
|
|
362
|
+
clearInterval(this.watchTimer);
|
|
363
|
+
this.watchTimer = null;
|
|
364
|
+
}
|
|
365
|
+
this.watchCallback = null;
|
|
366
|
+
}
|
|
311
367
|
// --- Private mutation implementations ---
|
|
312
368
|
makeOp(type, id, data, prev) {
|
|
313
369
|
const op = {
|
|
@@ -322,6 +378,14 @@ export class Store {
|
|
|
322
378
|
op.agent = this.agentId;
|
|
323
379
|
op.clock = this.clock.tick();
|
|
324
380
|
}
|
|
381
|
+
// Try delta encoding for updates (not creates or deletes)
|
|
382
|
+
if (type === "set" && prev !== null && data !== undefined) {
|
|
383
|
+
const delta = createDelta(prev, data);
|
|
384
|
+
if (delta && isDeltaSmaller(delta, prev)) {
|
|
385
|
+
op.prev = delta;
|
|
386
|
+
op.encoding = "delta";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
325
389
|
return op;
|
|
326
390
|
}
|
|
327
391
|
async _set(id, value) {
|
|
@@ -560,12 +624,23 @@ export class Store {
|
|
|
560
624
|
}
|
|
561
625
|
reverseOp(op) {
|
|
562
626
|
if (op.prev === null) {
|
|
627
|
+
// Was a create — reverse by deleting
|
|
563
628
|
this.records.delete(op.id);
|
|
564
629
|
}
|
|
630
|
+
else if (op.encoding === "delta") {
|
|
631
|
+
// Delta-encoded: apply the reverse patch to the current record
|
|
632
|
+
const current = this.records.get(op.id);
|
|
633
|
+
if (current) {
|
|
634
|
+
const restored = applyDelta(current, op.prev);
|
|
635
|
+
this.records.set(op.id, restored);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
565
638
|
else if (op.op === "delete") {
|
|
639
|
+
// Was a delete — reverse by restoring full prev
|
|
566
640
|
this.records.set(op.id, op.prev);
|
|
567
641
|
}
|
|
568
642
|
else {
|
|
643
|
+
// Was an update — reverse by restoring full prev
|
|
569
644
|
this.records.set(op.id, op.prev);
|
|
570
645
|
}
|
|
571
646
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backloghq/opslog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Embedded event-sourced document store. Append-only operation log with immutable snapshots, zero native dependencies.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|