@backloghq/opslog 0.1.0 → 0.1.2
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 +2 -2
- package/dist/archive.js +16 -2
- package/dist/manifest.js +5 -3
- package/dist/snapshot.js +2 -1
- package/dist/store.d.ts +1 -0
- package/dist/store.js +10 -2
- package/dist/validate.d.ts +5 -0
- package/dist/validate.js +71 -0
- package/dist/wal.js +7 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -7,13 +7,13 @@ Every mutation is recorded as an operation in an append-only log. Current state
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install opslog
|
|
10
|
+
npm install @backloghq/opslog
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
15
15
|
```typescript
|
|
16
|
-
import { Store } from "opslog";
|
|
16
|
+
import { Store } from "@backloghq/opslog";
|
|
17
17
|
|
|
18
18
|
const store = new Store<{ name: string; status: string }>();
|
|
19
19
|
await store.open("./data");
|
package/dist/archive.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { validateArchiveSegment } from "./validate.js";
|
|
3
4
|
export async function writeArchiveSegment(dir, period, records) {
|
|
4
5
|
const filename = `archive-${period}.json`;
|
|
5
6
|
const path = join(dir, "archive", filename);
|
|
7
|
+
// Merge with existing archive if present
|
|
8
|
+
let existing = {};
|
|
9
|
+
try {
|
|
10
|
+
const content = await readFile(path, "utf-8");
|
|
11
|
+
const parsed = validateArchiveSegment(JSON.parse(content));
|
|
12
|
+
existing = parsed.records;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
const isNotFound = err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
16
|
+
if (!isNotFound)
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
const merged = { ...existing, ...Object.fromEntries(records) };
|
|
6
20
|
const segment = {
|
|
7
21
|
version: 1,
|
|
8
22
|
period,
|
|
9
23
|
timestamp: new Date().toISOString(),
|
|
10
|
-
records:
|
|
24
|
+
records: merged,
|
|
11
25
|
};
|
|
12
26
|
const tmpPath = path + ".tmp";
|
|
13
27
|
await writeFile(tmpPath, JSON.stringify(segment, null, 2), "utf-8");
|
|
@@ -17,7 +31,7 @@ export async function writeArchiveSegment(dir, period, records) {
|
|
|
17
31
|
export async function loadArchiveSegment(dir, relativePath) {
|
|
18
32
|
const path = join(dir, relativePath);
|
|
19
33
|
const content = await readFile(path, "utf-8");
|
|
20
|
-
const segment = JSON.parse(content);
|
|
34
|
+
const segment = validateArchiveSegment(JSON.parse(content));
|
|
21
35
|
return new Map(Object.entries(segment.records));
|
|
22
36
|
}
|
|
23
37
|
export async function listArchiveSegments(dir) {
|
package/dist/manifest.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { validateManifest } from "./validate.js";
|
|
3
4
|
const MANIFEST_FILE = "manifest.json";
|
|
4
5
|
export async function readManifest(dir) {
|
|
6
|
+
let content;
|
|
5
7
|
try {
|
|
6
|
-
|
|
7
|
-
return JSON.parse(content);
|
|
8
|
+
content = await readFile(join(dir, MANIFEST_FILE), "utf-8");
|
|
8
9
|
}
|
|
9
10
|
catch {
|
|
10
|
-
return null;
|
|
11
|
+
return null; // File not found — fresh store
|
|
11
12
|
}
|
|
13
|
+
return validateManifest(JSON.parse(content));
|
|
12
14
|
}
|
|
13
15
|
export async function writeManifest(dir, manifest) {
|
|
14
16
|
const path = join(dir, MANIFEST_FILE);
|
package/dist/snapshot.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { validateSnapshot } from "./validate.js";
|
|
3
4
|
export async function writeSnapshot(dir, records, version) {
|
|
4
5
|
const timestamp = new Date().toISOString();
|
|
5
6
|
const filename = `snap-${Date.now()}.json`;
|
|
@@ -17,7 +18,7 @@ export async function writeSnapshot(dir, records, version) {
|
|
|
17
18
|
export async function loadSnapshot(dir, relativePath) {
|
|
18
19
|
const path = join(dir, relativePath);
|
|
19
20
|
const content = await readFile(path, "utf-8");
|
|
20
|
-
const snapshot = JSON.parse(content);
|
|
21
|
+
const snapshot = validateSnapshot(JSON.parse(content));
|
|
21
22
|
const records = new Map(Object.entries(snapshot.records));
|
|
22
23
|
return { records, version: snapshot.version };
|
|
23
24
|
}
|
package/dist/store.d.ts
CHANGED
package/dist/store.js
CHANGED
|
@@ -19,6 +19,7 @@ export class Store {
|
|
|
19
19
|
version: 1,
|
|
20
20
|
migrate: (r) => r,
|
|
21
21
|
};
|
|
22
|
+
archivedRecordCount = 0;
|
|
22
23
|
batching = false;
|
|
23
24
|
batchOps = [];
|
|
24
25
|
async open(dir, options) {
|
|
@@ -51,6 +52,7 @@ export class Store {
|
|
|
51
52
|
this.activeOpsPath = manifest.activeOps;
|
|
52
53
|
this.created = manifest.stats.created;
|
|
53
54
|
this.archiveSegments = manifest.archiveSegments;
|
|
55
|
+
this.archivedRecordCount = manifest.stats.archivedRecords;
|
|
54
56
|
// Migrate if needed
|
|
55
57
|
if (storedVersion < this.options.version) {
|
|
56
58
|
for (const [id, record] of this.records) {
|
|
@@ -163,7 +165,12 @@ export class Store {
|
|
|
163
165
|
catch (err) {
|
|
164
166
|
// Rollback in-memory changes on failure
|
|
165
167
|
for (const op of this.batchOps.reverse()) {
|
|
166
|
-
|
|
168
|
+
try {
|
|
169
|
+
this.reverseOp(op);
|
|
170
|
+
}
|
|
171
|
+
catch (rollbackErr) {
|
|
172
|
+
console.error("opslog: rollback failed for op", op.id, rollbackErr);
|
|
173
|
+
}
|
|
167
174
|
}
|
|
168
175
|
throw err;
|
|
169
176
|
}
|
|
@@ -205,7 +212,7 @@ export class Store {
|
|
|
205
212
|
archiveSegments: this.archiveSegments,
|
|
206
213
|
stats: {
|
|
207
214
|
activeRecords: this.records.size,
|
|
208
|
-
archivedRecords:
|
|
215
|
+
archivedRecords: this.archivedRecordCount,
|
|
209
216
|
opsCount: 0,
|
|
210
217
|
created: this.created,
|
|
211
218
|
lastCheckpoint: new Date().toISOString(),
|
|
@@ -232,6 +239,7 @@ export class Store {
|
|
|
232
239
|
for (const id of toArchive.keys()) {
|
|
233
240
|
this.records.delete(id);
|
|
234
241
|
}
|
|
242
|
+
this.archivedRecordCount += toArchive.size;
|
|
235
243
|
await this.compact();
|
|
236
244
|
return toArchive.size;
|
|
237
245
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Operation, Manifest, Snapshot, ArchiveSegment } from "./types.js";
|
|
2
|
+
export declare function validateOp<T>(raw: unknown): Operation<T>;
|
|
3
|
+
export declare function validateManifest(raw: unknown): Manifest;
|
|
4
|
+
export declare function validateSnapshot<T>(raw: unknown): Snapshot<T>;
|
|
5
|
+
export declare function validateArchiveSegment<T>(raw: unknown): ArchiveSegment<T>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function validateOp(raw) {
|
|
2
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
3
|
+
throw new Error("Invalid operation: not an object");
|
|
4
|
+
}
|
|
5
|
+
const obj = raw;
|
|
6
|
+
if (typeof obj.ts !== "string")
|
|
7
|
+
throw new Error("Invalid operation: missing ts");
|
|
8
|
+
if (obj.op !== "set" && obj.op !== "delete") {
|
|
9
|
+
throw new Error(`Invalid operation: unknown op "${obj.op}"`);
|
|
10
|
+
}
|
|
11
|
+
if (typeof obj.id !== "string")
|
|
12
|
+
throw new Error("Invalid operation: missing id");
|
|
13
|
+
return raw;
|
|
14
|
+
}
|
|
15
|
+
export function validateManifest(raw) {
|
|
16
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
17
|
+
throw new Error("Invalid manifest: not an object");
|
|
18
|
+
}
|
|
19
|
+
const obj = raw;
|
|
20
|
+
if (typeof obj.version !== "number")
|
|
21
|
+
throw new Error("Invalid manifest: missing version");
|
|
22
|
+
if (typeof obj.currentSnapshot !== "string")
|
|
23
|
+
throw new Error("Invalid manifest: missing currentSnapshot");
|
|
24
|
+
if (typeof obj.activeOps !== "string")
|
|
25
|
+
throw new Error("Invalid manifest: missing activeOps");
|
|
26
|
+
if (!Array.isArray(obj.archiveSegments))
|
|
27
|
+
throw new Error("Invalid manifest: archiveSegments must be an array");
|
|
28
|
+
if (typeof obj.stats !== "object" || obj.stats === null || Array.isArray(obj.stats)) {
|
|
29
|
+
throw new Error("Invalid manifest: missing stats");
|
|
30
|
+
}
|
|
31
|
+
const stats = obj.stats;
|
|
32
|
+
if (typeof stats.activeRecords !== "number")
|
|
33
|
+
throw new Error("Invalid manifest: stats.activeRecords must be a number");
|
|
34
|
+
if (typeof stats.archivedRecords !== "number")
|
|
35
|
+
throw new Error("Invalid manifest: stats.archivedRecords must be a number");
|
|
36
|
+
if (typeof stats.opsCount !== "number")
|
|
37
|
+
throw new Error("Invalid manifest: stats.opsCount must be a number");
|
|
38
|
+
if (typeof stats.created !== "string")
|
|
39
|
+
throw new Error("Invalid manifest: stats.created must be a string");
|
|
40
|
+
if (typeof stats.lastCheckpoint !== "string")
|
|
41
|
+
throw new Error("Invalid manifest: stats.lastCheckpoint must be a string");
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
44
|
+
export function validateSnapshot(raw) {
|
|
45
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
46
|
+
throw new Error("Invalid snapshot: not an object");
|
|
47
|
+
}
|
|
48
|
+
const obj = raw;
|
|
49
|
+
if (typeof obj.version !== "number")
|
|
50
|
+
throw new Error("Invalid snapshot: missing version");
|
|
51
|
+
if (typeof obj.records !== "object" || obj.records === null || Array.isArray(obj.records)) {
|
|
52
|
+
throw new Error("Invalid snapshot: records must be an object");
|
|
53
|
+
}
|
|
54
|
+
return raw;
|
|
55
|
+
}
|
|
56
|
+
export function validateArchiveSegment(raw) {
|
|
57
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
58
|
+
throw new Error("Invalid archive segment: not an object");
|
|
59
|
+
}
|
|
60
|
+
const obj = raw;
|
|
61
|
+
if (typeof obj.version !== "number")
|
|
62
|
+
throw new Error("Invalid archive segment: missing version");
|
|
63
|
+
if (typeof obj.period !== "string")
|
|
64
|
+
throw new Error("Invalid archive segment: missing period");
|
|
65
|
+
if (typeof obj.timestamp !== "string")
|
|
66
|
+
throw new Error("Invalid archive segment: missing timestamp");
|
|
67
|
+
if (typeof obj.records !== "object" || obj.records === null || Array.isArray(obj.records)) {
|
|
68
|
+
throw new Error("Invalid archive segment: records must be an object");
|
|
69
|
+
}
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
package/dist/wal.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { validateOp } from "./validate.js";
|
|
2
3
|
export async function appendOp(path, op) {
|
|
3
4
|
await appendFile(path, JSON.stringify(op) + "\n", "utf-8");
|
|
4
5
|
}
|
|
@@ -16,14 +17,18 @@ export async function readOps(path) {
|
|
|
16
17
|
}
|
|
17
18
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
18
19
|
const ops = [];
|
|
20
|
+
let skipped = 0;
|
|
19
21
|
for (const line of lines) {
|
|
20
22
|
try {
|
|
21
|
-
ops.push(JSON.parse(line));
|
|
23
|
+
ops.push(validateOp(JSON.parse(line)));
|
|
22
24
|
}
|
|
23
25
|
catch {
|
|
24
|
-
|
|
26
|
+
skipped++;
|
|
25
27
|
}
|
|
26
28
|
}
|
|
29
|
+
if (skipped > 0) {
|
|
30
|
+
console.error(`opslog: skipped ${skipped} malformed line(s) in ${path}`);
|
|
31
|
+
}
|
|
27
32
|
return ops;
|
|
28
33
|
}
|
|
29
34
|
export async function truncateLastOp(path) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backloghq/opslog",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
"LICENSE"
|
|
34
34
|
],
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@eslint/js": "
|
|
37
|
-
"@types/node": "
|
|
36
|
+
"@eslint/js": "^10.0.0",
|
|
37
|
+
"@types/node": "^25.0.0",
|
|
38
38
|
"@vitest/coverage-v8": "^4.1.2",
|
|
39
|
-
"eslint": "
|
|
40
|
-
"typescript": "
|
|
41
|
-
"typescript-eslint": "
|
|
42
|
-
"vitest": "
|
|
39
|
+
"eslint": "^10.0.0",
|
|
40
|
+
"typescript": "~6.0.2",
|
|
41
|
+
"typescript-eslint": "^8.58.0",
|
|
42
|
+
"vitest": "^4.1.2"
|
|
43
43
|
}
|
|
44
44
|
}
|