@backloghq/opslog 0.5.1 → 0.7.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 +17 -1
- package/dist/backend.d.ts +5 -0
- package/dist/backend.js +28 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/snapshot.d.ts +8 -0
- package/dist/snapshot.js +71 -10
- package/dist/store.d.ts +16 -1
- package/dist/store.js +106 -4
- package/dist/types.d.ts +19 -0
- package/package.json +2 -2
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>.
|
|
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/backend.d.ts
CHANGED
|
@@ -27,4 +27,9 @@ export declare class FsBackend implements StorageBackend {
|
|
|
27
27
|
acquireCompactionLock(): Promise<LockHandle>;
|
|
28
28
|
releaseCompactionLock(handle: LockHandle): Promise<void>;
|
|
29
29
|
getManifestVersion(): Promise<string | null>;
|
|
30
|
+
writeBlob(relativePath: string, content: Buffer): Promise<void>;
|
|
31
|
+
readBlob(relativePath: string): Promise<Buffer>;
|
|
32
|
+
listBlobs(prefix: string): Promise<string[]>;
|
|
33
|
+
deleteBlob(relativePath: string): Promise<void>;
|
|
34
|
+
deleteBlobDir(prefix: string): Promise<void>;
|
|
30
35
|
}
|
package/dist/backend.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { mkdir, open, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { mkdir, open, readdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import { appendOp, appendOps, readOps, truncateLastOp } from "./wal.js";
|
|
4
4
|
import { loadSnapshot, writeSnapshot } from "./snapshot.js";
|
|
5
5
|
import { readManifest, writeManifest } from "./manifest.js";
|
|
@@ -131,4 +131,30 @@ export class FsBackend {
|
|
|
131
131
|
return null;
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
+
// -- Blob storage --
|
|
135
|
+
async writeBlob(relativePath, content) {
|
|
136
|
+
const fullPath = join(this.dir, relativePath);
|
|
137
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
138
|
+
await writeFile(fullPath, content);
|
|
139
|
+
}
|
|
140
|
+
async readBlob(relativePath) {
|
|
141
|
+
return readFile(join(this.dir, relativePath));
|
|
142
|
+
}
|
|
143
|
+
async listBlobs(prefix) {
|
|
144
|
+
try {
|
|
145
|
+
return await readdir(join(this.dir, prefix));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async deleteBlob(relativePath) {
|
|
152
|
+
try {
|
|
153
|
+
await unlink(join(this.dir, relativePath));
|
|
154
|
+
}
|
|
155
|
+
catch { /* ignore if not found */ }
|
|
156
|
+
}
|
|
157
|
+
async deleteBlobDir(prefix) {
|
|
158
|
+
await rm(join(this.dir, prefix), { recursive: true, force: true });
|
|
159
|
+
}
|
|
134
160
|
}
|
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";
|
package/dist/snapshot.d.ts
CHANGED
|
@@ -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,85 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
1
2
|
import { readFile, rename, writeFile } 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()}.
|
|
8
|
+
const filename = `snap-${Date.now()}.jsonl`;
|
|
7
9
|
const path = join(dir, "snapshots", filename);
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
10
|
+
const lines = [];
|
|
11
|
+
lines.push(JSON.stringify({ version, timestamp }));
|
|
12
|
+
for (const [id, data] of records) {
|
|
13
|
+
lines.push(JSON.stringify({ id, data }));
|
|
14
|
+
}
|
|
13
15
|
const tmpPath = path + ".tmp";
|
|
14
|
-
await writeFile(tmpPath,
|
|
16
|
+
await writeFile(tmpPath, lines.join("\n") + "\n", "utf-8");
|
|
15
17
|
await rename(tmpPath, path);
|
|
16
18
|
return `snapshots/${filename}`;
|
|
17
19
|
}
|
|
18
20
|
export async function loadSnapshot(dir, relativePath) {
|
|
19
21
|
const path = join(dir, relativePath);
|
|
20
22
|
const content = await readFile(path, "utf-8");
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
23
|
+
// Detect format: JSONL (first line is header without "records" key) vs legacy JSON
|
|
24
|
+
const firstNewline = content.indexOf("\n");
|
|
25
|
+
const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline);
|
|
26
|
+
const parsed = JSON.parse(firstLine);
|
|
27
|
+
if ("records" in parsed) {
|
|
28
|
+
// Legacy monolithic JSON format
|
|
29
|
+
const snapshot = validateSnapshot(parsed);
|
|
30
|
+
const records = new Map(Object.entries(snapshot.records));
|
|
31
|
+
return { records, version: snapshot.version };
|
|
32
|
+
}
|
|
33
|
+
// JSONL format: first line is header, remaining lines are records
|
|
34
|
+
const header = parsed;
|
|
35
|
+
const records = new Map();
|
|
36
|
+
const lines = content.split("\n");
|
|
37
|
+
for (let i = 1; i < lines.length; i++) {
|
|
38
|
+
const line = lines[i].trim();
|
|
39
|
+
if (!line)
|
|
40
|
+
continue;
|
|
41
|
+
const entry = JSON.parse(line);
|
|
42
|
+
records.set(entry.id, entry.data);
|
|
43
|
+
}
|
|
44
|
+
return { records, version: header.version };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Stream snapshot records line by line using readline.
|
|
48
|
+
* True streaming — only one record in memory at a time.
|
|
49
|
+
* Supports both JSONL and legacy JSON formats.
|
|
50
|
+
*/
|
|
51
|
+
export async function* streamSnapshotFile(dir, relativePath) {
|
|
52
|
+
const path = join(dir, relativePath);
|
|
53
|
+
// Peek at first line to detect format
|
|
54
|
+
const content = await readFile(path, "utf-8");
|
|
55
|
+
const firstNewline = content.indexOf("\n");
|
|
56
|
+
const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline);
|
|
57
|
+
const parsed = JSON.parse(firstLine);
|
|
58
|
+
if ("records" in parsed) {
|
|
59
|
+
// Legacy JSON: must load all, then yield
|
|
60
|
+
const snapshot = validateSnapshot(parsed);
|
|
61
|
+
for (const [id, record] of Object.entries(snapshot.records)) {
|
|
62
|
+
yield [id, record];
|
|
63
|
+
}
|
|
64
|
+
return { version: snapshot.version };
|
|
65
|
+
}
|
|
66
|
+
// JSONL: stream line by line via readline
|
|
67
|
+
const header = parsed;
|
|
68
|
+
const rl = createInterface({
|
|
69
|
+
input: createReadStream(path, "utf-8"),
|
|
70
|
+
crlfDelay: Infinity,
|
|
71
|
+
});
|
|
72
|
+
let isFirst = true;
|
|
73
|
+
for await (const line of rl) {
|
|
74
|
+
if (isFirst) {
|
|
75
|
+
isFirst = false;
|
|
76
|
+
continue;
|
|
77
|
+
} // skip header
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (!trimmed)
|
|
80
|
+
continue;
|
|
81
|
+
const entry = JSON.parse(trimmed);
|
|
82
|
+
yield [entry.id, entry.data];
|
|
83
|
+
}
|
|
84
|
+
return { version: header.version };
|
|
24
85
|
}
|
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. */
|
|
@@ -102,4 +111,14 @@ export interface StorageBackend {
|
|
|
102
111
|
acquireCompactionLock(): Promise<LockHandle>;
|
|
103
112
|
releaseCompactionLock(handle: LockHandle): Promise<void>;
|
|
104
113
|
getManifestVersion(): Promise<string | null>;
|
|
114
|
+
/** Write a blob at a relative path. Creates directories as needed. */
|
|
115
|
+
writeBlob(relativePath: string, content: Buffer): Promise<void>;
|
|
116
|
+
/** Read a blob from a relative path. */
|
|
117
|
+
readBlob(relativePath: string): Promise<Buffer>;
|
|
118
|
+
/** List blob names under a prefix directory. */
|
|
119
|
+
listBlobs(prefix: string): Promise<string[]>;
|
|
120
|
+
/** Delete a single blob. */
|
|
121
|
+
deleteBlob(relativePath: string): Promise<void>;
|
|
122
|
+
/** Delete all blobs under a prefix directory. */
|
|
123
|
+
deleteBlobDir(prefix: string): Promise<void>;
|
|
105
124
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backloghq/opslog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"type": "module",
|
|
27
27
|
"engines": {
|
|
28
|
-
"node": ">=
|
|
28
|
+
"node": ">=22"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist/",
|