@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.
@@ -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.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",