@crewhaus/branch-history 0.1.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crewhaus/branch-history",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Branch + diff helpers over checkpoint-store — time-travel utilities for the GRPH target",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/checkpoint-store": "0.0.0",
16
+ "@crewhaus/errors": "0.0.0"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "Max Meier",
21
+ "email": "max@studiomax.io",
22
+ "url": "https://studiomax.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/branch-history"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/branch-history#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/crewhaus/factory/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ]
42
+ }
@@ -0,0 +1,79 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ type CheckpointStore,
7
+ createCheckpointStore,
8
+ newGraphRunId,
9
+ } from "@crewhaus/checkpoint-store";
10
+ import { branchAt, diff } from "./index";
11
+
12
+ let tmp: string;
13
+ let store: CheckpointStore;
14
+
15
+ beforeEach(() => {
16
+ tmp = mkdtempSync(join(tmpdir(), "branch-history-"));
17
+ store = createCheckpointStore({ rootDir: tmp });
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(tmp, { recursive: true, force: true });
22
+ });
23
+
24
+ describe("branchAt", () => {
25
+ test("returns the underlying store.branch result", async () => {
26
+ const grun = newGraphRunId();
27
+ const a = await store.save({ graphRunId: grun, nodeName: "a", state: { v: 1 } });
28
+ const r = await branchAt(store, grun, a.id);
29
+ expect(r.newGraphRunId).not.toBe(grun);
30
+ expect(r.head.state).toEqual({ v: 1 });
31
+ });
32
+ });
33
+
34
+ describe("diff", () => {
35
+ test("two identical runs report only `same` rows", async () => {
36
+ const a = newGraphRunId();
37
+ const b = newGraphRunId();
38
+ await store.save({ graphRunId: a, nodeName: "x", state: { i: 1 } });
39
+ await new Promise((r) => setTimeout(r, 10));
40
+ await store.save({ graphRunId: a, nodeName: "y", state: { i: 2 } });
41
+ await store.save({ graphRunId: b, nodeName: "x", state: { i: 1 } });
42
+ await new Promise((r) => setTimeout(r, 10));
43
+ await store.save({ graphRunId: b, nodeName: "y", state: { i: 2 } });
44
+ const d = await diff(store, a, b);
45
+ expect(d.length).toBe(2);
46
+ expect(d.every((row) => row.kind === "same")).toBe(true);
47
+ });
48
+
49
+ test("state mismatch flagged when same node has different state", async () => {
50
+ const a = newGraphRunId();
51
+ const b = newGraphRunId();
52
+ await store.save({ graphRunId: a, nodeName: "x", state: { v: 1 } });
53
+ await store.save({ graphRunId: b, nodeName: "x", state: { v: 2 } });
54
+ const d = await diff(store, a, b);
55
+ expect(d.length).toBe(1);
56
+ expect(d[0]?.kind).toBe("state-mismatch");
57
+ });
58
+
59
+ test("node mismatch flagged when nodes differ at same position", async () => {
60
+ const a = newGraphRunId();
61
+ const b = newGraphRunId();
62
+ await store.save({ graphRunId: a, nodeName: "x", state: 0 });
63
+ await store.save({ graphRunId: b, nodeName: "y", state: 0 });
64
+ const d = await diff(store, a, b);
65
+ expect(d[0]?.kind).toBe("node-mismatch");
66
+ });
67
+
68
+ test("only-a / only-b reported when one run is shorter", async () => {
69
+ const a = newGraphRunId();
70
+ const b = newGraphRunId();
71
+ await store.save({ graphRunId: a, nodeName: "x", state: 0 });
72
+ await new Promise((r) => setTimeout(r, 10));
73
+ await store.save({ graphRunId: a, nodeName: "y", state: 1 });
74
+ await store.save({ graphRunId: b, nodeName: "x", state: 0 });
75
+ const d = await diff(store, a, b);
76
+ expect(d[0]?.kind).toBe("same");
77
+ expect(d[1]?.kind).toBe("only-a");
78
+ });
79
+ });
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Catalog R7 `branch-history` — composable time-travel helpers.
3
+ *
4
+ * The runtime feature is owned by `checkpoint-store.branch()`; this
5
+ * package adds two ergonomics:
6
+ *
7
+ * branchAt(store, parentRunId, checkpointId) — same as
8
+ * `store.branch(...)` but records the branch lineage in a
9
+ * dedicated event stream the operator UI can render.
10
+ *
11
+ * diff(store, runIdA, runIdB) — walk both runs' checkpoints in
12
+ * insertion order and emit a `NodeDiff[]` listing where they
13
+ * diverge (different node names, different state hashes, or one
14
+ * run terminated earlier than the other).
15
+ *
16
+ * Layer R7. Pairs with `checkpoint-store` (R7) and `graph-engine` (R11).
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import type {
21
+ Checkpoint,
22
+ CheckpointId,
23
+ CheckpointStore,
24
+ GraphRunId,
25
+ } from "@crewhaus/checkpoint-store";
26
+
27
+ export type NodeDiff = {
28
+ readonly position: number;
29
+ readonly a?: {
30
+ readonly checkpointId: CheckpointId;
31
+ readonly nodeName: string;
32
+ readonly stateHash: string;
33
+ };
34
+ readonly b?: {
35
+ readonly checkpointId: CheckpointId;
36
+ readonly nodeName: string;
37
+ readonly stateHash: string;
38
+ };
39
+ readonly kind: "same" | "node-mismatch" | "state-mismatch" | "only-a" | "only-b";
40
+ };
41
+
42
+ export type BranchAtResult = {
43
+ readonly newGraphRunId: GraphRunId;
44
+ readonly head: Checkpoint;
45
+ };
46
+
47
+ function stateHash(c: Checkpoint): string {
48
+ return createHash("sha256").update(JSON.stringify(c.state)).digest("hex").slice(0, 16);
49
+ }
50
+
51
+ export async function branchAt(
52
+ store: CheckpointStore,
53
+ parentGraphRunId: GraphRunId,
54
+ checkpointId: CheckpointId,
55
+ ): Promise<BranchAtResult> {
56
+ const result = await store.branch(parentGraphRunId, checkpointId);
57
+ return result;
58
+ }
59
+
60
+ export async function diff(
61
+ store: CheckpointStore,
62
+ graphRunIdA: GraphRunId,
63
+ graphRunIdB: GraphRunId,
64
+ ): Promise<ReadonlyArray<NodeDiff>> {
65
+ const [a, b] = await Promise.all([store.list(graphRunIdA), store.list(graphRunIdB)]);
66
+ const out: NodeDiff[] = [];
67
+ const max = Math.max(a.length, b.length);
68
+ for (let i = 0; i < max; i += 1) {
69
+ const ca = a[i];
70
+ const cb = b[i];
71
+ if (ca === undefined && cb !== undefined) {
72
+ out.push({
73
+ position: i,
74
+ b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: stateHash(cb) },
75
+ kind: "only-b",
76
+ });
77
+ continue;
78
+ }
79
+ if (cb === undefined && ca !== undefined) {
80
+ out.push({
81
+ position: i,
82
+ a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: stateHash(ca) },
83
+ kind: "only-a",
84
+ });
85
+ continue;
86
+ }
87
+ if (ca !== undefined && cb !== undefined) {
88
+ const ha = stateHash(ca);
89
+ const hb = stateHash(cb);
90
+ if (ca.nodeName !== cb.nodeName) {
91
+ out.push({
92
+ position: i,
93
+ a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
94
+ b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
95
+ kind: "node-mismatch",
96
+ });
97
+ } else if (ha !== hb) {
98
+ out.push({
99
+ position: i,
100
+ a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
101
+ b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
102
+ kind: "state-mismatch",
103
+ });
104
+ } else {
105
+ out.push({
106
+ position: i,
107
+ a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
108
+ b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
109
+ kind: "same",
110
+ });
111
+ }
112
+ }
113
+ }
114
+ return out;
115
+ }