@ontrails/core 1.0.0-beta.13 → 1.0.0-beta.15

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.
Files changed (287) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +19 -14
  4. package/dist/branded.d.ts +1 -1
  5. package/dist/branded.d.ts.map +1 -1
  6. package/dist/branded.js +1 -1
  7. package/dist/branded.js.map +1 -1
  8. package/dist/context.d.ts +13 -1
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +19 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/contour.d.ts +81 -0
  13. package/dist/contour.d.ts.map +1 -0
  14. package/dist/contour.js +176 -0
  15. package/dist/contour.js.map +1 -0
  16. package/dist/cross-schema.d.ts +18 -0
  17. package/dist/cross-schema.d.ts.map +1 -0
  18. package/dist/cross-schema.js +29 -0
  19. package/dist/cross-schema.js.map +1 -0
  20. package/dist/derive.d.ts +6 -0
  21. package/dist/derive.d.ts.map +1 -1
  22. package/dist/derive.js +29 -5
  23. package/dist/derive.js.map +1 -1
  24. package/dist/draft.d.ts +28 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +157 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/errors.d.ts +62 -4
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +44 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/execute.d.ts +26 -9
  33. package/dist/execute.d.ts.map +1 -1
  34. package/dist/execute.js +441 -20
  35. package/dist/execute.js.map +1 -1
  36. package/dist/fire.d.ts +43 -0
  37. package/dist/fire.d.ts.map +1 -0
  38. package/dist/fire.js +185 -0
  39. package/dist/fire.js.map +1 -0
  40. package/dist/index.d.ts +33 -17
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +26 -12
  43. package/dist/index.js.map +1 -1
  44. package/dist/internal/cross-batch.d.ts +44 -0
  45. package/dist/internal/cross-batch.d.ts.map +1 -0
  46. package/dist/internal/cross-batch.js +58 -0
  47. package/dist/internal/cross-batch.js.map +1 -0
  48. package/dist/internal/fork-ctx.d.ts +41 -0
  49. package/dist/internal/fork-ctx.d.ts.map +1 -0
  50. package/dist/internal/fork-ctx.js +47 -0
  51. package/dist/internal/fork-ctx.js.map +1 -0
  52. package/dist/internal/signal-ref.d.ts +15 -0
  53. package/dist/internal/signal-ref.d.ts.map +1 -0
  54. package/dist/internal/signal-ref.js +41 -0
  55. package/dist/internal/signal-ref.js.map +1 -0
  56. package/dist/internal/topo-saves.d.ts +53 -0
  57. package/dist/internal/topo-saves.d.ts.map +1 -0
  58. package/dist/internal/topo-saves.js +449 -0
  59. package/dist/internal/topo-saves.js.map +1 -0
  60. package/dist/internal/topo-snapshots.d.ts +57 -0
  61. package/dist/internal/topo-snapshots.d.ts.map +1 -0
  62. package/dist/internal/topo-snapshots.js +372 -0
  63. package/dist/internal/topo-snapshots.js.map +1 -0
  64. package/dist/internal/topo-store-read.d.ts +70 -0
  65. package/dist/internal/topo-store-read.d.ts.map +1 -0
  66. package/dist/internal/topo-store-read.js +221 -0
  67. package/dist/internal/topo-store-read.js.map +1 -0
  68. package/dist/internal/topo-store.d.ts +26 -0
  69. package/dist/internal/topo-store.d.ts.map +1 -0
  70. package/dist/internal/topo-store.js +621 -0
  71. package/dist/internal/topo-store.js.map +1 -0
  72. package/dist/internal/tracing.d.ts +98 -0
  73. package/dist/internal/tracing.d.ts.map +1 -0
  74. package/dist/internal/tracing.js +102 -0
  75. package/dist/internal/tracing.js.map +1 -0
  76. package/dist/internal/trails-db.d.ts +16 -0
  77. package/dist/internal/trails-db.d.ts.map +1 -0
  78. package/dist/internal/trails-db.js +118 -0
  79. package/dist/internal/trails-db.js.map +1 -0
  80. package/dist/internal/zod-wrappers.d.ts +40 -0
  81. package/dist/internal/zod-wrappers.d.ts.map +1 -0
  82. package/dist/internal/zod-wrappers.js +59 -0
  83. package/dist/internal/zod-wrappers.js.map +1 -0
  84. package/dist/layer.d.ts +22 -5
  85. package/dist/layer.d.ts.map +1 -1
  86. package/dist/layer.js +20 -3
  87. package/dist/layer.js.map +1 -1
  88. package/dist/path-security.d.ts +1 -1
  89. package/dist/path-security.d.ts.map +1 -1
  90. package/dist/path-security.js +1 -1
  91. package/dist/path-security.js.map +1 -1
  92. package/dist/resilience.d.ts +1 -1
  93. package/dist/resilience.d.ts.map +1 -1
  94. package/dist/resilience.js +3 -3
  95. package/dist/resilience.js.map +1 -1
  96. package/dist/resource-config.d.ts +22 -0
  97. package/dist/resource-config.d.ts.map +1 -0
  98. package/dist/resource-config.js +210 -0
  99. package/dist/resource-config.js.map +1 -0
  100. package/dist/resource.d.ts +74 -0
  101. package/dist/resource.d.ts.map +1 -0
  102. package/dist/resource.js +61 -0
  103. package/dist/resource.js.map +1 -0
  104. package/dist/run.d.ts +4 -3
  105. package/dist/run.d.ts.map +1 -1
  106. package/dist/run.js +5 -4
  107. package/dist/run.js.map +1 -1
  108. package/dist/serialization.d.ts.map +1 -1
  109. package/dist/serialization.js +2 -1
  110. package/dist/serialization.js.map +1 -1
  111. package/dist/store/accessor-protocol.d.ts +51 -0
  112. package/dist/store/accessor-protocol.d.ts.map +1 -0
  113. package/dist/store/accessor-protocol.js +2 -0
  114. package/dist/store/accessor-protocol.js.map +1 -0
  115. package/dist/store/index.d.ts +2 -0
  116. package/dist/store/index.d.ts.map +1 -0
  117. package/dist/store/index.js +2 -0
  118. package/dist/store/index.js.map +1 -0
  119. package/dist/surface-filter.d.ts +13 -0
  120. package/dist/surface-filter.d.ts.map +1 -0
  121. package/dist/surface-filter.js +76 -0
  122. package/dist/surface-filter.js.map +1 -0
  123. package/dist/topo-store.d.ts +62 -0
  124. package/dist/topo-store.d.ts.map +1 -0
  125. package/dist/topo-store.js +319 -0
  126. package/dist/topo-store.js.map +1 -0
  127. package/dist/topo.d.ts +23 -9
  128. package/dist/topo.d.ts.map +1 -1
  129. package/dist/topo.js +175 -46
  130. package/dist/topo.js.map +1 -1
  131. package/dist/trail.d.ts +86 -19
  132. package/dist/trail.d.ts.map +1 -1
  133. package/dist/trail.js +59 -4
  134. package/dist/trail.js.map +1 -1
  135. package/dist/trails/derive-trail.d.ts +66 -0
  136. package/dist/trails/derive-trail.d.ts.map +1 -0
  137. package/dist/trails/derive-trail.js +378 -0
  138. package/dist/trails/derive-trail.js.map +1 -0
  139. package/dist/trails/index.d.ts +5 -0
  140. package/dist/trails/index.d.ts.map +1 -0
  141. package/dist/trails/index.js +3 -0
  142. package/dist/trails/index.js.map +1 -0
  143. package/dist/trails/ingest.d.ts +24 -0
  144. package/dist/trails/ingest.d.ts.map +1 -0
  145. package/dist/trails/ingest.js +63 -0
  146. package/dist/trails/ingest.js.map +1 -0
  147. package/dist/transport-error-map.d.ts +106 -0
  148. package/dist/transport-error-map.d.ts.map +1 -0
  149. package/dist/transport-error-map.js +24 -0
  150. package/dist/transport-error-map.js.map +1 -0
  151. package/dist/type-checks.test-d.d.ts +71 -0
  152. package/dist/type-checks.test-d.d.ts.map +1 -0
  153. package/dist/type-checks.test-d.js +12 -0
  154. package/dist/type-checks.test-d.js.map +1 -0
  155. package/dist/type-utils.d.ts +11 -2
  156. package/dist/type-utils.d.ts.map +1 -1
  157. package/dist/type-utils.js.map +1 -1
  158. package/dist/types.d.ts +113 -7
  159. package/dist/types.d.ts.map +1 -1
  160. package/dist/types.js.map +1 -1
  161. package/dist/validate-established-topo.d.ts +76 -0
  162. package/dist/validate-established-topo.d.ts.map +1 -0
  163. package/dist/validate-established-topo.js +43 -0
  164. package/dist/validate-established-topo.js.map +1 -0
  165. package/dist/validate-topo.d.ts.map +1 -1
  166. package/dist/validate-topo.js +27 -8
  167. package/dist/validate-topo.js.map +1 -1
  168. package/dist/workspace.d.ts +1 -1
  169. package/dist/workspace.d.ts.map +1 -1
  170. package/dist/workspace.js +1 -1
  171. package/dist/workspace.js.map +1 -1
  172. package/package.json +10 -1
  173. package/src/__tests__/branded.test.ts +5 -5
  174. package/src/__tests__/context.test.ts +3 -3
  175. package/src/__tests__/contour.test.ts +263 -0
  176. package/src/__tests__/derive-trail.test.ts +668 -0
  177. package/src/__tests__/derive.test.ts +58 -1
  178. package/src/__tests__/errors.test.ts +118 -0
  179. package/src/__tests__/execute.test.ts +1179 -141
  180. package/src/__tests__/fire.test.ts +1056 -0
  181. package/src/__tests__/fork-ctx.test.ts +83 -0
  182. package/src/__tests__/ingest.test.ts +191 -0
  183. package/src/__tests__/{gate.test.ts → layer.test.ts} +21 -21
  184. package/src/__tests__/path-security.test.ts +7 -7
  185. package/src/__tests__/resilience.test.ts +8 -8
  186. package/src/__tests__/resource.test.ts +204 -0
  187. package/src/__tests__/run.test.ts +13 -13
  188. package/src/__tests__/service-config.test.ts +28 -32
  189. package/src/__tests__/surface-filter.test.ts +196 -0
  190. package/src/__tests__/topo-store-read.test.ts +256 -0
  191. package/src/__tests__/topo-store.test.ts +869 -0
  192. package/src/__tests__/topo.test.ts +350 -54
  193. package/src/__tests__/trail.test.ts +189 -24
  194. package/src/__tests__/trails-db.test.ts +198 -0
  195. package/src/__tests__/transport-error-map.test.ts +82 -0
  196. package/src/__tests__/type-utils.test.ts +29 -1
  197. package/src/__tests__/validate-topo.test.ts +305 -16
  198. package/src/__tests__/workspace.test.ts +6 -6
  199. package/src/__tests__/zod-wrappers.test.ts +62 -0
  200. package/src/branded.ts +1 -1
  201. package/src/context.ts +24 -4
  202. package/src/contour.ts +344 -0
  203. package/src/cross-schema.ts +36 -0
  204. package/src/derive.ts +39 -8
  205. package/src/draft.ts +338 -0
  206. package/src/errors.ts +62 -18
  207. package/src/execute.ts +840 -29
  208. package/src/fire.ts +295 -0
  209. package/src/index.ts +142 -26
  210. package/src/internal/cross-batch.ts +77 -0
  211. package/src/internal/fork-ctx.ts +69 -0
  212. package/src/internal/signal-ref.ts +87 -0
  213. package/src/internal/topo-snapshots.ts +517 -0
  214. package/src/internal/topo-store-read.ts +481 -0
  215. package/src/internal/topo-store.ts +1192 -0
  216. package/src/internal/tracing.ts +187 -0
  217. package/src/internal/trails-db.ts +187 -0
  218. package/src/internal/zod-wrappers.ts +78 -0
  219. package/src/layer.ts +50 -0
  220. package/src/path-security.ts +1 -1
  221. package/src/resilience.ts +3 -3
  222. package/src/{provision-config.ts → resource-config.ts} +109 -123
  223. package/src/resource.ts +155 -0
  224. package/src/run.ts +5 -4
  225. package/src/serialization.ts +2 -0
  226. package/src/store/accessor-protocol.ts +56 -0
  227. package/src/store/index.ts +4 -0
  228. package/src/surface-filter.ts +182 -0
  229. package/src/topo-store.ts +510 -0
  230. package/src/topo.ts +328 -57
  231. package/src/trail.ts +199 -31
  232. package/src/trails/derive-trail.ts +842 -0
  233. package/src/trails/index.ts +9 -0
  234. package/src/trails/ingest.ts +146 -0
  235. package/src/transport-error-map.ts +56 -0
  236. package/src/type-checks.test-d.ts +106 -0
  237. package/src/type-utils.ts +17 -2
  238. package/src/types.ts +148 -11
  239. package/src/validate-established-topo.ts +63 -0
  240. package/src/validate-topo.ts +37 -9
  241. package/src/workspace.ts +1 -1
  242. package/tsconfig.tests.json +10 -0
  243. package/tsconfig.tsbuildinfo +1 -1
  244. package/dist/adapters.d.ts +0 -39
  245. package/dist/adapters.d.ts.map +0 -1
  246. package/dist/adapters.js +0 -2
  247. package/dist/adapters.js.map +0 -1
  248. package/dist/dispatch.d.ts +0 -27
  249. package/dist/dispatch.d.ts.map +0 -1
  250. package/dist/dispatch.js +0 -34
  251. package/dist/dispatch.js.map +0 -1
  252. package/dist/event.d.ts +0 -8
  253. package/dist/event.d.ts.map +0 -1
  254. package/dist/event.js +0 -7
  255. package/dist/event.js.map +0 -1
  256. package/dist/gate.d.ts +0 -17
  257. package/dist/gate.d.ts.map +0 -1
  258. package/dist/gate.js +0 -21
  259. package/dist/gate.js.map +0 -1
  260. package/dist/health.d.ts +0 -18
  261. package/dist/health.d.ts.map +0 -1
  262. package/dist/health.js +0 -5
  263. package/dist/health.js.map +0 -1
  264. package/dist/job.d.ts +0 -24
  265. package/dist/job.d.ts.map +0 -1
  266. package/dist/job.js +0 -17
  267. package/dist/job.js.map +0 -1
  268. package/dist/provision-config.d.ts +0 -22
  269. package/dist/provision-config.d.ts.map +0 -1
  270. package/dist/provision-config.js +0 -210
  271. package/dist/provision-config.js.map +0 -1
  272. package/dist/provision.d.ts +0 -71
  273. package/dist/provision.d.ts.map +0 -1
  274. package/dist/provision.js +0 -56
  275. package/dist/provision.js.map +0 -1
  276. package/dist/service-config.d.ts +0 -22
  277. package/dist/service-config.d.ts.map +0 -1
  278. package/dist/service-config.js +0 -210
  279. package/dist/service-config.js.map +0 -1
  280. package/dist/service.d.ts +0 -71
  281. package/dist/service.d.ts.map +0 -1
  282. package/dist/service.js +0 -56
  283. package/dist/service.js.map +0 -1
  284. package/src/__tests__/service.test.ts +0 -197
  285. package/src/event.ts +0 -15
  286. package/src/gate.ts +0 -44
  287. package/src/provision.ts +0 -148
@@ -0,0 +1,1192 @@
1
+ import type { Database, SQLQueryBindings } from 'bun:sqlite';
2
+
3
+ import { getContourReferences } from '../contour.js';
4
+ import { deriveCliPath } from '../derive.js';
5
+ import { Result } from '../result.js';
6
+ import type { AnyContour } from '../contour.js';
7
+ import type { AnyResource } from '../resource.js';
8
+ import type { AnySignal } from '../signal.js';
9
+ import type { Topo } from '../topo.js';
10
+ import type { AnyTrail } from '../trail.js';
11
+ import { validateEstablishedTopo } from '../validate-established-topo.js';
12
+ import { zodToJsonSchema } from '../validation.js';
13
+ import type {
14
+ CreateTopoSnapshotInput,
15
+ TopoSnapshot,
16
+ } from './topo-snapshots.js';
17
+ import {
18
+ ensureTopoSnapshotSchema,
19
+ insertTopoSnapshotRecord,
20
+ } from './topo-snapshots.js';
21
+
22
+ type SurfaceMapEntryRecord = Readonly<Record<string, unknown>> & {
23
+ readonly id: string;
24
+ readonly kind: 'contour' | 'resource' | 'signal' | 'trail';
25
+ };
26
+
27
+ type SurfaceMapRecord = Readonly<{
28
+ readonly entries: readonly SurfaceMapEntryRecord[];
29
+ readonly generatedAt: string;
30
+ readonly version: '1.0';
31
+ }>;
32
+
33
+ type JsonRecord = Readonly<Record<string, unknown>>;
34
+ type ZodSchemaInput = Parameters<typeof zodToJsonSchema>[0];
35
+
36
+ interface TopoTrailRow {
37
+ readonly description: string | null;
38
+ readonly exampleCount: number;
39
+ readonly hasExamples: number;
40
+ readonly hasOutput: number;
41
+ readonly id: string;
42
+ readonly idempotent: number;
43
+ readonly intent: string;
44
+ readonly meta: string | null;
45
+ readonly pattern: string | null;
46
+ readonly snapshotId: string;
47
+ }
48
+
49
+ interface TopoCrossingRow {
50
+ readonly snapshotId: string;
51
+ readonly sourceId: string;
52
+ readonly targetId: string;
53
+ }
54
+
55
+ interface TopoFiresRow {
56
+ readonly snapshotId: string;
57
+ readonly signalId: string;
58
+ readonly trailId: string;
59
+ }
60
+
61
+ interface TopoOnRow {
62
+ readonly snapshotId: string;
63
+ readonly signalId: string;
64
+ readonly trailId: string;
65
+ }
66
+
67
+ interface TopoTrailResourceRow {
68
+ readonly resourceId: string;
69
+ readonly snapshotId: string;
70
+ readonly trailId: string;
71
+ }
72
+
73
+ interface TopoResourceRow {
74
+ readonly hasHealth: number;
75
+ readonly hasMock: number;
76
+ readonly id: string;
77
+ readonly snapshotId: string;
78
+ }
79
+
80
+ interface TopoSignalRow {
81
+ readonly description: string | null;
82
+ readonly id: string;
83
+ readonly snapshotId: string;
84
+ }
85
+
86
+ interface TopoTrailSignalRow {
87
+ readonly snapshotId: string;
88
+ readonly signalId: string;
89
+ readonly trailId: string;
90
+ }
91
+
92
+ interface TopoSurfaceRow {
93
+ readonly derivedName: string;
94
+ readonly method: string | null;
95
+ readonly snapshotId: string;
96
+ readonly trailId: string;
97
+ readonly surface: string;
98
+ }
99
+
100
+ interface TopoExampleRow {
101
+ readonly description: string | null;
102
+ readonly error: string | null;
103
+ readonly expected: string | null;
104
+ readonly expectedMatch: string | null;
105
+ readonly id: string;
106
+ readonly input: string;
107
+ readonly name: string;
108
+ readonly ordinal: number;
109
+ readonly snapshotId: string;
110
+ readonly trailId: string;
111
+ }
112
+
113
+ interface TopoSchemaRow {
114
+ readonly jsonSchema: string;
115
+ readonly ownerId: string;
116
+ readonly ownerKind: 'signal' | 'trail';
117
+ readonly snapshotId: string;
118
+ readonly schemaKind: 'input' | 'output' | 'payload';
119
+ readonly zodHash: string;
120
+ }
121
+
122
+ interface StoredTopoExportRow {
123
+ readonly snapshotId: string;
124
+ readonly serializedLock: string;
125
+ readonly surfaceHash: string;
126
+ readonly surfaceMap: string;
127
+ }
128
+
129
+ interface StoredTopoExportDbRow {
130
+ readonly serialized_lock: string;
131
+ readonly surface_hash: string;
132
+ readonly surface_map: string;
133
+ }
134
+
135
+ export interface StoredTopoExport {
136
+ readonly lockContent: string;
137
+ readonly surfaceHash: string;
138
+ readonly surfaceMapJson: string;
139
+ }
140
+
141
+ interface MaterializedSchemas {
142
+ readonly rows: readonly TopoSchemaRow[];
143
+ readonly signalPayloads: ReadonlyMap<string, JsonRecord>;
144
+ readonly trailSchemas: ReadonlyMap<
145
+ string,
146
+ Readonly<{
147
+ readonly input: JsonRecord;
148
+ readonly output?: JsonRecord;
149
+ }>
150
+ >;
151
+ }
152
+
153
+ interface MaterializedTopoArtifacts {
154
+ readonly exportRow: StoredTopoExportRow;
155
+ readonly schemaRows: readonly TopoSchemaRow[];
156
+ }
157
+
158
+ interface NormalizedTopoProjection {
159
+ readonly crossings: readonly TopoCrossingRow[];
160
+ readonly examples: readonly TopoExampleRow[];
161
+ readonly fires: readonly TopoFiresRow[];
162
+ readonly on: readonly TopoOnRow[];
163
+ readonly resources: readonly TopoResourceRow[];
164
+ readonly signals: readonly TopoSignalRow[];
165
+ readonly surfaces: readonly TopoSurfaceRow[];
166
+ readonly trailResources: readonly TopoTrailResourceRow[];
167
+ readonly trailSignals: readonly TopoTrailSignalRow[];
168
+ readonly trails: readonly TopoTrailRow[];
169
+ }
170
+
171
+ const canonicalLeaf = (value: unknown): unknown => {
172
+ switch (typeof value) {
173
+ case 'bigint': {
174
+ return value.toString();
175
+ }
176
+ case 'function': {
177
+ return `[Function:${value.name || 'anonymous'}]`;
178
+ }
179
+ case 'symbol': {
180
+ return `[Symbol:${value.description ?? ''}]`;
181
+ }
182
+ case 'undefined': {
183
+ return '[Undefined]';
184
+ }
185
+ default: {
186
+ return value;
187
+ }
188
+ }
189
+ };
190
+
191
+ const canonicalObject = (
192
+ value: Record<string, unknown>,
193
+ visit: (value: unknown) => unknown
194
+ ): Record<string, unknown> => {
195
+ const sorted: Record<string, unknown> = {};
196
+ for (const key of Object.keys(value).toSorted()) {
197
+ const next = value[key];
198
+ sorted[key] = next === undefined ? '[Undefined]' : visit(next);
199
+ }
200
+ return sorted;
201
+ };
202
+
203
+ const canonicalize = (value: unknown): unknown => {
204
+ if (Array.isArray(value)) {
205
+ return value.map(canonicalize);
206
+ }
207
+ if (value instanceof Date) {
208
+ return value.toISOString();
209
+ }
210
+ if (value instanceof RegExp) {
211
+ return value.toString();
212
+ }
213
+ if (value !== null && typeof value === 'object') {
214
+ return canonicalObject(value as Record<string, unknown>, canonicalize);
215
+ }
216
+ return canonicalLeaf(value);
217
+ };
218
+
219
+ /**
220
+ * Canonicalize a value for schema definition hashing.
221
+ *
222
+ * Matches the canonicalization logic used in `@ontrails/schema` so that
223
+ * schema hashes are consistent regardless of which code path computes them.
224
+ * Unlike the general `canonicalize`, this does not convert `Date`, `RegExp`,
225
+ * or `undefined` to sentinel strings — Zod `_zod.def` objects are plain
226
+ * JSON-serializable structures and both paths must agree on the same encoding.
227
+ */
228
+ const schemaCanonical = (value: unknown): unknown => {
229
+ if (Array.isArray(value)) {
230
+ return value.map(schemaCanonical);
231
+ }
232
+ if (value !== null && typeof value === 'object') {
233
+ const sorted: Record<string, unknown> = {};
234
+ for (const key of Object.keys(value).toSorted()) {
235
+ sorted[key] = schemaCanonical((value as Record<string, unknown>)[key]);
236
+ }
237
+ return sorted;
238
+ }
239
+ return value;
240
+ };
241
+
242
+ const stableJson = (value: unknown): string =>
243
+ JSON.stringify(canonicalize(value));
244
+
245
+ const hashText = (text: string): string => {
246
+ const hasher = new Bun.CryptoHasher('sha256');
247
+ hasher.update(text);
248
+ return hasher.digest('hex');
249
+ };
250
+
251
+ const hashValue = (value: unknown): string => hashText(stableJson(value));
252
+
253
+ const parseJsonRecord = (value: string): JsonRecord =>
254
+ JSON.parse(value) as JsonRecord;
255
+
256
+ const schemaDefinitionHash = (schema: unknown): string => {
257
+ const def =
258
+ typeof schema === 'object' &&
259
+ schema !== null &&
260
+ '_zod' in schema &&
261
+ typeof (schema as { readonly _zod: unknown })._zod === 'object' &&
262
+ (schema as { readonly _zod: Record<string, unknown> })._zod !== null &&
263
+ 'def' in (schema as { readonly _zod: Record<string, unknown> })._zod
264
+ ? (schema as { readonly _zod: { readonly def: unknown } })._zod.def
265
+ : schema;
266
+ return hashText(JSON.stringify(schemaCanonical(def)));
267
+ };
268
+
269
+ const sortedJsonSchema = (
270
+ schema: ZodSchemaInput
271
+ ): { readonly json: string; readonly value: JsonRecord } => {
272
+ const json = stableJson(zodToJsonSchema(schema));
273
+ return {
274
+ json,
275
+ value: parseJsonRecord(json),
276
+ };
277
+ };
278
+
279
+ const sortKeys = <T extends Record<string, unknown>>(value: T): T => {
280
+ const sorted: Record<string, unknown> = {};
281
+ for (const key of Object.keys(value).toSorted()) {
282
+ sorted[key] = value[key];
283
+ }
284
+ return sorted as T;
285
+ };
286
+
287
+ const normalizeTrailRows = (
288
+ trails: readonly AnyTrail[],
289
+ snapshotId: string
290
+ ): readonly TopoTrailRow[] =>
291
+ trails.map((trail) => ({
292
+ description: trail.description ?? null,
293
+ exampleCount: trail.examples?.length ?? 0,
294
+ hasExamples: (trail.examples?.length ?? 0) > 0 ? 1 : 0,
295
+ hasOutput: trail.output === undefined ? 0 : 1,
296
+ id: trail.id,
297
+ idempotent: trail.idempotent === true ? 1 : 0,
298
+ intent: trail.intent,
299
+ meta: trail.meta === undefined ? null : stableJson(trail.meta),
300
+ pattern: trail.pattern ?? null,
301
+ snapshotId,
302
+ }));
303
+
304
+ const normalizeCrossingRows = (
305
+ trails: readonly AnyTrail[],
306
+ snapshotId: string
307
+ ): readonly TopoCrossingRow[] =>
308
+ trails.flatMap((trail) =>
309
+ [...new Set(trail.crosses)].toSorted().map((targetId) => ({
310
+ snapshotId,
311
+ sourceId: trail.id,
312
+ targetId,
313
+ }))
314
+ );
315
+
316
+ export const normalizeFiresRows = (
317
+ trails: readonly AnyTrail[],
318
+ snapshotId: string
319
+ ): readonly TopoFiresRow[] =>
320
+ trails.flatMap((trail) =>
321
+ [...new Set(trail.fires)].toSorted().map((signalId) => ({
322
+ signalId,
323
+ snapshotId,
324
+ trailId: trail.id,
325
+ }))
326
+ );
327
+
328
+ export const normalizeOnRows = (
329
+ trails: readonly AnyTrail[],
330
+ snapshotId: string
331
+ ): readonly TopoOnRow[] =>
332
+ trails.flatMap((trail) =>
333
+ [...new Set(trail.on)].toSorted().map((signalId) => ({
334
+ signalId,
335
+ snapshotId,
336
+ trailId: trail.id,
337
+ }))
338
+ );
339
+
340
+ const normalizeTrailResourceRows = (
341
+ trails: readonly AnyTrail[],
342
+ snapshotId: string
343
+ ): readonly TopoTrailResourceRow[] =>
344
+ trails.flatMap((trail) =>
345
+ [...new Set(trail.resources.map((resource) => resource.id))]
346
+ .toSorted()
347
+ .map((resourceId) => ({
348
+ resourceId,
349
+ snapshotId,
350
+ trailId: trail.id,
351
+ }))
352
+ );
353
+
354
+ const normalizeResourceRows = (
355
+ resources: readonly AnyResource[],
356
+ snapshotId: string
357
+ ): readonly TopoResourceRow[] =>
358
+ resources.map((resource) => ({
359
+ hasHealth: resource.health === undefined ? 0 : 1,
360
+ hasMock: resource.mock === undefined ? 0 : 1,
361
+ id: resource.id,
362
+ snapshotId,
363
+ }));
364
+
365
+ const normalizeSignalRows = (
366
+ signals: readonly AnySignal[],
367
+ snapshotId: string
368
+ ): readonly TopoSignalRow[] =>
369
+ signals.map((signal) => ({
370
+ description: signal.description ?? null,
371
+ id: signal.id,
372
+ snapshotId,
373
+ }));
374
+
375
+ const normalizeTrailSignalRows = (
376
+ signals: readonly AnySignal[],
377
+ snapshotId: string
378
+ ): readonly TopoTrailSignalRow[] =>
379
+ signals.flatMap((signal) =>
380
+ [...new Set(signal.from)].toSorted().map((trailId) => ({
381
+ signalId: signal.id,
382
+ snapshotId,
383
+ trailId,
384
+ }))
385
+ );
386
+
387
+ /**
388
+ * Project surface rows for stored topo.
389
+ *
390
+ * Currently records only CLI-derived rows. MCP, HTTP, and other surface
391
+ * projections are intentionally deferred until the topo-store schema supports
392
+ * multi-surface representation. The JSON export (`surface_map` in
393
+ * `topo_exports`) is more faithful for now. See ADR-0015 for the target shape.
394
+ */
395
+ const normalizeSurfaceRows = (
396
+ trails: readonly AnyTrail[],
397
+ snapshotId: string
398
+ ): readonly TopoSurfaceRow[] =>
399
+ trails.map((trail) => ({
400
+ derivedName: deriveCliPath(trail.id).join(' '),
401
+ method: null,
402
+ snapshotId,
403
+ surface: 'cli',
404
+ trailId: trail.id,
405
+ }));
406
+
407
+ const buildExampleId = (
408
+ snapshotId: string,
409
+ trailId: string,
410
+ ordinal: number
411
+ ): string => `${snapshotId}:${trailId}:${ordinal.toString().padStart(4, '0')}`;
412
+
413
+ const normalizeExampleRows = (
414
+ trails: readonly AnyTrail[],
415
+ snapshotId: string
416
+ ): readonly TopoExampleRow[] =>
417
+ trails.flatMap((trail) =>
418
+ (trail.examples ?? []).map((example, index) => ({
419
+ description: example.description ?? null,
420
+ error: example.error ?? null,
421
+ expected:
422
+ example.expected === undefined ? null : stableJson(example.expected),
423
+ expectedMatch:
424
+ example.expectedMatch === undefined
425
+ ? null
426
+ : stableJson(example.expectedMatch),
427
+ id: buildExampleId(snapshotId, trail.id, index),
428
+ input: stableJson(example.input),
429
+ name: example.name,
430
+ ordinal: index,
431
+ snapshotId,
432
+ trailId: trail.id,
433
+ }))
434
+ );
435
+
436
+ const normalizeTopoProjection = (
437
+ topo: Topo,
438
+ snapshotId: string
439
+ ): NormalizedTopoProjection => {
440
+ const trails = topo.list().toSorted((a, b) => a.id.localeCompare(b.id));
441
+ const resources = topo
442
+ .listResources()
443
+ .toSorted((a, b) => a.id.localeCompare(b.id));
444
+ const signals = topo
445
+ .listSignals()
446
+ .toSorted((a, b) => a.id.localeCompare(b.id));
447
+
448
+ return {
449
+ crossings: normalizeCrossingRows(trails, snapshotId),
450
+ examples: normalizeExampleRows(trails, snapshotId),
451
+ fires: normalizeFiresRows(trails, snapshotId),
452
+ on: normalizeOnRows(trails, snapshotId),
453
+ resources: normalizeResourceRows(resources, snapshotId),
454
+ signals: normalizeSignalRows(signals, snapshotId),
455
+ surfaces: normalizeSurfaceRows(trails, snapshotId),
456
+ trailResources: normalizeTrailResourceRows(trails, snapshotId),
457
+ trailSignals: normalizeTrailSignalRows(signals, snapshotId),
458
+ trails: normalizeTrailRows(trails, snapshotId),
459
+ };
460
+ };
461
+
462
+ /**
463
+ * Look up a cached JSON schema by content hash.
464
+ *
465
+ * The query matches on `zod_hash` without filtering by `snapshot_id` because the
466
+ * cache is intentionally cross-snapshot: if the Zod schema definition has not
467
+ * changed (same hash), the serialized JSON Schema is reused regardless of
468
+ * which snapshot produced it. This is safe as long as `zodToJsonSchema` is
469
+ * deterministic for a given `_def` hash — the `schemaDefinitionHash` pipeline
470
+ * guarantees that structurally identical schemas produce the same hash.
471
+ */
472
+ const readCachedJsonSchema = (
473
+ db: Database,
474
+ ownerId: string,
475
+ ownerKind: TopoSchemaRow['ownerKind'],
476
+ schemaKind: TopoSchemaRow['schemaKind'],
477
+ zodHash: string
478
+ ): string | undefined => {
479
+ const row = db
480
+ .query<
481
+ {
482
+ readonly json_schema: string;
483
+ },
484
+ [string, string, string, string]
485
+ >(
486
+ `SELECT json_schema
487
+ FROM topo_schemas
488
+ WHERE owner_id = ?
489
+ AND owner_kind = ?
490
+ AND schema_kind = ?
491
+ AND zod_hash = ?
492
+ LIMIT 1`
493
+ )
494
+ .get(ownerId, ownerKind, schemaKind, zodHash);
495
+
496
+ return row?.json_schema;
497
+ };
498
+
499
+ const resolveSchemaRow = (
500
+ db: Database,
501
+ ownerId: string,
502
+ ownerKind: TopoSchemaRow['ownerKind'],
503
+ snapshotId: string,
504
+ schemaKind: TopoSchemaRow['schemaKind'],
505
+ schema: ZodSchemaInput
506
+ ): {
507
+ readonly row: TopoSchemaRow;
508
+ readonly value: JsonRecord;
509
+ } => {
510
+ const zodHash = schemaDefinitionHash(schema);
511
+ const cachedJson = readCachedJsonSchema(
512
+ db,
513
+ ownerId,
514
+ ownerKind,
515
+ schemaKind,
516
+ zodHash
517
+ );
518
+
519
+ if (cachedJson !== undefined) {
520
+ return {
521
+ row: {
522
+ jsonSchema: cachedJson,
523
+ ownerId,
524
+ ownerKind,
525
+ schemaKind,
526
+ snapshotId,
527
+ zodHash,
528
+ },
529
+ value: parseJsonRecord(cachedJson),
530
+ };
531
+ }
532
+
533
+ const generated = sortedJsonSchema(schema);
534
+ return {
535
+ row: {
536
+ jsonSchema: generated.json,
537
+ ownerId,
538
+ ownerKind,
539
+ schemaKind,
540
+ snapshotId,
541
+ zodHash,
542
+ },
543
+ value: generated.value,
544
+ };
545
+ };
546
+
547
+ const materializeTrailSchema = (
548
+ db: Database,
549
+ snapshotId: string,
550
+ trail: AnyTrail
551
+ ): {
552
+ readonly rows: readonly TopoSchemaRow[];
553
+ readonly value: Readonly<{
554
+ readonly input: JsonRecord;
555
+ readonly output?: JsonRecord;
556
+ }>;
557
+ } => {
558
+ const inputSchema = resolveSchemaRow(
559
+ db,
560
+ trail.id,
561
+ 'trail',
562
+ snapshotId,
563
+ 'input',
564
+ trail.input as ZodSchemaInput
565
+ );
566
+
567
+ if (trail.output === undefined) {
568
+ return {
569
+ rows: [inputSchema.row],
570
+ value: {
571
+ input: inputSchema.value,
572
+ },
573
+ };
574
+ }
575
+
576
+ const outputSchema = resolveSchemaRow(
577
+ db,
578
+ trail.id,
579
+ 'trail',
580
+ snapshotId,
581
+ 'output',
582
+ trail.output as ZodSchemaInput
583
+ );
584
+
585
+ return {
586
+ rows: [inputSchema.row, outputSchema.row],
587
+ value: {
588
+ input: inputSchema.value,
589
+ output: outputSchema.value,
590
+ },
591
+ };
592
+ };
593
+
594
+ const materializeTrailSchemas = (
595
+ db: Database,
596
+ snapshotId: string,
597
+ trails: readonly AnyTrail[]
598
+ ): Pick<MaterializedSchemas, 'rows' | 'trailSchemas'> => {
599
+ const rows: TopoSchemaRow[] = [];
600
+ const trailSchemas = new Map<
601
+ string,
602
+ Readonly<{
603
+ readonly input: JsonRecord;
604
+ readonly output?: JsonRecord;
605
+ }>
606
+ >();
607
+
608
+ for (const trail of trails) {
609
+ const materialized = materializeTrailSchema(db, snapshotId, trail);
610
+ rows.push(...materialized.rows);
611
+ trailSchemas.set(trail.id, materialized.value);
612
+ }
613
+
614
+ return {
615
+ rows,
616
+ trailSchemas,
617
+ };
618
+ };
619
+
620
+ const materializeSignalSchemas = (
621
+ db: Database,
622
+ snapshotId: string,
623
+ signals: readonly AnySignal[]
624
+ ): Pick<MaterializedSchemas, 'rows' | 'signalPayloads'> => {
625
+ const rows: TopoSchemaRow[] = [];
626
+ const signalPayloads = new Map<string, JsonRecord>();
627
+
628
+ for (const signal of signals) {
629
+ const payloadSchema = resolveSchemaRow(
630
+ db,
631
+ signal.id,
632
+ 'signal',
633
+ snapshotId,
634
+ 'payload',
635
+ signal.payload as ZodSchemaInput
636
+ );
637
+ rows.push(payloadSchema.row);
638
+ signalPayloads.set(signal.id, payloadSchema.value);
639
+ }
640
+
641
+ return {
642
+ rows,
643
+ signalPayloads,
644
+ };
645
+ };
646
+
647
+ const materializeSchemas = (
648
+ db: Database,
649
+ snapshotId: string,
650
+ signals: readonly AnySignal[],
651
+ trails: readonly AnyTrail[]
652
+ ): MaterializedSchemas => {
653
+ const trailMaterial = materializeTrailSchemas(db, snapshotId, trails);
654
+ const signalMaterial = materializeSignalSchemas(db, snapshotId, signals);
655
+
656
+ return {
657
+ rows: [...trailMaterial.rows, ...signalMaterial.rows],
658
+ signalPayloads: signalMaterial.signalPayloads,
659
+ trailSchemas: trailMaterial.trailSchemas,
660
+ };
661
+ };
662
+
663
+ const extractTrailheads = (raw: Record<string, unknown>): string[] =>
664
+ Array.isArray(raw['trailheads'])
665
+ ? (raw['trailheads'] as string[]).toSorted()
666
+ : [];
667
+
668
+ const addSafetyMarkers = (
669
+ entry: Record<string, unknown>,
670
+ trail: AnyTrail
671
+ ): void => {
672
+ if (trail.intent !== 'write') {
673
+ entry['intent'] = trail.intent;
674
+ }
675
+
676
+ if (trail.idempotent === true) {
677
+ entry['idempotent'] = true;
678
+ }
679
+ };
680
+
681
+ const addExtendedMetadata = (
682
+ entry: Record<string, unknown>,
683
+ raw: Record<string, unknown>,
684
+ trail: AnyTrail
685
+ ): void => {
686
+ if (raw['deprecated'] === true) {
687
+ entry['deprecated'] = true;
688
+ }
689
+
690
+ if (typeof raw['replacedBy'] === 'string') {
691
+ entry['replacedBy'] = raw['replacedBy'];
692
+ }
693
+
694
+ if (trail.detours.length > 0) {
695
+ entry['detours'] = trail.detours.map((d) => ({
696
+ maxAttempts: Math.max(1, Math.min(d.maxAttempts ?? 1, 5)),
697
+ on: d.on.name,
698
+ }));
699
+ }
700
+ };
701
+
702
+ const addTrailRelations = (
703
+ entry: Record<string, unknown>,
704
+ trail: AnyTrail
705
+ ): void => {
706
+ if (trail.crosses.length > 0) {
707
+ entry['crosses'] = trail.crosses.toSorted();
708
+ }
709
+
710
+ if (trail.contours.length > 0) {
711
+ entry['contours'] = trail.contours
712
+ .map((contour) => contour.name)
713
+ .toSorted();
714
+ }
715
+
716
+ if (trail.resources.length > 0) {
717
+ entry['resources'] = trail.resources
718
+ .map((resource) => resource.id)
719
+ .toSorted();
720
+ }
721
+ };
722
+
723
+ const buildTrailEntryBase = (
724
+ trail: AnyTrail,
725
+ trailSchema: Readonly<{
726
+ readonly input: JsonRecord;
727
+ readonly output?: JsonRecord;
728
+ }>
729
+ ): {
730
+ readonly entry: Record<string, unknown>;
731
+ readonly raw: Record<string, unknown>;
732
+ } => {
733
+ const raw = trail as unknown as Record<string, unknown>;
734
+ const entry: Record<string, unknown> = {
735
+ cli: { path: deriveCliPath(trail.id) },
736
+ exampleCount: trail.examples?.length ?? 0,
737
+ id: trail.id,
738
+ input: trailSchema.input,
739
+ kind: trail.kind,
740
+ trailheads: extractTrailheads(raw),
741
+ };
742
+
743
+ if (trailSchema.output !== undefined) {
744
+ entry['output'] = trailSchema.output;
745
+ }
746
+
747
+ if (trail.description !== undefined) {
748
+ entry['description'] = trail.description;
749
+ }
750
+
751
+ if (trail.pattern !== undefined) {
752
+ entry['pattern'] = trail.pattern;
753
+ }
754
+
755
+ return {
756
+ entry,
757
+ raw,
758
+ };
759
+ };
760
+
761
+ const trailToEntryRecord = (
762
+ trail: AnyTrail,
763
+ trailSchema: Readonly<{
764
+ readonly input: JsonRecord;
765
+ readonly output?: JsonRecord;
766
+ }>
767
+ ): SurfaceMapEntryRecord => {
768
+ const { entry, raw } = buildTrailEntryBase(trail, trailSchema);
769
+ addSafetyMarkers(entry, trail);
770
+ addExtendedMetadata(entry, raw, trail);
771
+ addTrailRelations(entry, trail);
772
+ return sortKeys(entry) as SurfaceMapEntryRecord;
773
+ };
774
+
775
+ const signalToEntryRecord = (
776
+ signal: AnySignal,
777
+ payloadSchema: JsonRecord
778
+ ): SurfaceMapEntryRecord => {
779
+ const raw = signal as unknown as Record<string, unknown>;
780
+ const entry: Record<string, unknown> = {
781
+ exampleCount: 0,
782
+ id: signal.id,
783
+ input: payloadSchema,
784
+ kind: 'signal',
785
+ trailheads: extractTrailheads(raw),
786
+ };
787
+
788
+ if (signal.description !== undefined) {
789
+ entry['description'] = signal.description;
790
+ }
791
+
792
+ if (raw['deprecated'] === true) {
793
+ entry['deprecated'] = true;
794
+ }
795
+
796
+ if (typeof raw['replacedBy'] === 'string') {
797
+ entry['replacedBy'] = raw['replacedBy'];
798
+ }
799
+
800
+ return sortKeys(entry) as SurfaceMapEntryRecord;
801
+ };
802
+
803
+ const resourceToEntryRecord = (
804
+ resource: AnyResource
805
+ ): SurfaceMapEntryRecord => {
806
+ const entry: Record<string, unknown> = {
807
+ exampleCount: 0,
808
+ id: resource.id,
809
+ kind: 'resource',
810
+ trailheads: [],
811
+ };
812
+
813
+ if (resource.description !== undefined) {
814
+ entry['description'] = resource.description;
815
+ }
816
+
817
+ if (resource.health !== undefined) {
818
+ entry['healthcheck'] = true;
819
+ }
820
+
821
+ return sortKeys(entry) as SurfaceMapEntryRecord;
822
+ };
823
+
824
+ const contourToEntryRecord = (contour: AnyContour): SurfaceMapEntryRecord => {
825
+ const schema = sortedJsonSchema(contour);
826
+ const entry: Record<string, unknown> = {
827
+ exampleCount: contour.examples?.length ?? 0,
828
+ id: contour.name,
829
+ identity: contour.identity,
830
+ kind: 'contour',
831
+ schema: schema.value,
832
+ trailheads: [],
833
+ };
834
+
835
+ const references = getContourReferences(contour);
836
+ if (references.length > 0) {
837
+ entry['references'] = references;
838
+ }
839
+
840
+ return sortKeys(entry) as SurfaceMapEntryRecord;
841
+ };
842
+
843
+ const requireTrailSchema = (
844
+ trailSchemas: MaterializedSchemas['trailSchemas'],
845
+ trailId: string
846
+ ): Readonly<{
847
+ readonly input: JsonRecord;
848
+ readonly output?: JsonRecord;
849
+ }> => {
850
+ const schema = trailSchemas.get(trailId);
851
+ if (schema === undefined) {
852
+ throw new Error(`Missing cached trail schema for "${trailId}"`);
853
+ }
854
+ return schema;
855
+ };
856
+
857
+ const requireSignalPayload = (
858
+ signalPayloads: MaterializedSchemas['signalPayloads'],
859
+ signalId: string
860
+ ): JsonRecord => {
861
+ const payload = signalPayloads.get(signalId);
862
+ if (payload === undefined) {
863
+ throw new Error(`Missing cached signal schema for "${signalId}"`);
864
+ }
865
+ return payload;
866
+ };
867
+
868
+ const buildSurfaceMap = (
869
+ contours: readonly AnyContour[],
870
+ generatedAt: string,
871
+ resources: readonly AnyResource[],
872
+ signalPayloads: ReadonlyMap<string, JsonRecord>,
873
+ signals: readonly AnySignal[],
874
+ trailSchemas: ReadonlyMap<
875
+ string,
876
+ Readonly<{
877
+ readonly input: JsonRecord;
878
+ readonly output?: JsonRecord;
879
+ }>
880
+ >,
881
+ trails: readonly AnyTrail[]
882
+ ): SurfaceMapRecord => {
883
+ const entries = [
884
+ ...contours.map((contour) => contourToEntryRecord(contour)),
885
+ ...trails.map((trail) =>
886
+ trailToEntryRecord(trail, requireTrailSchema(trailSchemas, trail.id))
887
+ ),
888
+ ...signals.map((signal) =>
889
+ signalToEntryRecord(
890
+ signal,
891
+ requireSignalPayload(signalPayloads, signal.id)
892
+ )
893
+ ),
894
+ ...resources.map((resource) => resourceToEntryRecord(resource)),
895
+ ].toSorted((a, b) => a.id.localeCompare(b.id));
896
+
897
+ return {
898
+ entries,
899
+ generatedAt,
900
+ version: '1.0',
901
+ };
902
+ };
903
+
904
+ const hashSurfaceMapRecord = (surfaceMap: SurfaceMapRecord): string => {
905
+ const { generatedAt: _unused, ...rest } = surfaceMap;
906
+ return hashValue(rest);
907
+ };
908
+
909
+ const entryPayload = (
910
+ entry: SurfaceMapEntryRecord
911
+ ): Readonly<Record<string, unknown>> => {
912
+ const { id: _unusedId, kind: _unusedKind, ...rest } = entry;
913
+ return sortKeys(rest);
914
+ };
915
+
916
+ const entriesForKind = (
917
+ entries: readonly SurfaceMapEntryRecord[],
918
+ kind: SurfaceMapEntryRecord['kind']
919
+ ): Readonly<Record<string, Readonly<Record<string, unknown>>>> =>
920
+ Object.fromEntries(
921
+ entries
922
+ .filter((entry) => entry.kind === kind)
923
+ .map((entry) => [entry.id, entryPayload(entry)])
924
+ );
925
+
926
+ const buildSerializedLock = (
927
+ hash: string,
928
+ topo: Topo,
929
+ surfaceMap: SurfaceMapRecord
930
+ ): Readonly<Record<string, unknown>> =>
931
+ sortKeys({
932
+ apps: sortKeys({
933
+ [topo.name]: sortKeys({
934
+ contours: entriesForKind(surfaceMap.entries, 'contour'),
935
+ resources: entriesForKind(surfaceMap.entries, 'resource'),
936
+ signals: entriesForKind(surfaceMap.entries, 'signal'),
937
+ trails: entriesForKind(surfaceMap.entries, 'trail'),
938
+ }),
939
+ }),
940
+ generatedAt: surfaceMap.generatedAt,
941
+ hash,
942
+ version: 1,
943
+ });
944
+
945
+ const buildStoredTopoExport = (
946
+ db: Database,
947
+ snapshot: TopoSnapshot,
948
+ topo: Topo
949
+ ): MaterializedTopoArtifacts => {
950
+ const contours = topo
951
+ .listContours()
952
+ .toSorted((a, b) => a.name.localeCompare(b.name));
953
+ const trails = topo.list().toSorted((a, b) => a.id.localeCompare(b.id));
954
+ const resources = topo
955
+ .listResources()
956
+ .toSorted((a, b) => a.id.localeCompare(b.id));
957
+ const signals = topo
958
+ .listSignals()
959
+ .toSorted((a, b) => a.id.localeCompare(b.id));
960
+ const schemas = materializeSchemas(db, snapshot.id, signals, trails);
961
+ const surfaceMap = buildSurfaceMap(
962
+ contours,
963
+ snapshot.createdAt,
964
+ resources,
965
+ schemas.signalPayloads,
966
+ signals,
967
+ schemas.trailSchemas,
968
+ trails
969
+ );
970
+ const surfaceHash = hashSurfaceMapRecord(surfaceMap);
971
+ const serializedLock = `${JSON.stringify(
972
+ buildSerializedLock(surfaceHash, topo, surfaceMap),
973
+ null,
974
+ 2
975
+ )}\n`;
976
+
977
+ return {
978
+ exportRow: {
979
+ serializedLock,
980
+ snapshotId: snapshot.id,
981
+ surfaceHash,
982
+ surfaceMap: `${JSON.stringify(surfaceMap, null, 2)}\n`,
983
+ },
984
+ schemaRows: schemas.rows,
985
+ };
986
+ };
987
+
988
+ const insertRows = <TRow>(
989
+ db: Database,
990
+ rows: readonly TRow[],
991
+ statement: string,
992
+ toParams: (row: TRow) => SQLQueryBindings[]
993
+ ): void => {
994
+ for (const row of rows) {
995
+ db.run(statement, toParams(row));
996
+ }
997
+ };
998
+
999
+ const insertProjectedRows = (
1000
+ db: Database,
1001
+ projection: NormalizedTopoProjection
1002
+ ): void => {
1003
+ insertRows(
1004
+ db,
1005
+ projection.trails,
1006
+ `INSERT INTO topo_trails (
1007
+ id, intent, idempotent, has_output, has_examples, example_count, description, pattern, meta, snapshot_id
1008
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1009
+ (row) => [
1010
+ row.id,
1011
+ row.intent,
1012
+ row.idempotent,
1013
+ row.hasOutput,
1014
+ row.hasExamples,
1015
+ row.exampleCount,
1016
+ row.description,
1017
+ row.pattern,
1018
+ row.meta,
1019
+ row.snapshotId,
1020
+ ]
1021
+ );
1022
+ insertRows(
1023
+ db,
1024
+ projection.crossings,
1025
+ 'INSERT INTO topo_crossings (source_id, target_id, snapshot_id) VALUES (?, ?, ?)',
1026
+ (row) => [row.sourceId, row.targetId, row.snapshotId]
1027
+ );
1028
+ insertRows(
1029
+ db,
1030
+ projection.trailResources,
1031
+ `INSERT INTO topo_trail_resources (trail_id, resource_id, snapshot_id)
1032
+ VALUES (?, ?, ?)`,
1033
+ (row) => [row.trailId, row.resourceId, row.snapshotId]
1034
+ );
1035
+ insertRows(
1036
+ db,
1037
+ projection.resources,
1038
+ `INSERT INTO topo_resources (id, has_mock, has_health, snapshot_id)
1039
+ VALUES (?, ?, ?, ?)`,
1040
+ (row) => [row.id, row.hasMock, row.hasHealth, row.snapshotId]
1041
+ );
1042
+ insertRows(
1043
+ db,
1044
+ projection.signals,
1045
+ 'INSERT INTO topo_signals (id, description, snapshot_id) VALUES (?, ?, ?)',
1046
+ (row) => [row.id, row.description, row.snapshotId]
1047
+ );
1048
+ insertRows(
1049
+ db,
1050
+ projection.trailSignals,
1051
+ `INSERT INTO topo_trail_signals (trail_id, signal_id, snapshot_id)
1052
+ VALUES (?, ?, ?)`,
1053
+ (row) => [row.trailId, row.signalId, row.snapshotId]
1054
+ );
1055
+ insertRows(
1056
+ db,
1057
+ projection.fires,
1058
+ `INSERT INTO topo_trail_fires (trail_id, signal_id, snapshot_id)
1059
+ VALUES (?, ?, ?)`,
1060
+ (row) => [row.trailId, row.signalId, row.snapshotId]
1061
+ );
1062
+ insertRows(
1063
+ db,
1064
+ projection.on,
1065
+ `INSERT INTO topo_trail_on (trail_id, signal_id, snapshot_id)
1066
+ VALUES (?, ?, ?)`,
1067
+ (row) => [row.trailId, row.signalId, row.snapshotId]
1068
+ );
1069
+ insertRows(
1070
+ db,
1071
+ projection.surfaces,
1072
+ `INSERT INTO topo_surfaces (trail_id, surface, derived_name, method, snapshot_id)
1073
+ VALUES (?, ?, ?, ?, ?)`,
1074
+ (row) => [
1075
+ row.trailId,
1076
+ row.surface,
1077
+ row.derivedName,
1078
+ row.method,
1079
+ row.snapshotId,
1080
+ ]
1081
+ );
1082
+ insertRows(
1083
+ db,
1084
+ projection.examples,
1085
+ `INSERT INTO topo_examples (
1086
+ id, trail_id, ordinal, name, description, input, expected, error, snapshot_id
1087
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1088
+ (row) => [
1089
+ row.id,
1090
+ row.trailId,
1091
+ row.ordinal,
1092
+ row.name,
1093
+ row.description,
1094
+ row.input,
1095
+ row.expected,
1096
+ row.error,
1097
+ row.snapshotId,
1098
+ ]
1099
+ );
1100
+ };
1101
+
1102
+ const insertSchemaRows = (
1103
+ db: Database,
1104
+ rows: readonly TopoSchemaRow[]
1105
+ ): void => {
1106
+ insertRows(
1107
+ db,
1108
+ rows,
1109
+ `INSERT INTO topo_schemas (
1110
+ owner_id, owner_kind, schema_kind, zod_hash, json_schema, snapshot_id
1111
+ ) VALUES (?, ?, ?, ?, ?, ?)`,
1112
+ (row) => [
1113
+ row.ownerId,
1114
+ row.ownerKind,
1115
+ row.schemaKind,
1116
+ row.zodHash,
1117
+ row.jsonSchema,
1118
+ row.snapshotId,
1119
+ ]
1120
+ );
1121
+ };
1122
+
1123
+ const insertStoredExport = (
1124
+ db: Database,
1125
+ exportRow: StoredTopoExportRow
1126
+ ): void => {
1127
+ db.run(
1128
+ `INSERT INTO topo_exports (
1129
+ snapshot_id, surface_map, surface_hash, serialized_lock
1130
+ ) VALUES (?, ?, ?, ?)`,
1131
+ [
1132
+ exportRow.snapshotId,
1133
+ exportRow.surfaceMap,
1134
+ exportRow.surfaceHash,
1135
+ exportRow.serializedLock,
1136
+ ]
1137
+ );
1138
+ };
1139
+
1140
+ export const getStoredTopoExport = (
1141
+ db: Database,
1142
+ snapshotId: string
1143
+ ): StoredTopoExport | undefined => {
1144
+ const row = db
1145
+ .query<StoredTopoExportDbRow, [string]>(
1146
+ `SELECT surface_map, surface_hash, serialized_lock
1147
+ FROM topo_exports
1148
+ WHERE snapshot_id = ?`
1149
+ )
1150
+ .get(snapshotId);
1151
+
1152
+ if (row === undefined || row === null) {
1153
+ return undefined;
1154
+ }
1155
+
1156
+ return {
1157
+ lockContent: row.serialized_lock,
1158
+ surfaceHash: row.surface_hash,
1159
+ surfaceMapJson: row.surface_map,
1160
+ };
1161
+ };
1162
+
1163
+ export const createTopoSnapshot = (
1164
+ db: Database,
1165
+ topo: Topo,
1166
+ input?: CreateTopoSnapshotInput
1167
+ ): Result<TopoSnapshot, Error> => {
1168
+ const validated = validateEstablishedTopo(topo);
1169
+ if (validated.isErr()) {
1170
+ return Result.err(validated.error);
1171
+ }
1172
+
1173
+ ensureTopoSnapshotSchema(db);
1174
+
1175
+ const snapshotInput: CreateTopoSnapshotInput = {
1176
+ ...input,
1177
+ resourceCount: input?.resourceCount ?? topo.resources.size,
1178
+ signalCount: input?.signalCount ?? topo.signals.size,
1179
+ trailCount: input?.trailCount ?? topo.trails.size,
1180
+ };
1181
+
1182
+ const snapshot = db.transaction(() => {
1183
+ const record = insertTopoSnapshotRecord(db, snapshotInput);
1184
+ const artifacts = buildStoredTopoExport(db, record, topo);
1185
+ insertProjectedRows(db, normalizeTopoProjection(topo, record.id));
1186
+ insertSchemaRows(db, artifacts.schemaRows);
1187
+ insertStoredExport(db, artifacts.exportRow);
1188
+ return record;
1189
+ })();
1190
+
1191
+ return Result.ok(snapshot);
1192
+ };