@nanobpm/urban 0.51.0 → 0.52.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.
@@ -0,0 +1,297 @@
1
+ // lineage — the framework-level lineage primitive (issue #254).
2
+ //
3
+ // Any Urban app wants to stitch *user intent → progress*: a single root request (a
4
+ // button press, an inbound webhook, an API call) fans out into the process instances,
5
+ // PRs, tasks and sub-loops it *causes*, and an operator wants to see that whole thread.
6
+ // Today every app hand-rolls that correlation. This module makes it a framework
7
+ // primitive so the next app gets intent→progress threading for free.
8
+ //
9
+ // Two DISTINCT relationships stitch the thread, and they must stay separate:
10
+ //
11
+ // - the LINEAGE / CAUSATION edge (this module): asynchronous causation — the cause does
12
+ // NOT block the effect, edges fan out (1→N), descendants settle independently and
13
+ // survive their ancestor's termination, and they are created by message-start / API
14
+ // `createInstance` / webhooks as much as by call activities. Carried as a reserved
15
+ // `_urban.lineage` envelope on every instance/message the SDK starts, AUTO-THREADED
16
+ // from the ambient job context (see {@link applyAmbientLineage}).
17
+ // - the EXECUTION edge (`parentProcessInstanceKey`, engine-native — Magikcraft/nano-bpm#808):
18
+ // synchronous delegation where the parent token blocks on the child. This module does
19
+ // NOT duplicate it; the read projection ({@link buildLineageTree}) CONSUMES it as a
20
+ // strong edge, unioned with the weak/causal edges of the envelope.
21
+ //
22
+ // This file is intentionally `node:*`-free (the host contract): the only ambient
23
+ // capability it reaches for is `crypto.randomUUID` (a Web/global, not `node:crypto`),
24
+ // with a portable fallback.
25
+
26
+ import { currentJobContext, type JobExecContext } from "./execContext.ts";
27
+ import { isRecord } from "./guards.ts";
28
+
29
+ /** The reserved variable namespace the lineage envelope lives under on every
30
+ * instance/message (`_urban.lineage`). Kept as one object so future framework
31
+ * conventions can share the `_urban.*` namespace without colliding. */
32
+ export const LINEAGE_NAMESPACE = "_urban";
33
+ /** The key of the lineage envelope within {@link LINEAGE_NAMESPACE}. */
34
+ export const LINEAGE_KEY = "lineage";
35
+
36
+ /**
37
+ * The lineage envelope, a reserved convention on every instance/message:
38
+ * `_urban.lineage = { rootRequestKey, causedByInstanceKey? }`.
39
+ * - `rootRequestKey` threads every descendant of a single top-level request back to
40
+ * it; it is PRESERVED verbatim across the whole causal tree.
41
+ * - `causedByInstanceKey` is the instance that directly caused this one (the weak
42
+ * parent edge). Absent on a genuine top-level request (a fresh root).
43
+ */
44
+ export interface LineageEnvelope {
45
+ readonly rootRequestKey: string;
46
+ readonly causedByInstanceKey?: string;
47
+ }
48
+
49
+ /** Options for {@link applyAmbientLineage}, injectable for deterministic tests. */
50
+ export interface ApplyLineageOptions {
51
+ /** Override the ambient job context (defaults to {@link currentJobContext}). */
52
+ readonly ambient?: JobExecContext | undefined;
53
+ /** Override the fresh-root minter (defaults to {@link mintRootRequestKey}). */
54
+ readonly mintRootRequestKey?: () => string;
55
+ }
56
+
57
+ /** Mint a fresh `rootRequestKey` for a genuine top-level request (no ambient lineage).
58
+ * Uses `crypto.randomUUID` where available (Node ≥ 16.7 / Deno expose it as a global),
59
+ * with a time+random fallback so this stays host-agnostic and never throws. */
60
+ export function mintRootRequestKey(): string {
61
+ const uuid = globalThis.crypto?.randomUUID?.();
62
+ if (uuid) return uuid;
63
+ return `root-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
64
+ }
65
+
66
+ /** Coerce a value to a non-empty string, or `undefined`. */
67
+ function nonEmptyString(value: unknown): string | undefined {
68
+ return typeof value === "string" && value !== "" ? value : undefined;
69
+ }
70
+
71
+ /**
72
+ * Read the `_urban.lineage` envelope from a variables map, or `undefined` when absent or
73
+ * malformed (no/blank `rootRequestKey`). Tolerant by design: a bad envelope degrades to
74
+ * "no ambient lineage" rather than throwing on the hot instance-creation path.
75
+ */
76
+ export function readLineage(variables: unknown): LineageEnvelope | undefined {
77
+ if (!isRecord(variables)) return undefined;
78
+ const ns = variables[LINEAGE_NAMESPACE];
79
+ if (!isRecord(ns)) return undefined;
80
+ const env = ns[LINEAGE_KEY];
81
+ if (!isRecord(env)) return undefined;
82
+ const rootRequestKey = nonEmptyString(env.rootRequestKey);
83
+ if (!rootRequestKey) return undefined;
84
+ return { rootRequestKey, causedByInstanceKey: nonEmptyString(env.causedByInstanceKey) };
85
+ }
86
+
87
+ /** Normalise an envelope for storage in variables — drop an absent `causedByInstanceKey`
88
+ * rather than serialising `undefined`, so a fresh root's envelope is `{ rootRequestKey }`. */
89
+ function toEnvelopeRecord(envelope: LineageEnvelope): Record<string, unknown> {
90
+ return envelope.causedByInstanceKey
91
+ ? { rootRequestKey: envelope.rootRequestKey, causedByInstanceKey: envelope.causedByInstanceKey }
92
+ : { rootRequestKey: envelope.rootRequestKey };
93
+ }
94
+
95
+ /**
96
+ * Return a COPY of `variables` with `envelope` written under `_urban.lineage`, preserving
97
+ * any other `_urban.*` keys. Never mutates the input.
98
+ */
99
+ export function writeLineage(
100
+ variables: Record<string, unknown> | undefined,
101
+ envelope: LineageEnvelope,
102
+ ): Record<string, unknown> {
103
+ const base = isRecord(variables) ? variables : {};
104
+ const ns = isRecord(base[LINEAGE_NAMESPACE]) ? base[LINEAGE_NAMESPACE] : {};
105
+ return { ...base, [LINEAGE_NAMESPACE]: { ...ns, [LINEAGE_KEY]: toEnvelopeRecord(envelope) } };
106
+ }
107
+
108
+ /**
109
+ * Derive the envelope to attach to a NEW instance/message given the ambient job context.
110
+ * - Ambient lineage present → PROPAGATE it: preserve `rootRequestKey`, set
111
+ * `causedByInstanceKey` to the current instance (the cause).
112
+ * - Ambient job with an instance but no envelope → that instance is itself a root; the
113
+ * child inherits `rootRequestKey = <ambient instance>` and is caused by it.
114
+ * - No ambient job at all (a genuine top-level request) → MINT a fresh `rootRequestKey`.
115
+ */
116
+ export function deriveLineage(
117
+ ambient: JobExecContext | undefined,
118
+ mint: () => string = mintRootRequestKey,
119
+ ): LineageEnvelope {
120
+ const rootRequestKey = ambient?.rootRequestKey ?? ambient?.instanceKey;
121
+ if (rootRequestKey) {
122
+ return { rootRequestKey, causedByInstanceKey: ambient?.instanceKey };
123
+ }
124
+ return { rootRequestKey: mint() };
125
+ }
126
+
127
+ /**
128
+ * The single, adapter-agnostic step that auto-threads lineage onto a `createInstance` /
129
+ * `publishMessage` call's variables. Both the live `SdkEngineClient` and the testkit's
130
+ * `WasmEngineClient` route through this (No Drift Surfaces), so lineage threads identically
131
+ * in production and in-harness.
132
+ *
133
+ * An EXPLICIT caller-supplied `_urban.lineage` always wins (start a new root or re-parent
134
+ * deliberately) — it is returned untouched. Otherwise the envelope is derived from the
135
+ * ambient job context ({@link deriveLineage}) and merged in.
136
+ */
137
+ export function applyAmbientLineage(
138
+ variables: Record<string, unknown> | undefined,
139
+ opts: ApplyLineageOptions = {},
140
+ ): Record<string, unknown> {
141
+ const base = isRecord(variables) ? variables : {};
142
+ if (readLineage(base)) return { ...base };
143
+ const ambient = opts.ambient !== undefined ? opts.ambient : currentJobContext();
144
+ return writeLineage(base, deriveLineage(ambient, opts.mintRootRequestKey));
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Read projection — union the weak (envelope) and strong (parentProcessInstanceKey)
149
+ // edges into a stitched descendant tree keyed by rootRequestKey.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /** How a node connects to its cause. `weak` = the causal envelope edge; `strong` = the
153
+ * engine-native execution edge (`parentProcessInstanceKey`, Magikcraft/nano-bpm#808). */
154
+ export type LineageEdgeType = "weak" | "strong";
155
+
156
+ /** One directed edge cause → effect in a lineage tree. `causedByInstanceKey` is absent on a
157
+ * root's own edge. */
158
+ export interface LineageEdge {
159
+ readonly rootRequestKey: string;
160
+ /** The effect / descendant instance this edge lands on. */
161
+ readonly instanceKey: string;
162
+ /** The cause / parent instance, or `undefined` for a root. */
163
+ readonly causedByInstanceKey?: string;
164
+ readonly edgeType: LineageEdgeType;
165
+ }
166
+
167
+ /** An app-registered domain row attached to a lineage node (a PR, a task, …). The framework
168
+ * stores it opaquely by `(nodeKey, kind, ref)` without knowing the app's schema. */
169
+ export interface LineageAttachment {
170
+ /** The instance key this row hangs off. */
171
+ readonly nodeKey: string;
172
+ /** App-defined category, e.g. `"pull_request"`, `"task"`. */
173
+ readonly kind: string;
174
+ /** App-defined reference into its own store, e.g. a PR key. */
175
+ readonly ref: string;
176
+ /** Optional human-readable label. */
177
+ readonly label?: string;
178
+ }
179
+
180
+ /** A node in the stitched lineage tree. */
181
+ export interface LineageNode {
182
+ readonly instanceKey: string;
183
+ /** How this node connects to its parent; `undefined` for the root. */
184
+ readonly edgeType?: LineageEdgeType;
185
+ readonly causedByInstanceKey?: string;
186
+ readonly attachments: LineageAttachment[];
187
+ readonly children: LineageNode[];
188
+ }
189
+
190
+ /** The stitched descendant tree for one `rootRequestKey`. */
191
+ export interface LineageTree {
192
+ readonly rootRequestKey: string;
193
+ /** The root node of the stitched tree, always present. Normally the top-level request's own
194
+ * instance; when `rootRequestKey` is a minted synthetic key, the parentless root instance it
195
+ * threads. Falls back to a synthetic node keyed by `rootRequestKey` when no edges exist. */
196
+ readonly root: LineageNode;
197
+ /** Every node keyed by instance, in insertion order — a convenience flat view. */
198
+ readonly nodes: LineageNode[];
199
+ }
200
+
201
+ /**
202
+ * Build the stitched descendant tree for `rootRequestKey` by unioning weak and strong edges.
203
+ * When both edge sources connect the SAME node, the STRONG (engine execution) edge wins — it
204
+ * is the authoritative parent — so #808's native edge supersedes the envelope's weak guess
205
+ * without duplicating it. Nodes whose parent chain does not reach the root stay unattached
206
+ * (they still appear in `nodes`). The root is the instance keyed by `rootRequestKey` when that
207
+ * is itself an instance, else the single parentless recorded instance (a minted `rootRequestKey`
208
+ * is a synthetic correlation key, not an instance), else a synthetic node. Pure and
209
+ * deterministic — the store is a thin persistence layer over this.
210
+ */
211
+ export function buildLineageTree(
212
+ rootRequestKey: string,
213
+ edges: readonly LineageEdge[],
214
+ attachments: readonly LineageAttachment[] = [],
215
+ ): LineageTree {
216
+ // Collapse to one edge per effect instance: strong supersedes weak.
217
+ const byInstance = new Map<string, LineageEdge>();
218
+ for (const e of edges) {
219
+ if (e.rootRequestKey !== rootRequestKey) continue;
220
+ const prev = byInstance.get(e.instanceKey);
221
+ if (!prev || (prev.edgeType === "weak" && e.edgeType === "strong")) {
222
+ byInstance.set(e.instanceKey, e);
223
+ }
224
+ }
225
+
226
+ const nodes = new Map<string, MutableNode>();
227
+ const ensure = (instanceKey: string): MutableNode => {
228
+ let n = nodes.get(instanceKey);
229
+ if (!n) {
230
+ n = { instanceKey, attachments: [], children: [] };
231
+ nodes.set(instanceKey, n);
232
+ }
233
+ return n;
234
+ };
235
+
236
+ // Speculatively materialise the `rootRequestKey` node so descendants whose cause IS the root
237
+ // (a child inherits the root's own instance key as `rootRequestKey`) can attach to it below.
238
+ const rootByKey = ensure(rootRequestKey);
239
+
240
+ for (const e of byInstance.values()) {
241
+ const node = ensure(e.instanceKey);
242
+ node.edgeType = e.edgeType;
243
+ node.causedByInstanceKey = e.causedByInstanceKey;
244
+ }
245
+
246
+ // Attachments are an app extension point keyed by instance. MATERIALISE the target node even
247
+ // when no edge has been recorded for it yet, so an attachment for an as-yet-unstitched instance
248
+ // is preserved in `tree.nodes` (unattached until its edge lands) rather than silently dropped.
249
+ for (const a of attachments) {
250
+ const node = ensure(a.nodeKey);
251
+ node.attachments.push({ nodeKey: a.nodeKey, kind: a.kind, ref: a.ref, ...(a.label ? { label: a.label } : {}) });
252
+ }
253
+
254
+ for (const node of nodes.values()) {
255
+ const parentKey = node.causedByInstanceKey;
256
+ if (parentKey && parentKey !== node.instanceKey && nodes.has(parentKey)) {
257
+ ensure(parentKey).children.push(node);
258
+ }
259
+ }
260
+
261
+ // Resolve the root node. Usually `rootRequestKey` IS the root instance's own key, so `rootByKey`
262
+ // carries that instance's edge and descendants. But a genuine top-level request (no ambient job)
263
+ // MINTS a synthetic `rootRequestKey` — a UUID that is not any instance — so `rootByKey` is an
264
+ // empty phantom while the real root is the single recorded instance with no cause. Prefer that
265
+ // parentless instance and drop the phantom, so the tree is never rooted at a childless synthetic
266
+ // node with the real root left floating detached (the bug this guards against).
267
+ let root = rootByKey;
268
+ const rootIsPhantom =
269
+ rootByKey.edgeType === undefined && rootByKey.children.length === 0 && rootByKey.attachments.length === 0;
270
+ if (rootIsPhantom) {
271
+ // A candidate real root is a parentless node that is genuinely part of the causal structure —
272
+ // it has its own recorded edge or descendants. Attachment-only orphan nodes (materialised above
273
+ // with no edge and no children) are NOT candidates, so a stray attachment can never shadow the
274
+ // real parentless root and re-introduce the phantom-root bug guarded by the minted-root case.
275
+ const parentlessInstances = [...nodes.values()].filter(
276
+ (n) =>
277
+ n !== rootByKey &&
278
+ n.causedByInstanceKey === undefined &&
279
+ (n.edgeType !== undefined || n.children.length > 0),
280
+ );
281
+ if (parentlessInstances.length === 1) {
282
+ root = parentlessInstances[0];
283
+ nodes.delete(rootRequestKey);
284
+ }
285
+ }
286
+
287
+ return { rootRequestKey, root, nodes: [...nodes.values()] };
288
+ }
289
+
290
+ /** The internal, mutable view of {@link LineageNode} used while building the tree. */
291
+ interface MutableNode {
292
+ instanceKey: string;
293
+ edgeType?: LineageEdgeType;
294
+ causedByInstanceKey?: string;
295
+ attachments: LineageAttachment[];
296
+ children: MutableNode[];
297
+ }
@@ -281,6 +281,15 @@ export class DataLayer {
281
281
  return s;
282
282
  }
283
283
 
284
+ /**
285
+ * Whether a default data source is *configured* — not whether it provisions. Lets a caller
286
+ * distinguish the expected "no source at all" case from a genuine misconfiguration (a default is
287
+ * named but its source is missing, so {@link source} throws `no such data source`).
288
+ */
289
+ hasDefaultSource(): boolean {
290
+ return this.defaultSource !== undefined;
291
+ }
292
+
284
293
  /** A typed accessor for a declared domain type (uses the default source). */
285
294
  repo(typeName: string, sourceName?: string): TypeRepo {
286
295
  const def = this.types[typeName];
@@ -0,0 +1,177 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { createNodeHost } from "../../adapters/node.ts";
9
+ import type { SqliteDb } from "../host.ts";
10
+ import { makeGateway } from "./gateway.ts";
11
+ import {
12
+ LINEAGE_ATTACHMENTS_TABLE,
13
+ LINEAGE_EDGES_TABLE,
14
+ LINEAGE_SCHEMA_SQL,
15
+ LineageStore,
16
+ } from "./lineage-store.ts";
17
+
18
+ async function withStore(fn: (store: LineageStore, db: SqliteDb) => void | Promise<void>): Promise<void> {
19
+ const dir = await mkdtemp(join(tmpdir(), "urban-lineage-"));
20
+ const host = createNodeHost({ cwd: dir, log: () => {} });
21
+ const db = host.openSqlite(join(dir, "test.db"));
22
+ const store = new LineageStore(db, { clock: { now: () => 0 } });
23
+ store.ensureSchema();
24
+ try {
25
+ await fn(store, db);
26
+ } finally {
27
+ db.close();
28
+ await rm(dir, { recursive: true, force: true });
29
+ }
30
+ }
31
+
32
+ function normaliseSql(sql: string): string {
33
+ return sql
34
+ .split("\n")
35
+ .map((line) => line.replace(/--.*$/, ""))
36
+ .join("\n")
37
+ .replace(/\s+/g, " ")
38
+ .trim();
39
+ }
40
+
41
+ const migrationPath = fileURLToPath(new URL("../../../../../../db/migrations/004_urban_lineage.sql", import.meta.url));
42
+
43
+ test("the lineage projection tables are framework bookkeeping — `_urban_`-prefixed and hidden from the domain model", async () => {
44
+ // The gateway hides `_urban_%` / `_nano_%` tables from the domain model / DB Manager. These
45
+ // projection tables are framework sidecars (not user/domain tables), so their names MUST carry
46
+ // that prefix or they would leak into apps' schemas.
47
+ assert.match(LINEAGE_EDGES_TABLE, /^_urban_/, "lineage edge table must be `_urban_`-prefixed to stay hidden");
48
+ assert.match(LINEAGE_ATTACHMENTS_TABLE, /^_urban_/, "lineage attachment table must be `_urban_`-prefixed to stay hidden");
49
+ await withStore(async (_store, db) => {
50
+ const surfaced = (await makeGateway(db).schema()).map((t) => t.name);
51
+ assert.ok(!surfaced.includes(LINEAGE_EDGES_TABLE), "lineage edge table must not surface in the domain model");
52
+ assert.ok(!surfaced.includes(LINEAGE_ATTACHMENTS_TABLE), "lineage attachment table must not surface in the domain model");
53
+ });
54
+ });
55
+
56
+ test("the boot migration and LineageStore's DDL do not drift", () => {
57
+ const migrationSql = readFileSync(migrationPath, "utf8");
58
+ assert.equal(
59
+ normaliseSql(migrationSql),
60
+ normaliseSql(LINEAGE_SCHEMA_SQL),
61
+ "db/migrations/004_urban_lineage.sql must match LINEAGE_SCHEMA_SQL — update both together",
62
+ );
63
+ });
64
+
65
+ test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
66
+ const migrationSql = readFileSync(migrationPath, "utf8");
67
+ assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS _urban_lineage_edges/);
68
+ assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS _urban_lineage_attachments/);
69
+ assert.doesNotMatch(migrationSql, /\bDROP\b/i);
70
+ assert.doesNotMatch(migrationSql, /\bALTER\b/i);
71
+ // Numbering is a shared surface; guard the prefix so a rebase renumber is caught here.
72
+ assert.match(migrationPath, /[/\\]004_urban_lineage\.sql$/);
73
+ });
74
+
75
+ test("ensureSchema creates both lineage tables idempotently", async () => {
76
+ await withStore((store, db) => {
77
+ store.ensureSchema();
78
+ store.ensureSchema();
79
+ const tables = db
80
+ .all<{ name: string }>("SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?)", [
81
+ LINEAGE_EDGES_TABLE,
82
+ LINEAGE_ATTACHMENTS_TABLE,
83
+ ])
84
+ .map((r) => r.name)
85
+ .sort();
86
+ assert.deepEqual(tables, [LINEAGE_ATTACHMENTS_TABLE, LINEAGE_EDGES_TABLE].sort());
87
+ });
88
+ });
89
+
90
+ test("recordEdge is idempotent (a re-record of the same node/edge-type is a no-op)", async () => {
91
+ await withStore((store) => {
92
+ const edge = {
93
+ rootRequestKey: "root",
94
+ instanceKey: "child",
95
+ causedByInstanceKey: "root",
96
+ edgeType: "weak" as const,
97
+ };
98
+ assert.equal(store.recordEdge(edge), true);
99
+ assert.equal(store.recordEdge(edge), false, "re-record collapses to a no-op");
100
+ assert.equal(store.edges("root").length, 1);
101
+ });
102
+ });
103
+
104
+ test("recordEnvelope records the weak edge carried by an instance's _urban.lineage", async () => {
105
+ await withStore((store) => {
106
+ const recorded = store.recordEnvelope("child", {
107
+ _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "root" } },
108
+ });
109
+ assert.equal(recorded, true);
110
+ assert.deepEqual(store.edges("root"), [
111
+ { rootRequestKey: "root", instanceKey: "child", causedByInstanceKey: "root", edgeType: "weak" },
112
+ ]);
113
+ // No envelope → nothing recorded.
114
+ assert.equal(store.recordEnvelope("x", { plain: 1 }), false);
115
+ });
116
+ });
117
+
118
+ test("recordFromJob records both the weak envelope edge and the strong execution edge (#808)", async () => {
119
+ await withStore((store) => {
120
+ store.recordFromJob({
121
+ processInstanceKey: "child",
122
+ parentProcessInstanceKey: "parent",
123
+ variables: { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "cause" } } },
124
+ });
125
+ const edges = store.edges("root");
126
+ assert.equal(edges.length, 2);
127
+ const strong = edges.find((e) => e.edgeType === "strong");
128
+ assert.deepEqual(strong, {
129
+ rootRequestKey: "root",
130
+ instanceKey: "child",
131
+ causedByInstanceKey: "parent",
132
+ edgeType: "strong",
133
+ });
134
+ // getLineage unions them: the strong execution edge wins for the node.
135
+ const tree = store.getLineage("root");
136
+ const node = tree.nodes.find((n) => n.instanceKey === "child");
137
+ assert.equal(node?.edgeType, "strong");
138
+ });
139
+ });
140
+
141
+ test("attach hangs an app domain row off a node, idempotently, and getLineage returns it", async () => {
142
+ await withStore((store) => {
143
+ store.recordEnvelope("child", { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "root" } } });
144
+ assert.equal(store.attach("root", { nodeKey: "child", kind: "pull_request", ref: "o/r#7", label: "PR 7" }), true);
145
+ assert.equal(store.attach("root", { nodeKey: "child", kind: "pull_request", ref: "o/r#7", label: "PR 7" }), false);
146
+ const tree = store.getLineage("root");
147
+ const child = tree.root.children[0];
148
+ assert.equal(child.instanceKey, "child");
149
+ assert.deepEqual(child.attachments, [{ nodeKey: "child", kind: "pull_request", ref: "o/r#7", label: "PR 7" }]);
150
+ });
151
+ });
152
+
153
+ test("getLineage returns the full descendant tree keyed by rootRequestKey", async () => {
154
+ await withStore((store) => {
155
+ // A root fans out to two children; one child spawns a grandchild.
156
+ store.recordEdge({ rootRequestKey: "root", instanceKey: "root", edgeType: "weak" });
157
+ store.recordEnvelope("c1", { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "root" } } });
158
+ store.recordEnvelope("c2", { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "root" } } });
159
+ store.recordEnvelope("g1", { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "c1" } } });
160
+
161
+ const tree = store.getLineage("root");
162
+ assert.equal(tree.rootRequestKey, "root");
163
+ assert.equal(tree.root.instanceKey, "root");
164
+ assert.deepEqual(tree.root.children.map((c) => c.instanceKey).sort(), ["c1", "c2"]);
165
+ const c1 = tree.root.children.find((c) => c.instanceKey === "c1");
166
+ assert.deepEqual(c1?.children.map((c) => c.instanceKey), ["g1"]);
167
+ });
168
+ });
169
+
170
+ test("getLineage returns a lone-root tree for a root with no recorded descendants", async () => {
171
+ await withStore((store) => {
172
+ const tree = store.getLineage("unknown-root");
173
+ assert.equal(tree.root.instanceKey, "unknown-root");
174
+ assert.deepEqual(tree.root.children, []);
175
+ assert.equal(tree.nodes.length, 1);
176
+ });
177
+ });