@backloghq/opslog 0.1.0 → 0.1.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 +2 -2
- package/dist/archive.js +14 -2
- package/dist/manifest.js +2 -1
- 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 +53 -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,25 @@
|
|
|
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 {
|
|
15
|
+
// First write to this period
|
|
16
|
+
}
|
|
17
|
+
const merged = { ...existing, ...Object.fromEntries(records) };
|
|
6
18
|
const segment = {
|
|
7
19
|
version: 1,
|
|
8
20
|
period,
|
|
9
21
|
timestamp: new Date().toISOString(),
|
|
10
|
-
records:
|
|
22
|
+
records: merged,
|
|
11
23
|
};
|
|
12
24
|
const tmpPath = path + ".tmp";
|
|
13
25
|
await writeFile(tmpPath, JSON.stringify(segment, null, 2), "utf-8");
|
|
@@ -17,7 +29,7 @@ export async function writeArchiveSegment(dir, period, records) {
|
|
|
17
29
|
export async function loadArchiveSegment(dir, relativePath) {
|
|
18
30
|
const path = join(dir, relativePath);
|
|
19
31
|
const content = await readFile(path, "utf-8");
|
|
20
|
-
const segment = JSON.parse(content);
|
|
32
|
+
const segment = validateArchiveSegment(JSON.parse(content));
|
|
21
33
|
return new Map(Object.entries(segment.records));
|
|
22
34
|
}
|
|
23
35
|
export async function listArchiveSegments(dir) {
|
package/dist/manifest.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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) {
|
|
5
6
|
try {
|
|
6
7
|
const content = await readFile(join(dir, MANIFEST_FILE), "utf-8");
|
|
7
|
-
return JSON.parse(content);
|
|
8
|
+
return validateManifest(JSON.parse(content));
|
|
8
9
|
}
|
|
9
10
|
catch {
|
|
10
11
|
return null;
|
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 ?? 0;
|
|
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,53 @@
|
|
|
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
|
+
return raw;
|
|
27
|
+
}
|
|
28
|
+
export function validateSnapshot(raw) {
|
|
29
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
30
|
+
throw new Error("Invalid snapshot: not an object");
|
|
31
|
+
}
|
|
32
|
+
const obj = raw;
|
|
33
|
+
if (typeof obj.version !== "number")
|
|
34
|
+
throw new Error("Invalid snapshot: missing version");
|
|
35
|
+
if (typeof obj.records !== "object" || obj.records === null || Array.isArray(obj.records)) {
|
|
36
|
+
throw new Error("Invalid snapshot: records must be an object");
|
|
37
|
+
}
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
export function validateArchiveSegment(raw) {
|
|
41
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
42
|
+
throw new Error("Invalid archive segment: not an object");
|
|
43
|
+
}
|
|
44
|
+
const obj = raw;
|
|
45
|
+
if (typeof obj.version !== "number")
|
|
46
|
+
throw new Error("Invalid archive segment: missing version");
|
|
47
|
+
if (typeof obj.period !== "string")
|
|
48
|
+
throw new Error("Invalid archive segment: missing period");
|
|
49
|
+
if (typeof obj.records !== "object" || obj.records === null || Array.isArray(obj.records)) {
|
|
50
|
+
throw new Error("Invalid archive segment: records must be an object");
|
|
51
|
+
}
|
|
52
|
+
return raw;
|
|
53
|
+
}
|
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.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",
|
|
@@ -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
|
}
|