@ontrails/topography 0.2.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/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # @ontrails/topography
2
+
3
+ Topography is the durable graph substrate for Trails: deterministic TopoGraphs, lockfile helpers, semantic diffing, topo-store persistence, and Wayfind graph-read APIs.
4
+
5
+ Most applications reach this package through top-level `trails compile`, `trails validate`, and `trails diff`. Those CLI trails layer workspace and topo-store behavior on top of the building blocks in `@ontrails/topography`. The package itself ships library entry points, not a separate CLI binary, and retired `trails topo compile`, `trails topo verify`, and `trails topo check` forms are not aliases.
6
+
7
+ ## Why this package earns a README
8
+
9
+ [ADR-0042](../../docs/adr/0042-core-topography-boundary-doctrine.md) draws the boundary by lifecycle: core owns the in-process graph, while Topography owns durable graph artifacts that cross process boundaries or compare state across time. That boundary is easy to blur because the same authored topo feeds both runtime execution and saved evidence, so this README keeps the package's job explicit.
10
+
11
+ The four-role story is:
12
+
13
+ - author with core;
14
+ - resolve to the topography;
15
+ - render with surfaces;
16
+ - govern with Warden.
17
+
18
+ This is not a private helper package. The Trails operator app consumes Topography for compile, validate, run, survey, topo history, and Wayfind support. `@ontrails/warden` consumes it for drift detection and topo-aware governance rules. `apps/trails-demo` consumes it as the example app's governance proof. Regrade's contract suite also derives TopoGraph evidence through it. Those independent consumers make the package boundary worth documenting directly.
19
+
20
+ ## What it owns
21
+
22
+ - deterministic TopoGraph generation from an established topo
23
+ - deterministic app-partitioned workspace views from Config-owned app identity
24
+ and app-local locks, with completeness, binding, freshness, collection-edge,
25
+ and unowned-lock evidence kept outside the canonical hash
26
+ - structured example and field-override provenance derivation for TopoGraph entries
27
+ - stable hashing for CI drift detection
28
+ - semantic diffing between two TopoGraphs
29
+ - file I/O helpers for app-root `trails.lock` plus legacy artifact-family readers
30
+ - the topo-store: queryable persistence of the resolved topo graph in the shared
31
+ `trails.db` in the per-user Trails state store, including snapshots, pinning,
32
+ history, and read-only query accessors (relocated from `@ontrails/core` per
33
+ ADR-0042)
34
+ - Wayfind graph-read trails and helpers over saved Topography artifacts,
35
+ including artifact loading, provenance envelopes, typed entity filters,
36
+ relation traversal, error facts, adapter facts, and explicit graph diffs
37
+
38
+ `@ontrails/topography` is the durable graph substrate for Trails. Generic `trails-db` plumbing (read/write SQLite handles, subsystem schema management, derived paths) stays in `@ontrails/core` so other subsystems (tracing, signals) can share it without depending on Topography.
39
+
40
+ ## Usage
41
+
42
+ ```typescript
43
+ import {
44
+ deriveTopoGraph,
45
+ deriveTopoGraphDiff,
46
+ deriveTopoGraphHash,
47
+ writeTrailsLock,
48
+ } from '@ontrails/topography';
49
+
50
+ const topoGraph = deriveTopoGraph(graph);
51
+ const hash = deriveTopoGraphHash(topoGraph);
52
+
53
+ await writeTrailsLock({
54
+ scope: { app: 'demo' },
55
+ summary: { entities: 0, resources: 0, signals: 0, trails: 1 },
56
+ topoGraph,
57
+ topoGraphHash: hash,
58
+ version: 5,
59
+ });
60
+
61
+ // Later, after changes:
62
+ const nextTopoGraph = deriveTopoGraph(graph);
63
+ const diff = deriveTopoGraphDiff(topoGraph, nextTopoGraph);
64
+
65
+ if (diff.hasBreaking) {
66
+ console.error('Breaking changes:', diff.breaking);
67
+ }
68
+ ```
69
+
70
+ Configured workspaces name apps through `@ontrails/config`. Topography consumes that static identity and reads exactly those app-root locks; its bounded lock census supplies coaching evidence but never discovers additional app identity:
71
+
72
+ ```typescript
73
+ import { readTrailsProjectIdentity } from '@ontrails/config';
74
+ import { deriveWorkspaceView } from '@ontrails/topography';
75
+
76
+ const projectRoot = process.cwd();
77
+ const identity = await readTrailsProjectIdentity({
78
+ boundaryDir: projectRoot,
79
+ startDir: projectRoot,
80
+ });
81
+ const view = await deriveWorkspaceView({ identity });
82
+
83
+ if (view.evidence.configuredCompleteness === 'partial') {
84
+ console.error(view.evidence.apps);
85
+ }
86
+ for (const unowned of view.evidence.unownedLocks) {
87
+ console.warn(unowned.path, unowned.coaching);
88
+ }
89
+ ```
90
+
91
+ `workspaceViewHash` exists only for a complete, correctly bound configured app set. It hashes the schema version, sorted app IDs and project-relative roots, app graph hashes, and collision facts. Selected scope, absolute checkout location, lock paths, freshness, collection skips, and unowned-lock evidence do not affect it. Passing `currentAppGraphHashes` proves `fresh` or `stale` state; without live evidence, saved graphs report freshness as `unknown`.
92
+
93
+ `deriveTopoGraph()` rejects draft-contaminated topos. Only established state can be serialized into the committed artifacts.
94
+
95
+ ## File outputs
96
+
97
+ The normal exported artifact is:
98
+
99
+ - `trails.lock` — committed resolved truth at the root of one lock-owning app.
100
+ It embeds the serialized TopoGraph plus the hash and summary needed for drift
101
+ detection. A configured workspace has one such file per configured app and no
102
+ workspace-root aggregate lock, unless the root itself is a configured app.
103
+
104
+ `trails compile` writes it from the current topo. `trails validate` and `@ontrails/warden` use the lockfile helpers here to detect drift.
105
+
106
+ Compatibility helpers still read the previous `.trails/trails.lock` plus `.trails/topo.lock` artifact family during the migration window. New writes should use `writeTrailsLock()`.
107
+
108
+ ## API
109
+
110
+ | Export | What it does |
111
+ | --- | --- |
112
+ | `deriveTopoGraph(topo)` | Deterministic TopoGraph of every established trail, signal, resource, and entity |
113
+ | `deriveActivationGraph(topoGraph)` | Static activation overview for trails, signals, and activation sources in a TopoGraph |
114
+ | `deriveDeclaredTrailActivation(entry)` | Trail-local activation report from a resolved TopoGraph entry |
115
+ | `deriveSignalActivationRelations(topoGraph)` | Signal-local activation relations for source and consumer navigation |
116
+ | `deriveTopoGraphHash(topoGraph)` | Stable SHA-256 hash of the TopoGraph |
117
+ | `deriveWorkspaceView(options)` | Config-fed app-partitioned saved graph view with separate observation evidence |
118
+ | `deriveTopoGraphDiff(prev, curr)` | Semantic diff with `breaking`, `warning`, and `info` classifications |
119
+ | `writeTrailsLock(lock, options?)` | Write an app-root `trails.lock` envelope |
120
+ | `readTrailsLock(options?)` | Read an app-root `trails.lock` envelope |
121
+ | `readTopoGraph(options?)` | Read a TopoGraph from v4 `trails.lock` or legacy `topo.lock` |
122
+ | `writeTopoGraph(topoGraph, options?)` | Write legacy `topo.lock` for explicit migration/testing paths |
123
+ | `writeLockManifest(manifest, options?)` | Write legacy `trails.lock` as a v3 manifest |
124
+ | `readLockManifest(options?)` | Read v3 manifests, deriving v4 locks back to v3 for compatibility |
125
+ | `createTopoStore(options?)` | Read-only query interface over the persisted topo state in the Trails state-store `trails.db` |
126
+ | `createMockTopoStore(seed?)` | Seeded in-memory mock for tests that need a `ReadOnlyTopoStore` |
127
+ | `topoStore` | Read-only `resource()` wrapper around `createTopoStore`, suitable for `resources: [...]` |
128
+ | `createTopoSnapshot(topo, options?)` | Persist a new topo snapshot row plus its denormalized derived facts |
129
+ | `listTopoSnapshots(options?)` | List historical topo snapshots (filterable by pinned status) |
130
+ | `pinTopoSnapshot(id, name, options?)` / `unpinTopoSnapshot(nameOrId, options?)` | Manage human-named pins |
131
+
132
+ ## Wayfind graph reads
133
+
134
+ Wayfind remains the product, trail-id, CLI, and MCP brand for graph navigation. The package boundary is Topography: there is no `@ontrails/wayfinder` compatibility package. Programmatic consumers should import the Wayfind APIs from `@ontrails/topography`:
135
+
136
+ ```typescript
137
+ import {
138
+ loadWayfinderArtifacts,
139
+ wayfindContractTrail,
140
+ wayfindOverviewTrail,
141
+ wayfinderTopo,
142
+ } from '@ontrails/topography';
143
+ ```
144
+
145
+ The Wayfind catalog is cold and deterministic. Graph queries read the selected app root's `trails.lock` and topo-store records; adapter queries read `@ontrails/adapter-kit` package and conformance evidence. They do not boot apps, resolve resources, reach the network, or mutate local state.
146
+
147
+ | Export | What it does |
148
+ | --- | --- |
149
+ | `wayfinderTopo` | Internal topo containing the reusable `wayfind.*` graph-read trails |
150
+ | `wayfindOverviewTrail` / `wayfindSearchTrail` | Summarize and search saved graph facts |
151
+ | `wayfindTrailsTrail` / `wayfindEntitiesTrail` / `wayfindResourcesTrail` / `wayfindSignalsTrail` | List typed graph populations with filters |
152
+ | `wayfindSurfacesTrail` / `wayfindTrailheadsTrail` | Inspect saved surface and trailhead membership facts |
153
+ | `wayfindVersionsTrail` / `wayfindExamplesTrail` | Inspect saved version and example facts without executing trails |
154
+ | `wayfindErrorsTrail` / `wayfindAdaptersTrail` / `wayfindOverlayTrail` | Inspect error facts, adapter evidence, and namespaced overlays |
155
+ | `wayfindDescribeTrail` / `wayfindContractTrail` | Inspect one saved entity or trail contract |
156
+ | `wayfindNearbyTrail` / `wayfindImpactTrail` | Traverse typed relation edges around saved graph entities |
157
+ | `wayfindDiffTrail` | Compare two explicit saved TopoGraph baselines as a low-level artifact query |
158
+ | `loadWayfinderArtifacts` | Load lock, TopoGraph, and topo-store evidence with drift status |
159
+ | `createWayfinderGraphEntityPredicate` / `filterWayfinderEntityRefs` | Reuse the typed Wayfind entity filter kit |
160
+
161
+ Wayfind trails are internal by default. Surface hosts expose selected query trails deliberately, usually by exact trail ID for operator tooling. The Trails operator CLI preserves the existing `trails wayfind` grammar and adds Config-owned project/app selection around saved navigation and semantic diff. Topography continues to own artifact loading, app-partitioned workspace derivation, and graph comparison; it does not interpret `--app`. The operator MCP surface preserves the selected direct `wayfind.*` tools through that same project-aware command boundary.
162
+
163
+ ### Operator File Outline
164
+
165
+ File outline is an operator capability, not a public Topography query trail. Use `trails wayfind file <file> --outline` for a compact map of authored trail and app declarations, surface membership, saved graph matches, and diagnostics. Add `--source` when the inspection also needs import, export, and declaration rows. The operator parses the explicit file through `@ontrails/source` and reconciles trail IDs with saved Topography artifacts. Missing artifacts are diagnostics, not hard failures, so outline remains useful in a fresh checkout or during repair work.
166
+
167
+ ### Backend Support Subpath
168
+
169
+ Direct shared database helper APIs are public, but they are backend-support APIs rather than root graph contracts. Import them from `@ontrails/topography/backend-support`:
170
+
171
+ ```typescript
172
+ import {
173
+ countPinnedSnapshots,
174
+ countPrunableSnapshots,
175
+ countTopoSnapshots,
176
+ createStoredTopoSnapshot,
177
+ getStoredTopoExport,
178
+ pruneUnpinnedSnapshots,
179
+ } from '@ontrails/topography/backend-support';
180
+ ```
181
+
182
+ This subpath owns lower-level snapshot counters, pruning helpers, and direct DB-handle variants for callers that already hold an open `trails.db` handle.
183
+
184
+ ## Breaking change detection
185
+
186
+ The diff classifies every change by severity:
187
+
188
+ | Change | Severity |
189
+ | --- | --- |
190
+ | Trail removed | breaking |
191
+ | Required input field added | breaking |
192
+ | Input or output field removed | breaking |
193
+ | Output field type changed | breaking |
194
+ | CLI path changed | breaking |
195
+ | Safety property changed | warning |
196
+ | Trail deprecated | warning |
197
+ | Compositions changed | warning |
198
+ | Declared resources changed | warning |
199
+ | Resource removed | breaking |
200
+ | Trail added | info |
201
+ | Resource added | info |
202
+ | Optional input field added | info |
203
+ | Output field added | info |
204
+
205
+ Because CLI paths are now full hierarchical command paths, command-tree changes are reflected directly in the semantic diff.
206
+
207
+ ## Drift detection with warden
208
+
209
+ ```typescript
210
+ import { deriveTopoGraph, deriveTopoGraphHash, readTrailsLock } from '@ontrails/topography';
211
+
212
+ const current = deriveTopoGraphHash(deriveTopoGraph(graph));
213
+ const committed = await readTrailsLock();
214
+
215
+ if (committed?.topoGraphHash !== current) {
216
+ // lock file is stale -- topo has changed
217
+ }
218
+ ```
219
+
220
+ The `@ontrails/warden` package wraps this into `checkDrift()` with CI-friendly reporting.
221
+
222
+ ## Installation
223
+
224
+ These commands target stable `0.2.0`. Run them after that version is published to npm.
225
+
226
+ ```bash
227
+ bun add --exact -d @ontrails/topography@0.2.0
228
+ ```
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@ontrails/topography",
3
+ "version": "0.2.0",
4
+ "description": "Durable graph substrate for Trails: deterministic TopoGraphs, lockfile helpers, and semantic diffing.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/outfitter-dev/trails.git",
8
+ "directory": "packages/topography"
9
+ },
10
+ "files": [
11
+ "src/**/*.ts",
12
+ "!src/**/__tests__/**",
13
+ "!src/**/*.test.ts",
14
+ "!src/**/*.test-d.ts",
15
+ "README.md",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "type": "module",
19
+ "exports": {
20
+ ".": "./src/index.ts",
21
+ "./backend-support": "./src/backend-support.ts",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -b",
26
+ "test": "bun test",
27
+ "typecheck": "tsc --noEmit",
28
+ "lint": "oxlint ./src",
29
+ "clean": "rm -rf dist *.tsbuildinfo"
30
+ },
31
+ "dependencies": {
32
+ "@ontrails/adapter-kit": "^0.2.0",
33
+ "@ontrails/config": "^0.2.0",
34
+ "@ontrails/source": "^0.2.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@ontrails/core": "^0.2.0",
38
+ "zod": "^4.3.5"
39
+ }
40
+ }
@@ -0,0 +1,385 @@
1
+ import type {
2
+ ActivationEntry,
3
+ ActivationSource,
4
+ ActivationSourceFacts,
5
+ AnyTrail,
6
+ Topo,
7
+ } from '@ontrails/core';
8
+ import {
9
+ activationSourceKey,
10
+ deriveActivationSourceFacts,
11
+ } from '@ontrails/core';
12
+
13
+ export interface ActivationChainReport {
14
+ readonly consumer: string;
15
+ readonly producer: string;
16
+ readonly signal: string;
17
+ }
18
+
19
+ type JsonSchemaReport = Readonly<Record<string, unknown>>;
20
+
21
+ export interface ActivationSourceReport extends ActivationSourceFacts {
22
+ readonly cron?: string | undefined;
23
+ readonly hasParse?: true | undefined;
24
+ readonly hasPayloadSchema?: true | undefined;
25
+ readonly hasVerify?: true | undefined;
26
+ readonly id: string;
27
+ readonly input?: unknown;
28
+ readonly inputSchema?: JsonSchemaReport | undefined;
29
+ readonly kind: string;
30
+ readonly key: string;
31
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
32
+ readonly method?: string | undefined;
33
+ readonly parseOutputSchema?: JsonSchemaReport | undefined;
34
+ readonly path?: string | undefined;
35
+ readonly payloadSchema?: JsonSchemaReport | undefined;
36
+ readonly timezone?: string | undefined;
37
+ }
38
+
39
+ export interface ActivationEdgeReport extends Readonly<
40
+ Record<string, unknown>
41
+ > {
42
+ readonly hasWhere: boolean;
43
+ readonly sourceId: string;
44
+ readonly sourceKey: string;
45
+ readonly sourceKind: string;
46
+ readonly trailId: string;
47
+ readonly where?: { readonly predicate: true } | undefined;
48
+ }
49
+
50
+ export interface SignalActivationRelations {
51
+ readonly consumers: readonly string[];
52
+ readonly producers: readonly string[];
53
+ }
54
+
55
+ export interface TrailActivationReport {
56
+ readonly activatedBy: readonly string[];
57
+ readonly activates: readonly string[];
58
+ readonly chains: readonly ActivationChainReport[];
59
+ readonly edges: readonly ActivationEdgeReport[];
60
+ readonly fires: readonly string[];
61
+ readonly on: readonly string[];
62
+ readonly sources: readonly ActivationSourceReport[];
63
+ }
64
+
65
+ export interface ActivationOverviewReport {
66
+ readonly chainCount: number;
67
+ readonly chains: readonly ActivationChainReport[];
68
+ readonly edgeCount: number;
69
+ readonly edges: readonly ActivationEdgeReport[];
70
+ readonly signalIds: readonly string[];
71
+ readonly sourceCount: number;
72
+ readonly sourceKeys: readonly string[];
73
+ readonly trailIds: readonly string[];
74
+ }
75
+
76
+ export interface ActivationGraphReport {
77
+ readonly overview: ActivationOverviewReport;
78
+ readonly signals: ReadonlyMap<string, SignalActivationRelations>;
79
+ readonly sources: ReadonlyMap<string, ActivationSourceReport>;
80
+ readonly trails: ReadonlyMap<string, TrailActivationReport>;
81
+ }
82
+
83
+ interface MutableSignalRelations {
84
+ readonly consumers: Set<string>;
85
+ readonly producers: Set<string>;
86
+ }
87
+
88
+ interface MutableTrailActivation {
89
+ readonly activatedBy: Set<string>;
90
+ readonly activates: Set<string>;
91
+ readonly chains: ActivationChainReport[];
92
+ readonly fires: readonly string[];
93
+ readonly on: readonly string[];
94
+ }
95
+
96
+ const canonicalLeaf = (value: unknown): unknown => {
97
+ switch (typeof value) {
98
+ case 'bigint': {
99
+ return value.toString();
100
+ }
101
+ case 'function': {
102
+ return `[Function:${value.name || 'anonymous'}]`;
103
+ }
104
+ case 'symbol': {
105
+ return `[Symbol:${value.description ?? ''}]`;
106
+ }
107
+ case 'undefined': {
108
+ return '[Undefined]';
109
+ }
110
+ default: {
111
+ return value;
112
+ }
113
+ }
114
+ };
115
+
116
+ const canonicalize = (value: unknown): unknown => {
117
+ if (Array.isArray(value)) {
118
+ return value.map(canonicalize);
119
+ }
120
+ if (value instanceof Date) {
121
+ return value.toISOString();
122
+ }
123
+ if (value instanceof RegExp) {
124
+ return value.toString();
125
+ }
126
+ if (value !== null && typeof value === 'object') {
127
+ const sorted: Record<string, unknown> = {};
128
+ for (const key of Object.keys(value).toSorted()) {
129
+ const next = (value as Record<string, unknown>)[key];
130
+ sorted[key] = next === undefined ? '[Undefined]' : canonicalize(next);
131
+ }
132
+ return sorted;
133
+ }
134
+ return canonicalLeaf(value);
135
+ };
136
+
137
+ const sortKeys = <T extends Record<string, unknown>>(value: T): T => {
138
+ const sorted: Record<string, unknown> = {};
139
+ for (const key of Object.keys(value).toSorted()) {
140
+ sorted[key] = value[key];
141
+ }
142
+ return sorted as T;
143
+ };
144
+
145
+ const compareChains = (
146
+ a: ActivationChainReport,
147
+ b: ActivationChainReport
148
+ ): number =>
149
+ a.producer.localeCompare(b.producer) ||
150
+ a.signal.localeCompare(b.signal) ||
151
+ a.consumer.localeCompare(b.consumer);
152
+
153
+ const sortedUnique = (values: Iterable<string>): readonly string[] =>
154
+ [...new Set(values)].toSorted();
155
+
156
+ const deriveActivationSource = (
157
+ source: ActivationSource
158
+ ): ActivationSourceReport =>
159
+ deriveActivationSourceFacts(source) as ActivationSourceReport;
160
+
161
+ const deriveActivationEdge = (
162
+ trailId: string,
163
+ activation: ActivationEntry
164
+ ): ActivationEdgeReport => {
165
+ const sourceKey = activationSourceKey(activation.source);
166
+ const edge: Record<string, unknown> = {
167
+ hasWhere: activation.where !== undefined,
168
+ sourceId: activation.source.id,
169
+ sourceKey,
170
+ sourceKind: activation.source.kind,
171
+ trailId,
172
+ };
173
+
174
+ if (activation.meta !== undefined) {
175
+ edge['meta'] = canonicalize(activation.meta);
176
+ }
177
+ if (activation.where !== undefined) {
178
+ edge['where'] = { predicate: true };
179
+ }
180
+
181
+ return sortKeys(edge) as ActivationEdgeReport;
182
+ };
183
+
184
+ const collectActivationSourceCatalog = (
185
+ trails: readonly AnyTrail[]
186
+ ): readonly ActivationSourceReport[] => {
187
+ const sources = new Map<string, ActivationSourceReport>();
188
+ for (const trail of trails) {
189
+ for (const activation of trail.activationSources) {
190
+ const derived = deriveActivationSource(activation.source);
191
+ sources.set(derived.key, derived);
192
+ }
193
+ }
194
+ return [...sources.values()].toSorted((a, b) => a.key.localeCompare(b.key));
195
+ };
196
+
197
+ const collectActivationEdges = (
198
+ trails: readonly AnyTrail[]
199
+ ): readonly ActivationEdgeReport[] => {
200
+ const edges = new Map<string, ActivationEdgeReport>();
201
+ for (const trail of trails) {
202
+ for (const activation of trail.activationSources) {
203
+ const edge = deriveActivationEdge(trail.id, activation);
204
+ const key = `${edge.sourceKey}\0${edge.trailId}`;
205
+ const previous = edges.get(key);
206
+ edges.set(
207
+ key,
208
+ previous === undefined || (!previous.hasWhere && edge.hasWhere)
209
+ ? edge
210
+ : previous
211
+ );
212
+ }
213
+ }
214
+
215
+ return [...edges.values()].toSorted(
216
+ (a, b) =>
217
+ a.sourceKey.localeCompare(b.sourceKey) ||
218
+ a.trailId.localeCompare(b.trailId)
219
+ );
220
+ };
221
+
222
+ const getSignalRelations = (
223
+ relations: Map<string, MutableSignalRelations>,
224
+ signalId: string
225
+ ): MutableSignalRelations => {
226
+ const existing = relations.get(signalId);
227
+ if (existing !== undefined) {
228
+ return existing;
229
+ }
230
+
231
+ const created = {
232
+ consumers: new Set<string>(),
233
+ producers: new Set<string>(),
234
+ };
235
+ relations.set(signalId, created);
236
+ return created;
237
+ };
238
+
239
+ const getTrailActivation = (
240
+ trails: Map<string, MutableTrailActivation>,
241
+ trail: AnyTrail
242
+ ): MutableTrailActivation => {
243
+ const existing = trails.get(trail.id);
244
+ if (existing !== undefined) {
245
+ return existing;
246
+ }
247
+
248
+ const created = {
249
+ activatedBy: new Set<string>(),
250
+ activates: new Set<string>(),
251
+ chains: [],
252
+ fires: sortedUnique(trail.fires),
253
+ on: sortedUnique(trail.on),
254
+ };
255
+ trails.set(trail.id, created);
256
+ return created;
257
+ };
258
+
259
+ export const deriveDeclaredTrailActivation = (
260
+ trail: AnyTrail
261
+ ): TrailActivationReport => {
262
+ const sources = collectActivationSourceCatalog([trail]);
263
+ return {
264
+ activatedBy: [],
265
+ activates: [],
266
+ chains: [],
267
+ edges: collectActivationEdges([trail]),
268
+ fires: sortedUnique(trail.fires),
269
+ on: sortedUnique(trail.on),
270
+ sources,
271
+ };
272
+ };
273
+
274
+ export const deriveSignalActivationRelations = (
275
+ app: Topo,
276
+ signalId: string
277
+ ): SignalActivationRelations => {
278
+ const consumers: string[] = [];
279
+ const producers: string[] = [];
280
+
281
+ for (const trail of app.list()) {
282
+ if (trail.fires.includes(signalId)) {
283
+ producers.push(trail.id);
284
+ }
285
+ if (trail.on.includes(signalId)) {
286
+ consumers.push(trail.id);
287
+ }
288
+ }
289
+
290
+ return {
291
+ consumers: sortedUnique(consumers),
292
+ producers: sortedUnique(producers),
293
+ };
294
+ };
295
+
296
+ export const deriveActivationGraph = (app: Topo): ActivationGraphReport => {
297
+ const signalRelations = new Map<string, MutableSignalRelations>();
298
+ const trailActivations = new Map<string, MutableTrailActivation>();
299
+ const trails = app.list();
300
+ const sources = collectActivationSourceCatalog(trails);
301
+ const edges = collectActivationEdges(trails);
302
+
303
+ for (const signal of app.listSignals()) {
304
+ getSignalRelations(signalRelations, signal.id);
305
+ }
306
+
307
+ for (const trail of trails) {
308
+ const trailActivation = getTrailActivation(trailActivations, trail);
309
+ for (const signalId of trailActivation.fires) {
310
+ getSignalRelations(signalRelations, signalId).producers.add(trail.id);
311
+ }
312
+ for (const signalId of trailActivation.on) {
313
+ getSignalRelations(signalRelations, signalId).consumers.add(trail.id);
314
+ }
315
+ }
316
+
317
+ const chains: ActivationChainReport[] = [];
318
+ for (const [signal, related] of signalRelations) {
319
+ for (const producer of related.producers) {
320
+ const producerActivation = trailActivations.get(producer);
321
+ for (const consumer of related.consumers) {
322
+ const chain = { consumer, producer, signal };
323
+ chains.push(chain);
324
+ producerActivation?.activates.add(consumer);
325
+ producerActivation?.chains.push(chain);
326
+ const consumerActivation = trailActivations.get(consumer);
327
+ consumerActivation?.activatedBy.add(producer);
328
+ if (consumerActivation !== producerActivation) {
329
+ consumerActivation?.chains.push(chain);
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ chains.sort(compareChains);
336
+ const activeTrailIds = sortedUnique([
337
+ ...[...trailActivations.entries()].flatMap(([trailId, trail]) =>
338
+ trail.fires.length > 0 || trail.on.length > 0
339
+ ? [trailId, ...trail.activatedBy, ...trail.activates]
340
+ : []
341
+ ),
342
+ ...edges.map((edge) => edge.trailId),
343
+ ]);
344
+
345
+ return {
346
+ overview: {
347
+ chainCount: chains.length,
348
+ chains,
349
+ edgeCount: edges.length,
350
+ edges,
351
+ signalIds: [...signalRelations.keys()].toSorted(),
352
+ sourceCount: sources.length,
353
+ sourceKeys: sources.map((source) => source.key),
354
+ trailIds: activeTrailIds,
355
+ },
356
+ signals: new Map(
357
+ [...signalRelations.entries()].map(([id, relations]) => [
358
+ id,
359
+ {
360
+ consumers: sortedUnique(relations.consumers),
361
+ producers: sortedUnique(relations.producers),
362
+ },
363
+ ])
364
+ ),
365
+ sources: new Map(sources.map((source) => [source.key, source])),
366
+ trails: new Map(
367
+ [...trailActivations.entries()].map(([id, activation]) => [
368
+ id,
369
+ {
370
+ activatedBy: sortedUnique(activation.activatedBy),
371
+ activates: sortedUnique(activation.activates),
372
+ chains: activation.chains.toSorted(compareChains),
373
+ edges: edges.filter((edge) => edge.trailId === id),
374
+ fires: activation.fires,
375
+ on: activation.on,
376
+ sources: sources.filter((source) =>
377
+ edges.some(
378
+ (edge) => edge.trailId === id && edge.sourceKey === source.key
379
+ )
380
+ ),
381
+ },
382
+ ])
383
+ ),
384
+ };
385
+ };
@@ -0,0 +1,11 @@
1
+ export {
2
+ countPinnedSnapshots,
3
+ countPrunableSnapshots,
4
+ countTopoSnapshots,
5
+ pruneUnpinnedSnapshots,
6
+ } from './internal/topo-snapshots.js';
7
+ export {
8
+ createTopoSnapshot as createStoredTopoSnapshot,
9
+ getStoredTopoExport,
10
+ } from './internal/topo-store.js';
11
+ export type { StoredTopoExport } from './internal/topo-store.js';