@backloghq/opslog 0.3.0 → 0.4.1
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 +22 -3
- package/dist/store.js +92 -6
- 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
|
@@ -47,11 +47,30 @@ export declare class Store<T = Record<string, unknown>> {
|
|
|
47
47
|
loadArchive(segment: string): Promise<Map<string, T>>;
|
|
48
48
|
stats(): StoreStats;
|
|
49
49
|
/**
|
|
50
|
-
* Reload state from the backend
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* Reload state from the backend.
|
|
51
|
+
* In multi-writer mode: re-reads manifest, snapshot, and all agent WAL files.
|
|
52
|
+
* In single-writer/readOnly mode: re-reads the active ops file for new entries.
|
|
53
|
+
* Use this to pick up writes from other agents or processes.
|
|
53
54
|
*/
|
|
54
55
|
refresh(): Promise<void>;
|
|
56
|
+
private watchTimer;
|
|
57
|
+
private watchCallback;
|
|
58
|
+
/**
|
|
59
|
+
* Tail the WAL for new operations.
|
|
60
|
+
* In single-writer/readOnly: re-reads the active ops file for new entries.
|
|
61
|
+
* In multi-writer: re-reads ALL agent WAL files from the manifest.
|
|
62
|
+
* Returns the newly applied operations.
|
|
63
|
+
*/
|
|
64
|
+
tail(): Promise<Operation<T>[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Watch for new operations on an interval.
|
|
67
|
+
* Calls the callback with new operations whenever they appear.
|
|
68
|
+
* @param callback Called with new operations
|
|
69
|
+
* @param intervalMs Polling interval in milliseconds (default: 1000)
|
|
70
|
+
*/
|
|
71
|
+
watch(callback: (ops: Operation<T>[]) => void, intervalMs?: number): void;
|
|
72
|
+
/** Stop watching for new operations. */
|
|
73
|
+
unwatch(): void;
|
|
55
74
|
private makeOp;
|
|
56
75
|
private _set;
|
|
57
76
|
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) {
|
|
@@ -297,16 +299,81 @@ export class Store {
|
|
|
297
299
|
};
|
|
298
300
|
}
|
|
299
301
|
/**
|
|
300
|
-
* Reload state from the backend
|
|
301
|
-
*
|
|
302
|
-
*
|
|
302
|
+
* Reload state from the backend.
|
|
303
|
+
* In multi-writer mode: re-reads manifest, snapshot, and all agent WAL files.
|
|
304
|
+
* In single-writer/readOnly mode: re-reads the active ops file for new entries.
|
|
305
|
+
* Use this to pick up writes from other agents or processes.
|
|
303
306
|
*/
|
|
304
307
|
async refresh() {
|
|
305
308
|
this.ensureOpen();
|
|
306
|
-
if (
|
|
307
|
-
|
|
309
|
+
if (this.isMultiWriter()) {
|
|
310
|
+
return this.serialize(() => this._refresh());
|
|
311
|
+
}
|
|
312
|
+
// Single-writer / readOnly: just tail the active ops file
|
|
313
|
+
await this.tail();
|
|
314
|
+
}
|
|
315
|
+
// --- WAL tailing ---
|
|
316
|
+
watchTimer = null;
|
|
317
|
+
watchCallback = null;
|
|
318
|
+
/**
|
|
319
|
+
* Tail the WAL for new operations.
|
|
320
|
+
* In single-writer/readOnly: re-reads the active ops file for new entries.
|
|
321
|
+
* In multi-writer: re-reads ALL agent WAL files from the manifest.
|
|
322
|
+
* Returns the newly applied operations.
|
|
323
|
+
*/
|
|
324
|
+
async tail() {
|
|
325
|
+
this.ensureOpen();
|
|
326
|
+
const prevCount = this.ops.length;
|
|
327
|
+
if (this.isMultiWriter()) {
|
|
328
|
+
// Multi-writer: full refresh to pick up all agents' writes
|
|
329
|
+
await this.serialize(() => this._refresh());
|
|
330
|
+
// Return the difference
|
|
331
|
+
if (this.ops.length > prevCount) {
|
|
332
|
+
return this.ops.slice(prevCount);
|
|
333
|
+
}
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
// Single-writer / readOnly: just re-read our ops file
|
|
337
|
+
const allOps = (await this.backend.readOps(this.activeOpsPath));
|
|
338
|
+
if (allOps.length <= prevCount)
|
|
339
|
+
return [];
|
|
340
|
+
const newOps = allOps.slice(prevCount);
|
|
341
|
+
for (const op of newOps) {
|
|
342
|
+
this.applyOp(op);
|
|
343
|
+
}
|
|
344
|
+
this.ops.push(...newOps);
|
|
345
|
+
return newOps;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Watch for new operations on an interval.
|
|
349
|
+
* Calls the callback with new operations whenever they appear.
|
|
350
|
+
* @param callback Called with new operations
|
|
351
|
+
* @param intervalMs Polling interval in milliseconds (default: 1000)
|
|
352
|
+
*/
|
|
353
|
+
watch(callback, intervalMs = 1000) {
|
|
354
|
+
this.ensureOpen();
|
|
355
|
+
if (this.watchTimer)
|
|
356
|
+
this.unwatch();
|
|
357
|
+
this.watchCallback = callback;
|
|
358
|
+
this.watchTimer = setInterval(async () => {
|
|
359
|
+
try {
|
|
360
|
+
const newOps = await this.tail();
|
|
361
|
+
if (newOps.length > 0 && this.watchCallback) {
|
|
362
|
+
this.watchCallback(newOps);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Silently ignore tail errors during watch
|
|
367
|
+
}
|
|
368
|
+
}, intervalMs);
|
|
369
|
+
}
|
|
370
|
+
/** Stop watching for new operations. */
|
|
371
|
+
unwatch() {
|
|
372
|
+
if (this.watchTimer) {
|
|
373
|
+
clearInterval(this.watchTimer);
|
|
374
|
+
this.watchTimer = null;
|
|
308
375
|
}
|
|
309
|
-
|
|
376
|
+
this.watchCallback = null;
|
|
310
377
|
}
|
|
311
378
|
// --- Private mutation implementations ---
|
|
312
379
|
makeOp(type, id, data, prev) {
|
|
@@ -322,6 +389,14 @@ export class Store {
|
|
|
322
389
|
op.agent = this.agentId;
|
|
323
390
|
op.clock = this.clock.tick();
|
|
324
391
|
}
|
|
392
|
+
// Try delta encoding for updates (not creates or deletes)
|
|
393
|
+
if (type === "set" && prev !== null && data !== undefined) {
|
|
394
|
+
const delta = createDelta(prev, data);
|
|
395
|
+
if (delta && isDeltaSmaller(delta, prev)) {
|
|
396
|
+
op.prev = delta;
|
|
397
|
+
op.encoding = "delta";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
325
400
|
return op;
|
|
326
401
|
}
|
|
327
402
|
async _set(id, value) {
|
|
@@ -560,12 +635,23 @@ export class Store {
|
|
|
560
635
|
}
|
|
561
636
|
reverseOp(op) {
|
|
562
637
|
if (op.prev === null) {
|
|
638
|
+
// Was a create — reverse by deleting
|
|
563
639
|
this.records.delete(op.id);
|
|
564
640
|
}
|
|
641
|
+
else if (op.encoding === "delta") {
|
|
642
|
+
// Delta-encoded: apply the reverse patch to the current record
|
|
643
|
+
const current = this.records.get(op.id);
|
|
644
|
+
if (current) {
|
|
645
|
+
const restored = applyDelta(current, op.prev);
|
|
646
|
+
this.records.set(op.id, restored);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
565
649
|
else if (op.op === "delete") {
|
|
650
|
+
// Was a delete — reverse by restoring full prev
|
|
566
651
|
this.records.set(op.id, op.prev);
|
|
567
652
|
}
|
|
568
653
|
else {
|
|
654
|
+
// Was an update — reverse by restoring full prev
|
|
569
655
|
this.records.set(op.id, op.prev);
|
|
570
656
|
}
|
|
571
657
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backloghq/opslog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|