@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.
@@ -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 (multi-writer mode).
51
- * Re-reads the manifest, snapshot, and all agent WAL files.
52
- * Use this to pick up writes from other agents.
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 (multi-writer mode).
301
- * Re-reads the manifest, snapshot, and all agent WAL files.
302
- * Use this to pick up writes from other agents.
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 (!this.isMultiWriter()) {
307
- throw new Error("refresh() is only available in multi-writer mode");
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
- return this.serialize(() => this._refresh());
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.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",