@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 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: Object.fromEntries(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
@@ -9,6 +9,7 @@ export declare class Store<T = Record<string, unknown>> {
9
9
  private activeOpsPath;
10
10
  private created;
11
11
  private options;
12
+ private archivedRecordCount;
12
13
  private batching;
13
14
  private batchOps;
14
15
  open(dir: string, options?: StoreOptions): Promise<void>;
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
- this.reverseOp(op);
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: 0,
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>;
@@ -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
- // Skip malformed lines (crash recovery)
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.0",
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
  }