@backloghq/opslog 0.2.0 → 0.3.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/README.md CHANGED
@@ -54,13 +54,14 @@ State survives restarts — reopen the same directory and everything is there.
54
54
 
55
55
  ```
56
56
  data/
57
- manifest.json # Points to current snapshot + ops file
57
+ manifest.json # Points to current snapshot + ops file(s)
58
58
  snapshots/
59
- snap-<timestamp>.json # Immutable full-state capture
59
+ snap-<timestamp>.json # Immutable full-state capture
60
60
  ops/
61
- ops-<timestamp>.jsonl # Append-only operation log
61
+ ops-<timestamp>.jsonl # Append-only operation log (single-writer)
62
+ agent-<id>-<timestamp>.jsonl # Per-agent operation log (multi-writer)
62
63
  archive/
63
- archive-<period>.json # Old records, lazy-loaded
64
+ archive-<period>.json # Old records, lazy-loaded
64
65
  ```
65
66
 
66
67
  **Writes** append an operation (one JSON line) to the ops file. **Reads** come from an in-memory map built from the latest snapshot + ops replay. **Checkpoints** materialize current state as a new immutable snapshot.
@@ -121,6 +122,7 @@ await store.archive(predicate) // Move matching records to archive
121
122
  await store.loadArchive(segment) // Lazy-load archived records
122
123
  store.listArchiveSegments() // List available archive files
123
124
  store.stats() // { activeRecords, opsCount, archiveSegments }
125
+ await store.refresh() // Reload from all agent WALs (multi-writer only)
124
126
  ```
125
127
 
126
128
  ## Options
@@ -132,9 +134,80 @@ await store.open(dir, {
132
134
  version: 1, // Schema version
133
135
  migrate: (record, fromVersion) => record, // Migration function
134
136
  readOnly: false, // Open in read-only mode (default: false)
137
+ agentId: "agent-A", // Enable multi-writer mode (optional)
138
+ backend: new FsBackend(), // Custom storage backend (optional, default: FsBackend)
135
139
  });
136
140
  ```
137
141
 
142
+ ## Multi-Writer Mode
143
+
144
+ Multiple agents can write to the same store concurrently. Each agent gets its own WAL file — no write contention.
145
+
146
+ ```typescript
147
+ // Agent A (process 1 / machine 1)
148
+ const storeA = new Store<Task>();
149
+ await storeA.open("./data", { agentId: "agent-A" });
150
+ await storeA.set("task-1", { title: "Build API", status: "active" });
151
+ await storeA.close();
152
+
153
+ // Agent B (process 2 / machine 2)
154
+ const storeB = new Store<Task>();
155
+ await storeB.open("./data", { agentId: "agent-B" });
156
+ // B sees A's writes on open
157
+ storeB.get("task-1"); // { title: "Build API", status: "active" }
158
+ await storeB.set("task-2", { title: "Write tests", status: "active" });
159
+ await storeB.close();
160
+ ```
161
+
162
+ ### How it works
163
+
164
+ - Each agent writes to `ops/agent-{id}-{timestamp}.jsonl` — separate files, no locking needed for writes
165
+ - Operations carry a [Lamport clock](https://en.wikipedia.org/wiki/Lamport_timestamp) for ordering
166
+ - On `open()`, all agent WAL files are merge-sorted by `(clock, agentId)` for a deterministic total order
167
+ - Conflicts (two agents write the same key) are resolved with **last-writer-wins** by clock value
168
+ - `undo()` only undoes the calling agent's last operation
169
+ - `compact()` acquires a compaction lock, snapshots the merged state, and resets all WAL files
170
+ - `refresh()` re-reads all agent WALs to pick up other agents' writes
171
+
172
+ ### Conflict resolution
173
+
174
+ When two agents modify the same key, the operation with the higher Lamport clock wins. If clocks are equal, the lexicographically higher agent ID wins. This is deterministic — all agents arrive at the same state regardless of replay order.
175
+
176
+ ```typescript
177
+ // Agent A sets "shared" (clock=1)
178
+ await storeA.set("shared", { value: "from-A" });
179
+
180
+ // Agent B opens (sees clock=1), sets "shared" (clock=2)
181
+ await storeB.set("shared", { value: "from-B" });
182
+
183
+ // B wins — higher clock
184
+ store.get("shared"); // { value: "from-B" }
185
+ ```
186
+
187
+ ## Custom Storage Backend
188
+
189
+ opslog uses a pluggable `StorageBackend` interface for all I/O. The default is `FsBackend` (local filesystem). You can implement your own backend for S3, databases, or other storage systems.
190
+
191
+ ```typescript
192
+ import { Store, FsBackend } from "@backloghq/opslog";
193
+ import type { StorageBackend } from "@backloghq/opslog";
194
+
195
+ // Use the default filesystem backend (implicit)
196
+ const store = new Store();
197
+ await store.open("./data");
198
+
199
+ // Or pass a custom backend explicitly
200
+ const store = new Store();
201
+ await store.open("./data", { backend: new FsBackend() });
202
+
203
+ // Or implement your own
204
+ class S3Backend implements StorageBackend {
205
+ // ... implement all methods
206
+ }
207
+ const store = new Store();
208
+ await store.open("s3://bucket/prefix", { backend: new S3Backend() });
209
+ ```
210
+
138
211
  ## Read-Only Mode
139
212
 
140
213
  Open a store for reading without acquiring the write lock. Useful for dashboards, backup processes, or multiple readers alongside a single writer.
@@ -0,0 +1,30 @@
1
+ import type { LockHandle, Manifest, Operation, StorageBackend } from "./types.js";
2
+ /** Filesystem-backed storage backend. Default backend for opslog. */
3
+ export declare class FsBackend implements StorageBackend {
4
+ private dir;
5
+ initialize(dir: string, opts: {
6
+ readOnly: boolean;
7
+ }): Promise<void>;
8
+ shutdown(): Promise<void>;
9
+ readManifest(): Promise<Manifest | null>;
10
+ writeManifest(manifest: Manifest): Promise<void>;
11
+ writeSnapshot(records: Map<string, unknown>, version: number): Promise<string>;
12
+ loadSnapshot(relativePath: string): Promise<{
13
+ records: Map<string, unknown>;
14
+ version: number;
15
+ }>;
16
+ appendOps(relativePath: string, ops: Operation[]): Promise<void>;
17
+ readOps(relativePath: string): Promise<Operation[]>;
18
+ truncateLastOp(relativePath: string): Promise<boolean>;
19
+ createOpsFile(): Promise<string>;
20
+ writeArchiveSegment(period: string, records: Map<string, unknown>): Promise<string>;
21
+ loadArchiveSegment(relativePath: string): Promise<Map<string, unknown>>;
22
+ listArchiveSegments(): Promise<string[]>;
23
+ acquireLock(): Promise<LockHandle>;
24
+ releaseLock(handle: LockHandle): Promise<void>;
25
+ createAgentOpsFile(agentId: string): Promise<string>;
26
+ listOpsFiles(): Promise<string[]>;
27
+ acquireCompactionLock(): Promise<LockHandle>;
28
+ releaseCompactionLock(handle: LockHandle): Promise<void>;
29
+ getManifestVersion(): Promise<string | null>;
30
+ }
@@ -0,0 +1,134 @@
1
+ import { mkdir, open, readdir, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { appendOp, appendOps, readOps, truncateLastOp } from "./wal.js";
4
+ import { loadSnapshot, writeSnapshot } from "./snapshot.js";
5
+ import { readManifest, writeManifest } from "./manifest.js";
6
+ import { loadArchiveSegment, writeArchiveSegment, listArchiveSegments as fsListArchiveSegments, } from "./archive.js";
7
+ import { acquireLock as fsAcquireLock, releaseLock as fsReleaseLock, } from "./lock.js";
8
+ class FsLockHandle {
9
+ fh;
10
+ dir;
11
+ constructor(fh, dir) {
12
+ this.fh = fh;
13
+ this.dir = dir;
14
+ }
15
+ }
16
+ /** Filesystem-backed storage backend. Default backend for opslog. */
17
+ export class FsBackend {
18
+ dir = "";
19
+ async initialize(dir, opts) {
20
+ this.dir = dir;
21
+ if (!opts.readOnly) {
22
+ await mkdir(join(dir, "snapshots"), { recursive: true });
23
+ await mkdir(join(dir, "ops"), { recursive: true });
24
+ await mkdir(join(dir, "archive"), { recursive: true });
25
+ }
26
+ }
27
+ async shutdown() {
28
+ // No-op for filesystem backend
29
+ }
30
+ // -- Manifest --
31
+ async readManifest() {
32
+ return readManifest(this.dir);
33
+ }
34
+ async writeManifest(manifest) {
35
+ return writeManifest(this.dir, manifest);
36
+ }
37
+ // -- Snapshots --
38
+ async writeSnapshot(records, version) {
39
+ return writeSnapshot(this.dir, records, version);
40
+ }
41
+ async loadSnapshot(relativePath) {
42
+ return loadSnapshot(this.dir, relativePath);
43
+ }
44
+ // -- WAL --
45
+ async appendOps(relativePath, ops) {
46
+ const fullPath = join(this.dir, relativePath);
47
+ if (ops.length === 1) {
48
+ return appendOp(fullPath, ops[0]);
49
+ }
50
+ return appendOps(fullPath, ops);
51
+ }
52
+ async readOps(relativePath) {
53
+ return readOps(join(this.dir, relativePath));
54
+ }
55
+ async truncateLastOp(relativePath) {
56
+ return truncateLastOp(join(this.dir, relativePath));
57
+ }
58
+ async createOpsFile() {
59
+ const filename = `ops-${Date.now()}.jsonl`;
60
+ const relativePath = `ops/${filename}`;
61
+ await writeFile(join(this.dir, relativePath), "", "utf-8");
62
+ return relativePath;
63
+ }
64
+ // -- Archive --
65
+ async writeArchiveSegment(period, records) {
66
+ return writeArchiveSegment(this.dir, period, records);
67
+ }
68
+ async loadArchiveSegment(relativePath) {
69
+ return loadArchiveSegment(this.dir, relativePath);
70
+ }
71
+ async listArchiveSegments() {
72
+ return fsListArchiveSegments(this.dir);
73
+ }
74
+ // -- Locking (single-writer) --
75
+ async acquireLock() {
76
+ const fh = await fsAcquireLock(this.dir);
77
+ return new FsLockHandle(fh, this.dir);
78
+ }
79
+ async releaseLock(handle) {
80
+ const fsHandle = handle;
81
+ return fsReleaseLock(fsHandle.dir, fsHandle.fh);
82
+ }
83
+ // -- Multi-writer extensions --
84
+ async createAgentOpsFile(agentId) {
85
+ const filename = `agent-${agentId}-${Date.now()}.jsonl`;
86
+ const relativePath = `ops/${filename}`;
87
+ await writeFile(join(this.dir, relativePath), "", "utf-8");
88
+ return relativePath;
89
+ }
90
+ async listOpsFiles() {
91
+ const opsDir = join(this.dir, "ops");
92
+ try {
93
+ const files = await readdir(opsDir);
94
+ return files.filter((f) => f.endsWith(".jsonl")).map((f) => `ops/${f}`);
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ }
100
+ async acquireCompactionLock() {
101
+ const lockPath = join(this.dir, ".compact-lock");
102
+ let fh;
103
+ try {
104
+ fh = await open(lockPath, "wx");
105
+ }
106
+ catch (err) {
107
+ if (err.code === "EEXIST") {
108
+ throw new Error("Compaction lock held by another agent", { cause: err });
109
+ }
110
+ throw err;
111
+ }
112
+ await fh.writeFile(String(process.pid), "utf-8");
113
+ return new FsLockHandle(fh, this.dir);
114
+ }
115
+ async releaseCompactionLock(handle) {
116
+ const fsHandle = handle;
117
+ await fsHandle.fh.close();
118
+ try {
119
+ await unlink(join(fsHandle.dir, ".compact-lock"));
120
+ }
121
+ catch {
122
+ // Already cleaned up
123
+ }
124
+ }
125
+ async getManifestVersion() {
126
+ try {
127
+ const s = await stat(join(this.dir, "manifest.json"));
128
+ return s.mtimeMs.toString();
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Lamport logical clock for multi-writer operation ordering.
3
+ * Each agent maintains its own clock. On local events, tick().
4
+ * On receiving remote events, merge(received) to stay ahead.
5
+ * Ties are broken by agent ID (lexicographic) for deterministic total order.
6
+ */
7
+ export declare class LamportClock {
8
+ private counter;
9
+ constructor(initial?: number);
10
+ /** Increment and return the new value (for local events). */
11
+ tick(): number;
12
+ /** Merge with a received clock value and increment. */
13
+ merge(received: number): number;
14
+ /** Current clock value without incrementing. */
15
+ get current(): number;
16
+ }
package/dist/clock.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Lamport logical clock for multi-writer operation ordering.
3
+ * Each agent maintains its own clock. On local events, tick().
4
+ * On receiving remote events, merge(received) to stay ahead.
5
+ * Ties are broken by agent ID (lexicographic) for deterministic total order.
6
+ */
7
+ export class LamportClock {
8
+ counter;
9
+ constructor(initial = 0) {
10
+ this.counter = initial;
11
+ }
12
+ /** Increment and return the new value (for local events). */
13
+ tick() {
14
+ return ++this.counter;
15
+ }
16
+ /** Merge with a received clock value and increment. */
17
+ merge(received) {
18
+ this.counter = Math.max(this.counter, received) + 1;
19
+ return this.counter;
20
+ }
21
+ /** Current clock value without incrementing. */
22
+ get current() {
23
+ return this.counter;
24
+ }
25
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  export { Store } from "./store.js";
2
+ export { FsBackend } from "./backend.js";
3
+ export { LamportClock } from "./clock.js";
2
4
  export { acquireLock, releaseLock } from "./lock.js";
3
- export type { Operation, Snapshot, Manifest, ManifestStats, ArchiveSegment, StoreOptions, StoreStats, } from "./types.js";
5
+ export { validateOp, validateManifest, validateSnapshot, validateArchiveSegment, } from "./validate.js";
6
+ export type { Operation, Snapshot, Manifest, ManifestStats, ArchiveSegment, StoreOptions, StoreStats, StorageBackend, LockHandle, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
1
  export { Store } from "./store.js";
2
+ export { FsBackend } from "./backend.js";
3
+ export { LamportClock } from "./clock.js";
2
4
  export { acquireLock, releaseLock } from "./lock.js";
5
+ export { validateOp, validateManifest, validateSnapshot, validateArchiveSegment, } from "./validate.js";
package/dist/store.d.ts CHANGED
@@ -8,20 +8,26 @@ export declare class Store<T = Record<string, unknown>> {
8
8
  private version;
9
9
  private activeOpsPath;
10
10
  private created;
11
- private options;
11
+ private coreOpts;
12
12
  private archivedRecordCount;
13
13
  private batching;
14
14
  private batchOps;
15
15
  private _lock;
16
- private lockFh;
16
+ private lockHandle;
17
+ private backend;
18
+ private agentId?;
19
+ private clock;
20
+ private manifestVersion;
17
21
  /**
18
22
  * Serialize all state-mutating operations through a promise chain.
19
- * This prevents interleaving of async mutations (e.g. compact + set,
20
- * undo + set) which could corrupt the WAL or in-memory state.
21
- * Read operations remain synchronous and lock-free.
23
+ * Prevents interleaving of async mutations. Reads remain synchronous and lock-free.
22
24
  */
23
25
  private serialize;
26
+ private isMultiWriter;
24
27
  open(dir: string, options?: StoreOptions): Promise<void>;
28
+ private initFreshStore;
29
+ private loadExistingStore;
30
+ private loadMultiWriterOps;
25
31
  close(): Promise<void>;
26
32
  get(id: string): T | undefined;
27
33
  set(id: string, value: T): Promise<void> | void;
@@ -40,14 +46,24 @@ export declare class Store<T = Record<string, unknown>> {
40
46
  listArchiveSegments(): string[];
41
47
  loadArchive(segment: string): Promise<Map<string, T>>;
42
48
  stats(): StoreStats;
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.
53
+ */
54
+ refresh(): Promise<void>;
55
+ private makeOp;
43
56
  private _set;
44
57
  private _setSync;
45
58
  private _delete;
46
59
  private _deleteSync;
47
60
  private _batch;
48
61
  private _undo;
62
+ private _undoMultiWriter;
49
63
  private _compact;
64
+ private _compactMultiWriter;
50
65
  private _archive;
66
+ private _refresh;
51
67
  private ensureOpen;
52
68
  private ensureWritable;
53
69
  private applyOp;
package/dist/store.js CHANGED
@@ -1,10 +1,6 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { appendOp, appendOps, readOps, truncateLastOp } from "./wal.js";
4
- import { loadSnapshot, writeSnapshot } from "./snapshot.js";
5
- import { createDefaultManifest, readManifest, writeManifest, } from "./manifest.js";
6
- import { loadArchiveSegment, writeArchiveSegment, } from "./archive.js";
7
- import { acquireLock, releaseLock } from "./lock.js";
1
+ import { createDefaultManifest } from "./manifest.js";
2
+ import { FsBackend } from "./backend.js";
3
+ import { LamportClock } from "./clock.js";
8
4
  export class Store {
9
5
  dir = "";
10
6
  records = new Map();
@@ -14,7 +10,7 @@ export class Store {
14
10
  version = 1;
15
11
  activeOpsPath = "";
16
12
  created = "";
17
- options = {
13
+ coreOpts = {
18
14
  checkpointThreshold: 100,
19
15
  checkpointOnClose: true,
20
16
  version: 1,
@@ -25,12 +21,15 @@ export class Store {
25
21
  batching = false;
26
22
  batchOps = [];
27
23
  _lock = Promise.resolve();
28
- lockFh = null;
24
+ lockHandle = null;
25
+ backend;
26
+ // Multi-writer state
27
+ agentId;
28
+ clock = null;
29
+ manifestVersion = null;
29
30
  /**
30
31
  * Serialize all state-mutating operations through a promise chain.
31
- * This prevents interleaving of async mutations (e.g. compact + set,
32
- * undo + set) which could corrupt the WAL or in-memory state.
33
- * Read operations remain synchronous and lock-free.
32
+ * Prevents interleaving of async mutations. Reads remain synchronous and lock-free.
34
33
  */
35
34
  serialize(fn) {
36
35
  const prev = this._lock;
@@ -40,79 +39,158 @@ export class Store {
40
39
  });
41
40
  return prev.then(fn).finally(() => resolve());
42
41
  }
42
+ isMultiWriter() {
43
+ return this.agentId !== undefined;
44
+ }
43
45
  async open(dir, options) {
44
46
  this.dir = dir;
45
47
  if (options) {
46
- this.options = { ...this.options, ...options };
48
+ const { backend, agentId, ...rest } = options;
49
+ this.coreOpts = { ...this.coreOpts, ...rest };
50
+ if (backend)
51
+ this.backend = backend;
52
+ if (agentId)
53
+ this.agentId = agentId;
47
54
  }
48
- if (!this.options.readOnly) {
49
- await mkdir(join(dir, "snapshots"), { recursive: true });
50
- await mkdir(join(dir, "ops"), { recursive: true });
51
- await mkdir(join(dir, "archive"), { recursive: true });
52
- this.lockFh = await acquireLock(dir);
55
+ this.backend ??= new FsBackend();
56
+ await this.backend.initialize(dir, { readOnly: this.coreOpts.readOnly });
57
+ // Acquire write lock (single-writer only, not readOnly)
58
+ if (!this.coreOpts.readOnly && !this.isMultiWriter()) {
59
+ this.lockHandle = await this.backend.acquireLock();
53
60
  }
54
- const manifest = await readManifest(dir);
61
+ const manifest = await this.backend.readManifest();
55
62
  if (!manifest) {
56
- if (this.options.readOnly) {
63
+ if (this.coreOpts.readOnly) {
57
64
  throw new Error("Cannot open in readOnly mode: no existing store found");
58
65
  }
59
- // Fresh store — create empty snapshot and manifest
60
- const snapshotPath = await writeSnapshot(dir, new Map(), this.options.version);
61
- const opsFilename = `ops-${Date.now()}.jsonl`;
62
- const opsPath = `ops/${opsFilename}`;
63
- await writeFile(join(dir, opsPath), "", "utf-8");
64
- const newManifest = createDefaultManifest(snapshotPath, opsPath);
65
- await writeManifest(dir, newManifest);
66
- this.version = this.options.version;
67
- this.activeOpsPath = opsPath;
68
- this.created = newManifest.stats.created;
69
- this.archiveSegments = [];
66
+ await this.initFreshStore();
70
67
  }
71
68
  else {
72
- // Load existing state
73
- let snapshotData;
74
- try {
75
- snapshotData = await loadSnapshot(dir, manifest.currentSnapshot);
76
- }
77
- catch (err) {
78
- const isNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
79
- if (isNotFound) {
80
- throw new Error(`Snapshot file not found: ${manifest.currentSnapshot}. The data directory may be corrupted.`, { cause: err });
81
- }
82
- throw err;
69
+ await this.loadExistingStore(manifest);
70
+ }
71
+ this.manifestVersion = await this.backend.getManifestVersion();
72
+ this.opened = true;
73
+ }
74
+ async initFreshStore() {
75
+ const snapshotPath = await this.backend.writeSnapshot(new Map(), this.coreOpts.version);
76
+ let opsPath;
77
+ if (this.isMultiWriter()) {
78
+ opsPath = await this.backend.createAgentOpsFile(this.agentId);
79
+ }
80
+ else {
81
+ opsPath = await this.backend.createOpsFile();
82
+ }
83
+ const newManifest = createDefaultManifest(snapshotPath, opsPath);
84
+ if (this.isMultiWriter()) {
85
+ newManifest.activeAgentOps = { [this.agentId]: opsPath };
86
+ }
87
+ await this.backend.writeManifest(newManifest);
88
+ this.version = this.coreOpts.version;
89
+ this.activeOpsPath = opsPath;
90
+ this.created = newManifest.stats.created;
91
+ this.archiveSegments = [];
92
+ if (this.isMultiWriter()) {
93
+ this.clock = new LamportClock(0);
94
+ }
95
+ }
96
+ async loadExistingStore(manifest) {
97
+ // Load snapshot
98
+ let snapshotData;
99
+ try {
100
+ snapshotData = await this.backend.loadSnapshot(manifest.currentSnapshot);
101
+ }
102
+ catch (err) {
103
+ const isNotFound = err instanceof Error &&
104
+ "code" in err &&
105
+ err.code === "ENOENT";
106
+ if (isNotFound) {
107
+ throw new Error(`Snapshot file not found: ${manifest.currentSnapshot}. The data directory may be corrupted.`, { cause: err });
83
108
  }
84
- const { records, version: storedVersion } = snapshotData;
85
- this.records = records;
86
- this.version = storedVersion;
87
- this.activeOpsPath = manifest.activeOps;
88
- this.created = manifest.stats.created;
89
- this.archiveSegments = manifest.archiveSegments;
90
- this.archivedRecordCount = manifest.stats.archivedRecords;
91
- // Migrate if needed
92
- if (storedVersion < this.options.version) {
93
- for (const [id, record] of this.records) {
94
- this.records.set(id, this.options.migrate(record, storedVersion));
95
- }
96
- this.version = this.options.version;
109
+ throw err;
110
+ }
111
+ const { records, version: storedVersion } = snapshotData;
112
+ this.records = records;
113
+ this.version = storedVersion;
114
+ this.created = manifest.stats.created;
115
+ this.archiveSegments = manifest.archiveSegments;
116
+ this.archivedRecordCount = manifest.stats.archivedRecords;
117
+ // Migrate if needed
118
+ if (storedVersion < this.coreOpts.version) {
119
+ for (const [id, record] of this.records) {
120
+ this.records.set(id, this.coreOpts.migrate(record, storedVersion));
97
121
  }
98
- // Replay ops
99
- const ops = await readOps(join(dir, manifest.activeOps));
122
+ this.version = this.coreOpts.version;
123
+ }
124
+ if (this.isMultiWriter()) {
125
+ await this.loadMultiWriterOps(manifest);
126
+ }
127
+ else {
128
+ // Single-writer: replay ops from active ops file
129
+ const ops = (await this.backend.readOps(manifest.activeOps));
100
130
  for (const op of ops) {
101
131
  this.applyOp(op);
102
132
  }
103
133
  this.ops = ops;
134
+ this.activeOpsPath = manifest.activeOps;
135
+ }
136
+ }
137
+ async loadMultiWriterOps(manifest) {
138
+ const allOps = [];
139
+ // Read all agent ops files
140
+ if (manifest.activeAgentOps) {
141
+ for (const opsPath of Object.values(manifest.activeAgentOps)) {
142
+ const ops = (await this.backend.readOps(opsPath));
143
+ allOps.push(...ops);
144
+ }
145
+ }
146
+ // Also read legacy single-writer ops for backward compat
147
+ if (manifest.activeOps && !manifest.activeAgentOps) {
148
+ const ops = (await this.backend.readOps(manifest.activeOps));
149
+ allOps.push(...ops);
150
+ }
151
+ // Merge-sort by (clock, agent) for deterministic total order
152
+ allOps.sort((a, b) => {
153
+ const clockDiff = (a.clock ?? 0) - (b.clock ?? 0);
154
+ if (clockDiff !== 0)
155
+ return clockDiff;
156
+ return (a.agent ?? "").localeCompare(b.agent ?? "");
157
+ });
158
+ for (const op of allOps) {
159
+ this.applyOp(op);
160
+ }
161
+ this.ops = allOps;
162
+ // Initialize Lamport clock from max seen value
163
+ const maxClock = allOps.reduce((max, op) => Math.max(max, op.clock ?? 0), 0);
164
+ this.clock = new LamportClock(maxClock);
165
+ // Find or create our agent's ops file
166
+ if (manifest.activeAgentOps?.[this.agentId]) {
167
+ this.activeOpsPath = manifest.activeAgentOps[this.agentId];
168
+ }
169
+ else {
170
+ // Register this agent in the manifest
171
+ this.activeOpsPath = await this.backend.createAgentOpsFile(this.agentId);
172
+ const updatedManifest = {
173
+ ...manifest,
174
+ activeAgentOps: {
175
+ ...(manifest.activeAgentOps ?? {}),
176
+ [this.agentId]: this.activeOpsPath,
177
+ },
178
+ };
179
+ await this.backend.writeManifest(updatedManifest);
104
180
  }
105
- this.opened = true;
106
181
  }
107
182
  async close() {
108
183
  this.ensureOpen();
109
- if (!this.options.readOnly && this.options.checkpointOnClose && this.ops.length > 0) {
184
+ if (!this.coreOpts.readOnly &&
185
+ this.coreOpts.checkpointOnClose &&
186
+ this.ops.length > 0) {
110
187
  await this.serialize(() => this._compact());
111
188
  }
112
- if (this.lockFh) {
113
- await releaseLock(this.dir, this.lockFh);
114
- this.lockFh = null;
189
+ if (this.lockHandle) {
190
+ await this.backend.releaseLock(this.lockHandle);
191
+ this.lockHandle = null;
115
192
  }
193
+ await this.backend.shutdown();
116
194
  this.opened = false;
117
195
  }
118
196
  get(id) {
@@ -208,7 +286,7 @@ export class Store {
208
286
  const segmentPath = this.archiveSegments.find((s) => s === `archive/archive-${segment}.json`) || this.archiveSegments.find((s) => s.includes(segment));
209
287
  if (!segmentPath)
210
288
  throw new Error(`Archive segment '${segment}' not found`);
211
- return loadArchiveSegment(this.dir, segmentPath);
289
+ return this.backend.loadArchiveSegment(segmentPath);
212
290
  }
213
291
  stats() {
214
292
  this.ensureOpen();
@@ -218,28 +296,43 @@ export class Store {
218
296
  archiveSegments: this.archiveSegments.length,
219
297
  };
220
298
  }
299
+ /**
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.
303
+ */
304
+ async refresh() {
305
+ this.ensureOpen();
306
+ if (!this.isMultiWriter()) {
307
+ throw new Error("refresh() is only available in multi-writer mode");
308
+ }
309
+ return this.serialize(() => this._refresh());
310
+ }
221
311
  // --- Private mutation implementations ---
222
- async _set(id, value) {
223
- const prev = this.records.get(id) ?? null;
312
+ makeOp(type, id, data, prev) {
224
313
  const op = {
225
314
  ts: new Date().toISOString(),
226
- op: "set",
315
+ op: type,
227
316
  id,
228
- data: value,
229
317
  prev,
230
318
  };
319
+ if (type === "set")
320
+ op.data = data;
321
+ if (this.agentId) {
322
+ op.agent = this.agentId;
323
+ op.clock = this.clock.tick();
324
+ }
325
+ return op;
326
+ }
327
+ async _set(id, value) {
328
+ const prev = this.records.get(id) ?? null;
329
+ const op = this.makeOp("set", id, value, prev);
231
330
  this.records.set(id, value);
232
331
  await this.persistOp(op);
233
332
  }
234
333
  _setSync(id, value) {
235
334
  const prev = this.records.get(id) ?? null;
236
- const op = {
237
- ts: new Date().toISOString(),
238
- op: "set",
239
- id,
240
- data: value,
241
- prev,
242
- };
335
+ const op = this.makeOp("set", id, value, prev);
243
336
  this.records.set(id, value);
244
337
  this.batchOps.push(op);
245
338
  }
@@ -248,12 +341,7 @@ export class Store {
248
341
  if (prev === undefined) {
249
342
  throw new Error(`Record '${id}' not found`);
250
343
  }
251
- const op = {
252
- ts: new Date().toISOString(),
253
- op: "delete",
254
- id,
255
- prev,
256
- };
344
+ const op = this.makeOp("delete", id, undefined, prev);
257
345
  this.records.delete(id);
258
346
  await this.persistOp(op);
259
347
  }
@@ -262,12 +350,7 @@ export class Store {
262
350
  if (prev === undefined) {
263
351
  throw new Error(`Record '${id}' not found`);
264
352
  }
265
- const op = {
266
- ts: new Date().toISOString(),
267
- op: "delete",
268
- id,
269
- prev,
270
- };
353
+ const op = this.makeOp("delete", id, undefined, prev);
271
354
  this.records.delete(id);
272
355
  this.batchOps.push(op);
273
356
  }
@@ -276,17 +359,15 @@ export class Store {
276
359
  this.batchOps = [];
277
360
  try {
278
361
  fn();
279
- // Empty batches are no-ops — no I/O if fn() didn't call set/delete
280
362
  if (this.batchOps.length > 0) {
281
- await appendOps(join(this.dir, this.activeOpsPath), this.batchOps);
363
+ await this.backend.appendOps(this.activeOpsPath, this.batchOps);
282
364
  this.ops.push(...this.batchOps);
283
- if (this.ops.length >= this.options.checkpointThreshold) {
365
+ if (this.ops.length >= this.coreOpts.checkpointThreshold) {
284
366
  await this._compact();
285
367
  }
286
368
  }
287
369
  }
288
370
  catch (err) {
289
- // Rollback in-memory changes on failure
290
371
  for (const op of this.batchOps.reverse()) {
291
372
  try {
292
373
  this.reverseOp(op);
@@ -303,19 +384,37 @@ export class Store {
303
384
  }
304
385
  }
305
386
  async _undo() {
387
+ if (this.isMultiWriter()) {
388
+ return this._undoMultiWriter();
389
+ }
390
+ // Single-writer: O(1) undo
306
391
  if (this.ops.length === 0)
307
392
  return false;
308
393
  const lastOp = this.ops[this.ops.length - 1];
309
394
  this.reverseOp(lastOp);
310
395
  this.ops.pop();
311
- await truncateLastOp(join(this.dir, this.activeOpsPath));
396
+ await this.backend.truncateLastOp(this.activeOpsPath);
397
+ return true;
398
+ }
399
+ async _undoMultiWriter() {
400
+ // Find last op from this agent
401
+ const myOps = this.ops.filter((op) => op.agent === this.agentId);
402
+ if (myOps.length === 0)
403
+ return false;
404
+ // Truncate our WAL file
405
+ await this.backend.truncateLastOp(this.activeOpsPath);
406
+ // Re-derive state from scratch (correct but O(n))
407
+ await this._refresh();
312
408
  return true;
313
409
  }
314
410
  async _compact() {
315
- const snapshotPath = await writeSnapshot(this.dir, this.records, this.version);
316
- const opsFilename = `ops-${Date.now()}.jsonl`;
317
- const opsPath = `ops/${opsFilename}`;
318
- await writeFile(join(this.dir, opsPath), "", "utf-8");
411
+ if (this.isMultiWriter()) {
412
+ await this._compactMultiWriter();
413
+ return;
414
+ }
415
+ // Single-writer compaction
416
+ const snapshotPath = await this.backend.writeSnapshot(this.records, this.version);
417
+ const opsPath = await this.backend.createOpsFile();
319
418
  const updatedManifest = {
320
419
  version: this.version,
321
420
  currentSnapshot: snapshotPath,
@@ -329,10 +428,45 @@ export class Store {
329
428
  lastCheckpoint: new Date().toISOString(),
330
429
  },
331
430
  };
332
- await writeManifest(this.dir, updatedManifest);
431
+ await this.backend.writeManifest(updatedManifest);
333
432
  this.activeOpsPath = opsPath;
334
433
  this.ops = [];
335
434
  }
435
+ async _compactMultiWriter() {
436
+ let compactLock;
437
+ try {
438
+ compactLock = await this.backend.acquireCompactionLock();
439
+ }
440
+ catch {
441
+ // Another agent is compacting — skip
442
+ return;
443
+ }
444
+ try {
445
+ const snapshotPath = await this.backend.writeSnapshot(this.records, this.version);
446
+ const opsPath = await this.backend.createAgentOpsFile(this.agentId);
447
+ const updatedManifest = {
448
+ version: this.version,
449
+ currentSnapshot: snapshotPath,
450
+ activeOps: opsPath,
451
+ activeAgentOps: { [this.agentId]: opsPath },
452
+ archiveSegments: this.archiveSegments,
453
+ stats: {
454
+ activeRecords: this.records.size,
455
+ archivedRecords: this.archivedRecordCount,
456
+ opsCount: 0,
457
+ created: this.created,
458
+ lastCheckpoint: new Date().toISOString(),
459
+ },
460
+ };
461
+ await this.backend.writeManifest(updatedManifest);
462
+ this.activeOpsPath = opsPath;
463
+ this.ops = [];
464
+ this.manifestVersion = await this.backend.getManifestVersion();
465
+ }
466
+ finally {
467
+ await this.backend.releaseCompactionLock(compactLock);
468
+ }
469
+ }
336
470
  async _archive(predicate, segment) {
337
471
  const toArchive = new Map();
338
472
  for (const [id, value] of this.records) {
@@ -342,7 +476,7 @@ export class Store {
342
476
  if (toArchive.size === 0)
343
477
  return 0;
344
478
  const period = segment ?? this.defaultPeriod();
345
- const segmentPath = await writeArchiveSegment(this.dir, period, toArchive);
479
+ const segmentPath = await this.backend.writeArchiveSegment(period, toArchive);
346
480
  if (!this.archiveSegments.includes(segmentPath)) {
347
481
  this.archiveSegments.push(segmentPath);
348
482
  }
@@ -353,13 +487,67 @@ export class Store {
353
487
  await this._compact();
354
488
  return toArchive.size;
355
489
  }
490
+ async _refresh() {
491
+ const manifest = await this.backend.readManifest();
492
+ if (!manifest)
493
+ throw new Error("Manifest not found during refresh");
494
+ const { records, version } = await this.backend.loadSnapshot(manifest.currentSnapshot);
495
+ this.records = records;
496
+ this.version = version;
497
+ this.archiveSegments = manifest.archiveSegments;
498
+ this.archivedRecordCount = manifest.stats.archivedRecords;
499
+ this.created = manifest.stats.created;
500
+ // Read all agent ops
501
+ const allOps = [];
502
+ if (manifest.activeAgentOps) {
503
+ for (const opsPath of Object.values(manifest.activeAgentOps)) {
504
+ const ops = await this.backend.readOps(opsPath);
505
+ allOps.push(...ops);
506
+ }
507
+ }
508
+ // Legacy single-writer ops
509
+ if (manifest.activeOps && !manifest.activeAgentOps) {
510
+ const ops = await this.backend.readOps(manifest.activeOps);
511
+ allOps.push(...ops);
512
+ }
513
+ // Merge-sort
514
+ allOps.sort((a, b) => {
515
+ const clockDiff = (a.clock ?? 0) - (b.clock ?? 0);
516
+ if (clockDiff !== 0)
517
+ return clockDiff;
518
+ return (a.agent ?? "").localeCompare(b.agent ?? "");
519
+ });
520
+ for (const op of allOps)
521
+ this.applyOp(op);
522
+ this.ops = allOps;
523
+ // Update clock
524
+ const maxClock = allOps.reduce((max, op) => Math.max(max, op.clock ?? 0), 0);
525
+ this.clock = new LamportClock(maxClock);
526
+ // Update our ops path if manifest changed
527
+ if (manifest.activeAgentOps?.[this.agentId]) {
528
+ this.activeOpsPath = manifest.activeAgentOps[this.agentId];
529
+ }
530
+ else {
531
+ // Our ops file is not in the manifest (compaction happened)
532
+ this.activeOpsPath = await this.backend.createAgentOpsFile(this.agentId);
533
+ const updatedManifest = {
534
+ ...manifest,
535
+ activeAgentOps: {
536
+ ...(manifest.activeAgentOps ?? {}),
537
+ [this.agentId]: this.activeOpsPath,
538
+ },
539
+ };
540
+ await this.backend.writeManifest(updatedManifest);
541
+ }
542
+ this.manifestVersion = await this.backend.getManifestVersion();
543
+ }
356
544
  // --- Helpers ---
357
545
  ensureOpen() {
358
546
  if (!this.opened)
359
547
  throw new Error("Store is not open. Call open() first.");
360
548
  }
361
549
  ensureWritable() {
362
- if (this.options.readOnly)
550
+ if (this.coreOpts.readOnly)
363
551
  throw new Error("Store is read-only. Cannot perform mutations.");
364
552
  }
365
553
  applyOp(op) {
@@ -372,22 +560,19 @@ export class Store {
372
560
  }
373
561
  reverseOp(op) {
374
562
  if (op.prev === null) {
375
- // Was a create — reverse by deleting
376
563
  this.records.delete(op.id);
377
564
  }
378
565
  else if (op.op === "delete") {
379
- // Was a delete — reverse by restoring
380
566
  this.records.set(op.id, op.prev);
381
567
  }
382
568
  else {
383
- // Was an update — reverse by restoring prev
384
569
  this.records.set(op.id, op.prev);
385
570
  }
386
571
  }
387
572
  async persistOp(op) {
388
- await appendOp(join(this.dir, this.activeOpsPath), op);
573
+ await this.backend.appendOps(this.activeOpsPath, [op]);
389
574
  this.ops.push(op);
390
- if (this.ops.length >= this.options.checkpointThreshold) {
575
+ if (this.ops.length >= this.coreOpts.checkpointThreshold) {
391
576
  await this._compact();
392
577
  }
393
578
  }
package/dist/types.d.ts CHANGED
@@ -11,6 +11,10 @@ export interface Operation<T = Record<string, unknown>> {
11
11
  prev: T | null;
12
12
  /** Encoding format for prev field. Omitted or "full" = full record. "delta" = JSON Patch (future). */
13
13
  encoding?: "full" | "delta";
14
+ /** Agent ID (present in multi-writer mode) */
15
+ agent?: string;
16
+ /** Lamport clock value (present in multi-writer mode) */
17
+ clock?: number;
14
18
  }
15
19
  export interface Snapshot<T = Record<string, unknown>> {
16
20
  version: number;
@@ -21,6 +25,8 @@ export interface Manifest {
21
25
  version: number;
22
26
  currentSnapshot: string;
23
27
  activeOps: string;
28
+ /** Per-agent ops file paths (multi-writer mode). Keys are agent IDs. */
29
+ activeAgentOps?: Record<string, string>;
24
30
  archiveSegments: string[];
25
31
  stats: ManifestStats;
26
32
  }
@@ -48,9 +54,46 @@ export interface StoreOptions {
48
54
  migrate?: (record: unknown, fromVersion: number) => unknown;
49
55
  /** Open in read-only mode: skips directory lock, rejects all mutations. */
50
56
  readOnly?: boolean;
57
+ /** Storage backend implementation (default: FsBackend). */
58
+ backend?: StorageBackend;
59
+ /** Agent ID for multi-writer mode. Enables per-agent WAL streams and LWW conflict resolution. */
60
+ agentId?: string;
51
61
  }
52
62
  export interface StoreStats {
53
63
  activeRecords: number;
54
64
  opsCount: number;
55
65
  archiveSegments: number;
56
66
  }
67
+ /** Opaque lock handle returned by StorageBackend locking methods. */
68
+ export interface LockHandle {
69
+ }
70
+ /** Pluggable storage backend for opslog. */
71
+ export interface StorageBackend {
72
+ /** Initialize the backend (create directories, etc.). Called once during store.open(). */
73
+ initialize(dir: string, opts: {
74
+ readOnly: boolean;
75
+ }): Promise<void>;
76
+ /** Shut down the backend. Called during store.close(). */
77
+ shutdown(): Promise<void>;
78
+ readManifest(): Promise<Manifest | null>;
79
+ writeManifest(manifest: Manifest): Promise<void>;
80
+ writeSnapshot(records: Map<string, unknown>, version: number): Promise<string>;
81
+ loadSnapshot(relativePath: string): Promise<{
82
+ records: Map<string, unknown>;
83
+ version: number;
84
+ }>;
85
+ appendOps(relativePath: string, ops: Operation[]): Promise<void>;
86
+ readOps(relativePath: string): Promise<Operation[]>;
87
+ truncateLastOp(relativePath: string): Promise<boolean>;
88
+ createOpsFile(): Promise<string>;
89
+ writeArchiveSegment(period: string, records: Map<string, unknown>): Promise<string>;
90
+ loadArchiveSegment(relativePath: string): Promise<Map<string, unknown>>;
91
+ listArchiveSegments(): Promise<string[]>;
92
+ acquireLock(): Promise<LockHandle>;
93
+ releaseLock(handle: LockHandle): Promise<void>;
94
+ createAgentOpsFile(agentId: string): Promise<string>;
95
+ listOpsFiles(): Promise<string[]>;
96
+ acquireCompactionLock(): Promise<LockHandle>;
97
+ releaseCompactionLock(handle: LockHandle): Promise<void>;
98
+ getManifestVersion(): Promise<string | null>;
99
+ }
package/dist/validate.js CHANGED
@@ -24,6 +24,14 @@ export function validateOp(raw) {
24
24
  if ("encoding" in obj && obj.encoding !== "full" && obj.encoding !== "delta") {
25
25
  throw new Error(`Invalid operation: encoding must be "full" or "delta", got "${obj.encoding}"`);
26
26
  }
27
+ if ("agent" in obj && (typeof obj.agent !== "string" || obj.agent.length === 0)) {
28
+ throw new Error("Invalid operation: agent must be a non-empty string");
29
+ }
30
+ if ("clock" in obj) {
31
+ if (typeof obj.clock !== "number" || !Number.isFinite(obj.clock) || !Number.isInteger(obj.clock) || obj.clock < 0) {
32
+ throw new Error("Invalid operation: clock must be a non-negative integer");
33
+ }
34
+ }
27
35
  return raw;
28
36
  }
29
37
  export function validateManifest(raw) {
@@ -58,6 +66,16 @@ export function validateManifest(raw) {
58
66
  throw new Error("Invalid manifest: stats.created must be a non-empty string");
59
67
  if (typeof stats.lastCheckpoint !== "string" || stats.lastCheckpoint.length === 0)
60
68
  throw new Error("Invalid manifest: stats.lastCheckpoint must be a non-empty string");
69
+ if ("activeAgentOps" in obj && obj.activeAgentOps !== undefined) {
70
+ if (typeof obj.activeAgentOps !== "object" || obj.activeAgentOps === null || Array.isArray(obj.activeAgentOps)) {
71
+ throw new Error("Invalid manifest: activeAgentOps must be an object");
72
+ }
73
+ for (const [, val] of Object.entries(obj.activeAgentOps)) {
74
+ if (typeof val !== "string" || val.length === 0) {
75
+ throw new Error("Invalid manifest: activeAgentOps values must be non-empty strings");
76
+ }
77
+ }
78
+ }
61
79
  return raw;
62
80
  }
63
81
  export function validateSnapshot(raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backloghq/opslog",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",