@crewhaus/branch-history 0.1.3 → 0.1.5
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/dist/index.d.ts +38 -0
- package/dist/index.js +84 -0
- package/package.json +10 -7
- package/src/index.test.ts +0 -125
- package/src/index.ts +0 -120
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
import type { Checkpoint, CheckpointId, CheckpointStore, GraphRunId } from "@crewhaus/checkpoint-store";
|
|
19
|
+
export type NodeDiff = {
|
|
20
|
+
readonly position: number;
|
|
21
|
+
readonly a?: {
|
|
22
|
+
readonly checkpointId: CheckpointId;
|
|
23
|
+
readonly nodeName: string;
|
|
24
|
+
readonly stateHash: string;
|
|
25
|
+
};
|
|
26
|
+
readonly b?: {
|
|
27
|
+
readonly checkpointId: CheckpointId;
|
|
28
|
+
readonly nodeName: string;
|
|
29
|
+
readonly stateHash: string;
|
|
30
|
+
};
|
|
31
|
+
readonly kind: "same" | "node-mismatch" | "state-mismatch" | "only-a" | "only-b";
|
|
32
|
+
};
|
|
33
|
+
export type BranchAtResult = {
|
|
34
|
+
readonly newGraphRunId: GraphRunId;
|
|
35
|
+
readonly head: Checkpoint;
|
|
36
|
+
};
|
|
37
|
+
export declare function branchAt(store: CheckpointStore, parentGraphRunId: GraphRunId, checkpointId: CheckpointId): Promise<BranchAtResult>;
|
|
38
|
+
export declare function diff(store: CheckpointStore, graphRunIdA: GraphRunId, graphRunIdB: GraphRunId): Promise<ReadonlyArray<NodeDiff>>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
function stateHash(c) {
|
|
20
|
+
// `JSON.stringify` returns `undefined` for `state === undefined` (and for
|
|
21
|
+
// values that serialize to nothing, e.g. functions/symbols). Feeding that
|
|
22
|
+
// straight into `createHash().update()` throws a TypeError, so coalesce to a
|
|
23
|
+
// stable sentinel — every other state serializes unchanged, preserving hashes.
|
|
24
|
+
const serialized = JSON.stringify(c.state) ?? "undefined";
|
|
25
|
+
return createHash("sha256").update(serialized).digest("hex").slice(0, 16);
|
|
26
|
+
}
|
|
27
|
+
export async function branchAt(store, parentGraphRunId, checkpointId) {
|
|
28
|
+
const result = await store.branch(parentGraphRunId, checkpointId);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
export async function diff(store, graphRunIdA, graphRunIdB) {
|
|
32
|
+
const [a, b] = await Promise.all([store.list(graphRunIdA), store.list(graphRunIdB)]);
|
|
33
|
+
const out = [];
|
|
34
|
+
const max = Math.max(a.length, b.length);
|
|
35
|
+
for (let i = 0; i < max; i += 1) {
|
|
36
|
+
const ca = a[i];
|
|
37
|
+
const cb = b[i];
|
|
38
|
+
if (ca === undefined && cb !== undefined) {
|
|
39
|
+
out.push({
|
|
40
|
+
position: i,
|
|
41
|
+
b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: stateHash(cb) },
|
|
42
|
+
kind: "only-b",
|
|
43
|
+
});
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (cb === undefined && ca !== undefined) {
|
|
47
|
+
out.push({
|
|
48
|
+
position: i,
|
|
49
|
+
a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: stateHash(ca) },
|
|
50
|
+
kind: "only-a",
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ca !== undefined && cb !== undefined) {
|
|
55
|
+
const ha = stateHash(ca);
|
|
56
|
+
const hb = stateHash(cb);
|
|
57
|
+
if (ca.nodeName !== cb.nodeName) {
|
|
58
|
+
out.push({
|
|
59
|
+
position: i,
|
|
60
|
+
a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
|
|
61
|
+
b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
|
|
62
|
+
kind: "node-mismatch",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else if (ha !== hb) {
|
|
66
|
+
out.push({
|
|
67
|
+
position: i,
|
|
68
|
+
a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
|
|
69
|
+
b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
|
|
70
|
+
kind: "state-mismatch",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
out.push({
|
|
75
|
+
position: i,
|
|
76
|
+
a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
|
|
77
|
+
b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
|
|
78
|
+
kind: "same",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/branch-history",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Branch + diff helpers over checkpoint-store — time-travel utilities for the GRPH target",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/checkpoint-store": "0.1.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
+
"@crewhaus/checkpoint-store": "0.1.5",
|
|
19
|
+
"@crewhaus/errors": "0.1.5"
|
|
17
20
|
},
|
|
18
21
|
"license": "Apache-2.0",
|
|
19
22
|
"author": {
|
|
@@ -33,5 +36,5 @@
|
|
|
33
36
|
"publishConfig": {
|
|
34
37
|
"access": "public"
|
|
35
38
|
},
|
|
36
|
-
"files": ["
|
|
39
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
37
40
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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
|
-
|
|
80
|
-
test("only-b reported when run B is longer than run A", async () => {
|
|
81
|
-
const a = newGraphRunId();
|
|
82
|
-
const b = newGraphRunId();
|
|
83
|
-
await store.save({ graphRunId: a, nodeName: "x", state: 0 });
|
|
84
|
-
await store.save({ graphRunId: b, nodeName: "x", state: 0 });
|
|
85
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
86
|
-
const extra = await store.save({ graphRunId: b, nodeName: "y", state: 1 });
|
|
87
|
-
const d = await diff(store, a, b);
|
|
88
|
-
expect(d.length).toBe(2);
|
|
89
|
-
expect(d[0]?.kind).toBe("same");
|
|
90
|
-
expect(d[1]?.kind).toBe("only-b");
|
|
91
|
-
// The only-b row carries B's checkpoint metadata and no `a` side.
|
|
92
|
-
expect(d[1]?.a).toBeUndefined();
|
|
93
|
-
expect(d[1]?.b?.checkpointId).toBe(extra.id);
|
|
94
|
-
expect(d[1]?.b?.nodeName).toBe("y");
|
|
95
|
-
expect(typeof d[1]?.b?.stateHash).toBe("string");
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe("stateHash (via diff)", () => {
|
|
100
|
-
// Regression: a checkpoint whose `state` serializes to `undefined`
|
|
101
|
-
// (e.g. `state: undefined`) once crashed `diff` with a TypeError from
|
|
102
|
-
// `createHash().update(undefined)`. It must now hash to a stable sentinel.
|
|
103
|
-
test("checkpoints with undefined state diff without throwing", async () => {
|
|
104
|
-
const a = newGraphRunId();
|
|
105
|
-
const b = newGraphRunId();
|
|
106
|
-
await store.save({ graphRunId: a, nodeName: "x", state: undefined });
|
|
107
|
-
await store.save({ graphRunId: b, nodeName: "x", state: undefined });
|
|
108
|
-
const d = await diff(store, a, b);
|
|
109
|
-
expect(d.length).toBe(1);
|
|
110
|
-
// Two undefined states hash identically, so the row is `same`.
|
|
111
|
-
expect(d[0]?.kind).toBe("same");
|
|
112
|
-
expect(d[0]?.a?.stateHash).toBe(d[0]?.b?.stateHash);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("undefined state differs from a defined state", async () => {
|
|
116
|
-
const a = newGraphRunId();
|
|
117
|
-
const b = newGraphRunId();
|
|
118
|
-
await store.save({ graphRunId: a, nodeName: "x", state: undefined });
|
|
119
|
-
await store.save({ graphRunId: b, nodeName: "x", state: { v: 1 } });
|
|
120
|
-
const d = await diff(store, a, b);
|
|
121
|
-
expect(d.length).toBe(1);
|
|
122
|
-
expect(d[0]?.kind).toBe("state-mismatch");
|
|
123
|
-
expect(d[0]?.a?.stateHash).not.toBe(d[0]?.b?.stateHash);
|
|
124
|
-
});
|
|
125
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
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
|
-
// `JSON.stringify` returns `undefined` for `state === undefined` (and for
|
|
49
|
-
// values that serialize to nothing, e.g. functions/symbols). Feeding that
|
|
50
|
-
// straight into `createHash().update()` throws a TypeError, so coalesce to a
|
|
51
|
-
// stable sentinel — every other state serializes unchanged, preserving hashes.
|
|
52
|
-
const serialized = JSON.stringify(c.state) ?? "undefined";
|
|
53
|
-
return createHash("sha256").update(serialized).digest("hex").slice(0, 16);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export async function branchAt(
|
|
57
|
-
store: CheckpointStore,
|
|
58
|
-
parentGraphRunId: GraphRunId,
|
|
59
|
-
checkpointId: CheckpointId,
|
|
60
|
-
): Promise<BranchAtResult> {
|
|
61
|
-
const result = await store.branch(parentGraphRunId, checkpointId);
|
|
62
|
-
return result;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function diff(
|
|
66
|
-
store: CheckpointStore,
|
|
67
|
-
graphRunIdA: GraphRunId,
|
|
68
|
-
graphRunIdB: GraphRunId,
|
|
69
|
-
): Promise<ReadonlyArray<NodeDiff>> {
|
|
70
|
-
const [a, b] = await Promise.all([store.list(graphRunIdA), store.list(graphRunIdB)]);
|
|
71
|
-
const out: NodeDiff[] = [];
|
|
72
|
-
const max = Math.max(a.length, b.length);
|
|
73
|
-
for (let i = 0; i < max; i += 1) {
|
|
74
|
-
const ca = a[i];
|
|
75
|
-
const cb = b[i];
|
|
76
|
-
if (ca === undefined && cb !== undefined) {
|
|
77
|
-
out.push({
|
|
78
|
-
position: i,
|
|
79
|
-
b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: stateHash(cb) },
|
|
80
|
-
kind: "only-b",
|
|
81
|
-
});
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
if (cb === undefined && ca !== undefined) {
|
|
85
|
-
out.push({
|
|
86
|
-
position: i,
|
|
87
|
-
a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: stateHash(ca) },
|
|
88
|
-
kind: "only-a",
|
|
89
|
-
});
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (ca !== undefined && cb !== undefined) {
|
|
93
|
-
const ha = stateHash(ca);
|
|
94
|
-
const hb = stateHash(cb);
|
|
95
|
-
if (ca.nodeName !== cb.nodeName) {
|
|
96
|
-
out.push({
|
|
97
|
-
position: i,
|
|
98
|
-
a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
|
|
99
|
-
b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
|
|
100
|
-
kind: "node-mismatch",
|
|
101
|
-
});
|
|
102
|
-
} else if (ha !== hb) {
|
|
103
|
-
out.push({
|
|
104
|
-
position: i,
|
|
105
|
-
a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
|
|
106
|
-
b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
|
|
107
|
-
kind: "state-mismatch",
|
|
108
|
-
});
|
|
109
|
-
} else {
|
|
110
|
-
out.push({
|
|
111
|
-
position: i,
|
|
112
|
-
a: { checkpointId: ca.id, nodeName: ca.nodeName, stateHash: ha },
|
|
113
|
-
b: { checkpointId: cb.id, nodeName: cb.nodeName, stateHash: hb },
|
|
114
|
-
kind: "same",
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return out;
|
|
120
|
-
}
|