@backloghq/opslog 0.6.0 → 0.7.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/README.md CHANGED
@@ -56,7 +56,7 @@ State survives restarts — reopen the same directory and everything is there.
56
56
  data/
57
57
  manifest.json # Points to current snapshot + ops file(s)
58
58
  snapshots/
59
- snap-<timestamp>.json # Immutable full-state capture
59
+ snap-<timestamp>.jsonl # Immutable full-state capture (JSONL: header + one line per record)
60
60
  ops/
61
61
  ops-<timestamp>.jsonl # Append-only operation log (single-writer)
62
62
  agent-<id>-<timestamp>.jsonl # Per-agent operation log (multi-writer)
@@ -93,6 +93,21 @@ store.all() // All records
93
93
  store.entries() // All [id, record] pairs
94
94
  store.filter(predicate) // Records matching predicate
95
95
  store.count(predicate?) // Count (all or matching)
96
+ store.getManifest() // Read-only ManifestInfo (snapshot/WAL paths, stats)
97
+ ```
98
+
99
+ ### Streaming (for external consumers)
100
+
101
+ ```typescript
102
+ // Stream snapshot records without loading all into memory
103
+ for await (const [id, record] of store.streamSnapshot()) {
104
+ process(id, record);
105
+ }
106
+
107
+ // Read WAL operations (optionally since a timestamp)
108
+ for await (const op of store.getWalOps(sinceTimestamp?)) {
109
+ // op: { ts, op: "set"|"delete", id, data?, prev }
110
+ }
96
111
  ```
97
112
 
98
113
  ### Batch
@@ -134,6 +149,7 @@ await store.open(dir, {
134
149
  version: 1, // Schema version
135
150
  migrate: (record, fromVersion) => record, // Migration function
136
151
  readOnly: false, // Open in read-only mode (default: false)
152
+ skipLoad: false, // Skip loading snapshot/WAL into memory (default: false)
137
153
  writeMode: "immediate", // "immediate" (default), "group" (~12x faster), or "async" (~50x faster, lossy on crash)
138
154
  groupCommitSize: 50, // Group: flush after N ops (default: 50)
139
155
  groupCommitMs: 100, // Group: flush after N ms (default: 100)
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { FsBackend } from "./backend.js";
3
3
  export { LamportClock } from "./clock.js";
4
4
  export { createDelta, applyDelta, isDeltaSmaller } from "./delta.js";
5
5
  export type { DeltaPatch } from "./delta.js";
6
+ export { streamSnapshotFile } from "./snapshot.js";
6
7
  export { acquireLock, releaseLock } from "./lock.js";
7
8
  export { validateOp, validateManifest, validateSnapshot, validateArchiveSegment, } from "./validate.js";
8
- export type { Operation, Snapshot, Manifest, ManifestStats, ArchiveSegment, StoreOptions, StoreStats, StorageBackend, LockHandle, } from "./types.js";
9
+ export type { Operation, Snapshot, Manifest, ManifestInfo, ManifestStats, ArchiveSegment, StoreOptions, StoreStats, StorageBackend, LockHandle, } from "./types.js";
package/dist/index.js CHANGED
@@ -2,5 +2,6 @@ export { Store } from "./store.js";
2
2
  export { FsBackend } from "./backend.js";
3
3
  export { LamportClock } from "./clock.js";
4
4
  export { createDelta, applyDelta, isDeltaSmaller } from "./delta.js";
5
+ export { streamSnapshotFile } from "./snapshot.js";
5
6
  export { acquireLock, releaseLock } from "./lock.js";
6
7
  export { validateOp, validateManifest, validateSnapshot, validateArchiveSegment, } from "./validate.js";
@@ -3,3 +3,11 @@ export declare function loadSnapshot<T>(dir: string, relativePath: string): Prom
3
3
  records: Map<string, T>;
4
4
  version: number;
5
5
  }>;
6
+ /**
7
+ * Stream snapshot records line by line using readline.
8
+ * True streaming — only one record in memory at a time.
9
+ * Supports both JSONL and legacy JSON formats.
10
+ */
11
+ export declare function streamSnapshotFile<T>(dir: string, relativePath: string): AsyncGenerator<[string, T], {
12
+ version: number;
13
+ }>;
package/dist/snapshot.js CHANGED
@@ -1,24 +1,89 @@
1
- import { readFile, rename, writeFile } from "node:fs/promises";
1
+ import { createReadStream, createWriteStream } from "node:fs";
2
+ import { readFile, rename } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
2
4
  import { join } from "node:path";
3
5
  import { validateSnapshot } from "./validate.js";
4
6
  export async function writeSnapshot(dir, records, version) {
5
7
  const timestamp = new Date().toISOString();
6
- const filename = `snap-${Date.now()}.json`;
8
+ const filename = `snap-${Date.now()}.jsonl`;
7
9
  const path = join(dir, "snapshots", filename);
8
- const snapshot = {
9
- version,
10
- timestamp,
11
- records: Object.fromEntries(records),
12
- };
10
+ // Stream line-by-line to avoid V8 string limit at large record counts
13
11
  const tmpPath = path + ".tmp";
14
- await writeFile(tmpPath, JSON.stringify(snapshot, null, 2), "utf-8");
12
+ await new Promise((resolve, reject) => {
13
+ const ws = createWriteStream(tmpPath, "utf-8");
14
+ ws.on("error", reject);
15
+ ws.write(JSON.stringify({ version, timestamp }) + "\n");
16
+ for (const [id, data] of records) {
17
+ ws.write(JSON.stringify({ id, data }) + "\n");
18
+ }
19
+ ws.end(() => resolve());
20
+ });
15
21
  await rename(tmpPath, path);
16
22
  return `snapshots/${filename}`;
17
23
  }
18
24
  export async function loadSnapshot(dir, relativePath) {
19
25
  const path = join(dir, relativePath);
20
26
  const content = await readFile(path, "utf-8");
21
- const snapshot = validateSnapshot(JSON.parse(content));
22
- const records = new Map(Object.entries(snapshot.records));
23
- return { records, version: snapshot.version };
27
+ // Detect format: JSONL (first line is header without "records" key) vs legacy JSON
28
+ const firstNewline = content.indexOf("\n");
29
+ const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline);
30
+ const parsed = JSON.parse(firstLine);
31
+ if ("records" in parsed) {
32
+ // Legacy monolithic JSON format
33
+ const snapshot = validateSnapshot(parsed);
34
+ const records = new Map(Object.entries(snapshot.records));
35
+ return { records, version: snapshot.version };
36
+ }
37
+ // JSONL format: first line is header, remaining lines are records
38
+ const header = parsed;
39
+ const records = new Map();
40
+ const lines = content.split("\n");
41
+ for (let i = 1; i < lines.length; i++) {
42
+ const line = lines[i].trim();
43
+ if (!line)
44
+ continue;
45
+ const entry = JSON.parse(line);
46
+ records.set(entry.id, entry.data);
47
+ }
48
+ return { records, version: header.version };
49
+ }
50
+ /**
51
+ * Stream snapshot records line by line using readline.
52
+ * True streaming — only one record in memory at a time.
53
+ * Supports both JSONL and legacy JSON formats.
54
+ */
55
+ export async function* streamSnapshotFile(dir, relativePath) {
56
+ const path = join(dir, relativePath);
57
+ // Peek at first line to detect format
58
+ const content = await readFile(path, "utf-8");
59
+ const firstNewline = content.indexOf("\n");
60
+ const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline);
61
+ const parsed = JSON.parse(firstLine);
62
+ if ("records" in parsed) {
63
+ // Legacy JSON: must load all, then yield
64
+ const snapshot = validateSnapshot(parsed);
65
+ for (const [id, record] of Object.entries(snapshot.records)) {
66
+ yield [id, record];
67
+ }
68
+ return { version: snapshot.version };
69
+ }
70
+ // JSONL: stream line by line via readline
71
+ const header = parsed;
72
+ const rl = createInterface({
73
+ input: createReadStream(path, "utf-8"),
74
+ crlfDelay: Infinity,
75
+ });
76
+ let isFirst = true;
77
+ for await (const line of rl) {
78
+ if (isFirst) {
79
+ isFirst = false;
80
+ continue;
81
+ } // skip header
82
+ const trimmed = line.trim();
83
+ if (!trimmed)
84
+ continue;
85
+ const entry = JSON.parse(trimmed);
86
+ yield [entry.id, entry.data];
87
+ }
88
+ return { version: header.version };
24
89
  }
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Operation, StoreOptions, StoreStats } from "./types.js";
1
+ import type { ManifestInfo, Operation, StoreOptions, StoreStats } from "./types.js";
2
2
  export declare class Store<T = Record<string, unknown>> {
3
3
  private dir;
4
4
  private records;
@@ -24,6 +24,7 @@ export declare class Store<T = Record<string, unknown>> {
24
24
  private groupMs;
25
25
  private groupTimer;
26
26
  private manifestVersion;
27
+ private manifest;
27
28
  /**
28
29
  * Serialize all state-mutating operations through a promise chain.
29
30
  * Prevents interleaving of async mutations. Reads remain synchronous and lock-free.
@@ -42,6 +43,20 @@ export declare class Store<T = Record<string, unknown>> {
42
43
  all(): T[];
43
44
  entries(): [string, T][];
44
45
  filter(predicate: (value: T, id: string) => boolean): T[];
46
+ /** Get read-only manifest info. Returns null if store is not open or no manifest exists. */
47
+ getManifest(): ManifestInfo | null;
48
+ /**
49
+ * Stream snapshot records without loading all into memory.
50
+ * Yields [id, record] pairs from the current snapshot.
51
+ * Requires store to be open (manifest must be read).
52
+ */
53
+ streamSnapshot(): AsyncGenerator<[string, T]>;
54
+ /**
55
+ * Read WAL operations, optionally filtered to those after a given timestamp.
56
+ * Returns ops in chronological order (by Lamport clock for multi-writer).
57
+ * Does not modify the in-memory Map — consumer handles replay.
58
+ */
59
+ getWalOps(sinceTimestamp?: string): AsyncGenerator<Operation<T>>;
45
60
  count(predicate?: (value: T, id: string) => boolean): number;
46
61
  batch(fn: () => void): Promise<void>;
47
62
  undo(): Promise<boolean>;
package/dist/store.js CHANGED
@@ -2,6 +2,7 @@ import { createDefaultManifest } from "./manifest.js";
2
2
  import { FsBackend } from "./backend.js";
3
3
  import { LamportClock } from "./clock.js";
4
4
  import { createDelta, applyDelta, isDeltaSmaller } from "./delta.js";
5
+ import { streamSnapshotFile } from "./snapshot.js";
5
6
  export class Store {
6
7
  dir = "";
7
8
  records = new Map();
@@ -17,6 +18,7 @@ export class Store {
17
18
  version: 1,
18
19
  migrate: (r) => r,
19
20
  readOnly: false,
21
+ skipLoad: false,
20
22
  };
21
23
  archivedRecordCount = 0;
22
24
  batching = false;
@@ -35,6 +37,7 @@ export class Store {
35
37
  groupMs = 100;
36
38
  groupTimer = null;
37
39
  manifestVersion = null;
40
+ manifest = null;
38
41
  /**
39
42
  * Serialize all state-mutating operations through a promise chain.
40
43
  * Prevents interleaving of async mutations. Reads remain synchronous and lock-free.
@@ -53,7 +56,12 @@ export class Store {
53
56
  async open(dir, options) {
54
57
  this.dir = dir;
55
58
  if (options) {
56
- const { backend, agentId, writeMode, groupCommitSize, groupCommitMs, ...rest } = options;
59
+ const { backend, agentId, writeMode, groupCommitSize, groupCommitMs, skipLoad, ...rest } = options;
60
+ if (skipLoad) {
61
+ this.coreOpts.skipLoad = true;
62
+ // Never checkpoint when skipLoad — Map is empty, would overwrite real data
63
+ this.coreOpts.checkpointOnClose = false;
64
+ }
57
65
  this.coreOpts = { ...this.coreOpts, ...rest };
58
66
  if (backend)
59
67
  this.backend = backend;
@@ -107,6 +115,7 @@ export class Store {
107
115
  newManifest.activeAgentOps = { [this.agentId]: opsPath };
108
116
  }
109
117
  await this.backend.writeManifest(newManifest);
118
+ this.manifest = newManifest;
110
119
  this.version = this.coreOpts.version;
111
120
  this.activeOpsPath = opsPath;
112
121
  this.created = newManifest.stats.created;
@@ -116,6 +125,34 @@ export class Store {
116
125
  }
117
126
  }
118
127
  async loadExistingStore(manifest) {
128
+ this.created = manifest.stats.created;
129
+ this.archiveSegments = manifest.archiveSegments;
130
+ this.archivedRecordCount = manifest.stats.archivedRecords;
131
+ this.manifest = manifest;
132
+ // skipLoad: acquire ops path for writes but don't load records or replay WAL
133
+ if (this.coreOpts.skipLoad) {
134
+ this.version = this.coreOpts.version;
135
+ if (this.isMultiWriter()) {
136
+ const maxClock = 0;
137
+ this.clock = new LamportClock(maxClock);
138
+ if (manifest.activeAgentOps?.[this.agentId]) {
139
+ this.activeOpsPath = manifest.activeAgentOps[this.agentId];
140
+ }
141
+ else {
142
+ this.activeOpsPath = await this.backend.createAgentOpsFile(this.agentId);
143
+ const updatedManifest = {
144
+ ...manifest,
145
+ activeAgentOps: { ...(manifest.activeAgentOps ?? {}), [this.agentId]: this.activeOpsPath },
146
+ };
147
+ await this.backend.writeManifest(updatedManifest);
148
+ this.manifest = updatedManifest;
149
+ }
150
+ }
151
+ else {
152
+ this.activeOpsPath = manifest.activeOps;
153
+ }
154
+ return;
155
+ }
119
156
  // Load snapshot
120
157
  let snapshotData;
121
158
  try {
@@ -133,9 +170,6 @@ export class Store {
133
170
  const { records, version: storedVersion } = snapshotData;
134
171
  this.records = records;
135
172
  this.version = storedVersion;
136
- this.created = manifest.stats.created;
137
- this.archiveSegments = manifest.archiveSegments;
138
- this.archivedRecordCount = manifest.stats.archivedRecords;
139
173
  // Migrate if needed
140
174
  if (storedVersion < this.coreOpts.version) {
141
175
  for (const [id, record] of this.records) {
@@ -263,6 +297,71 @@ export class Store {
263
297
  }
264
298
  return results;
265
299
  }
300
+ /** Get read-only manifest info. Returns null if store is not open or no manifest exists. */
301
+ getManifest() {
302
+ const m = this.manifest;
303
+ if (!m)
304
+ return null;
305
+ return {
306
+ currentSnapshot: m.currentSnapshot,
307
+ activeOps: m.activeOps,
308
+ archiveSegments: m.archiveSegments,
309
+ stats: m.stats,
310
+ };
311
+ }
312
+ /**
313
+ * Stream snapshot records without loading all into memory.
314
+ * Yields [id, record] pairs from the current snapshot.
315
+ * Requires store to be open (manifest must be read).
316
+ */
317
+ async *streamSnapshot() {
318
+ this.ensureOpen();
319
+ const manifest = this.manifest;
320
+ if (!manifest)
321
+ return;
322
+ // True streaming via readline — one record in memory at a time (JSONL format).
323
+ // Falls back to parse-then-yield for legacy JSON snapshots.
324
+ yield* streamSnapshotFile(this.dir, manifest.currentSnapshot);
325
+ }
326
+ /**
327
+ * Read WAL operations, optionally filtered to those after a given timestamp.
328
+ * Returns ops in chronological order (by Lamport clock for multi-writer).
329
+ * Does not modify the in-memory Map — consumer handles replay.
330
+ */
331
+ async *getWalOps(sinceTimestamp) {
332
+ this.ensureOpen();
333
+ const manifest = this.manifest;
334
+ if (!manifest)
335
+ return;
336
+ if (manifest.activeAgentOps) {
337
+ // Multi-writer: must load all ops for merge-sort by (clock, agent)
338
+ const allOps = [];
339
+ for (const opsPath of Object.values(manifest.activeAgentOps)) {
340
+ const ops = (await this.backend.readOps(opsPath));
341
+ allOps.push(...ops);
342
+ }
343
+ allOps.sort((a, b) => {
344
+ const clockDiff = (a.clock ?? 0) - (b.clock ?? 0);
345
+ if (clockDiff !== 0)
346
+ return clockDiff;
347
+ return (a.agent ?? "").localeCompare(b.agent ?? "");
348
+ });
349
+ for (const op of allOps) {
350
+ if (sinceTimestamp && op.ts <= sinceTimestamp)
351
+ continue;
352
+ yield op;
353
+ }
354
+ }
355
+ else {
356
+ // Single-writer: yield directly from ops array (no extra accumulation)
357
+ const ops = (await this.backend.readOps(manifest.activeOps));
358
+ for (const op of ops) {
359
+ if (sinceTimestamp && op.ts <= sinceTimestamp)
360
+ continue;
361
+ yield op;
362
+ }
363
+ }
364
+ }
266
365
  count(predicate) {
267
366
  this.ensureOpen();
268
367
  if (!predicate)
@@ -297,6 +396,9 @@ export class Store {
297
396
  async compact() {
298
397
  this.ensureOpen();
299
398
  this.ensureWritable();
399
+ if (this.coreOpts.skipLoad) {
400
+ throw new Error("Cannot compact in skipLoad mode — in-memory Map is incomplete");
401
+ }
300
402
  return this.serialize(() => this._compact());
301
403
  }
302
404
  async archive(predicate, segment) {
package/dist/types.d.ts CHANGED
@@ -43,6 +43,13 @@ export interface ArchiveSegment<T = Record<string, unknown>> {
43
43
  timestamp: string;
44
44
  records: Record<string, T>;
45
45
  }
46
+ /** Read-only manifest info exposed to consumers via store.getManifest(). */
47
+ export interface ManifestInfo {
48
+ readonly currentSnapshot: string;
49
+ readonly activeOps: string;
50
+ readonly archiveSegments: readonly string[];
51
+ readonly stats: Readonly<ManifestStats>;
52
+ }
46
53
  export interface StoreOptions {
47
54
  /** Auto-checkpoint after this many operations (default: 100) */
48
55
  checkpointThreshold?: number;
@@ -54,6 +61,8 @@ export interface StoreOptions {
54
61
  migrate?: (record: unknown, fromVersion: number) => unknown;
55
62
  /** Open in read-only mode: skips directory lock, rejects all mutations. */
56
63
  readOnly?: boolean;
64
+ /** Skip loading snapshot and replaying WAL into memory. Store opens for writes only — reads return empty. For consumers that manage their own read path (e.g. Parquet-backed storage). */
65
+ skipLoad?: boolean;
57
66
  /** Storage backend implementation (default: FsBackend). */
58
67
  backend?: StorageBackend;
59
68
  /** Agent ID for multi-writer mode. Enables per-agent WAL streams and LWW conflict resolution. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backloghq/opslog",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",