@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,869 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { z } from 'zod';
7
+
8
+ import { NotFoundError } from '../errors.js';
9
+ import { contour, Result, resource, signal, topo, trail } from '../index.js';
10
+ import { __topoStoreMigrationStats, createTopoStore } from '../topo-store.js';
11
+ import {
12
+ ensureTopoSnapshotSchema,
13
+ pinTopoSnapshot,
14
+ pruneUnpinnedSnapshots,
15
+ } from '../internal/topo-snapshots.js';
16
+ import {
17
+ createTopoSnapshot,
18
+ getStoredTopoExport,
19
+ normalizeFiresRows,
20
+ normalizeOnRows,
21
+ } from '../internal/topo-store.js';
22
+ import { openWriteTrailsDb } from '../internal/trails-db.js';
23
+
24
+ const noop = () => Result.ok({ ok: true });
25
+
26
+ /** Unwrap a Result in tests, throwing on Err. */
27
+ const unwrap = <T>(result: Result<T, Error>): T => {
28
+ if (result.isErr()) {
29
+ throw result.error;
30
+ }
31
+ return result.value;
32
+ };
33
+
34
+ const countRows = (
35
+ db: ReturnType<typeof openWriteTrailsDb>,
36
+ tableName: string,
37
+ snapshotId?: string
38
+ ): number => {
39
+ const row =
40
+ snapshotId === undefined
41
+ ? db
42
+ .query<{ count: number }, []>(
43
+ `SELECT COUNT(*) as count FROM ${tableName}`
44
+ )
45
+ .get()
46
+ : db
47
+ .query<{ count: number }, [string]>(
48
+ `SELECT COUNT(*) as count FROM ${tableName} WHERE snapshot_id = ?`
49
+ )
50
+ .get(snapshotId);
51
+ return row?.count ?? 0;
52
+ };
53
+
54
+ const tableExists = (
55
+ db: ReturnType<typeof openWriteTrailsDb>,
56
+ tableName: string
57
+ ): boolean => {
58
+ const row = db
59
+ .query<{ name: string }, [string]>(
60
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
61
+ )
62
+ .get(tableName);
63
+ return row?.name === tableName;
64
+ };
65
+
66
+ const exampleApp = () => {
67
+ const entityContour = contour(
68
+ 'entity',
69
+ {
70
+ id: z.string(),
71
+ name: z.string(),
72
+ },
73
+ { identity: 'id' }
74
+ );
75
+
76
+ const dbMain = resource('db.main', {
77
+ create: () => Result.ok({ source: 'factory' }),
78
+ description: 'Primary database',
79
+ health: () => Result.ok({ ok: true }),
80
+ mock: () => ({ source: 'mock' }),
81
+ });
82
+
83
+ const searchIndex = resource('search.index', {
84
+ create: () => Result.ok({ source: 'factory' }),
85
+ mock: () => ({ source: 'mock' }),
86
+ });
87
+
88
+ const entityAdded = signal('entity.added', {
89
+ description: 'An entity was added',
90
+ from: ['entity.add'],
91
+ payload: z.object({ id: z.string() }),
92
+ });
93
+
94
+ const entityAdd = trail('entity.add', {
95
+ blaze: (input: { readonly name: string }) =>
96
+ Result.ok({ id: input.name.toLowerCase(), ok: true }),
97
+ contours: [entityContour],
98
+ description: 'Add a new entity',
99
+ examples: [
100
+ {
101
+ expected: { id: 'ada', ok: true },
102
+ input: { name: 'Ada' },
103
+ name: 'Add Ada',
104
+ },
105
+ {
106
+ error: 'ConflictError',
107
+ input: { name: 'Existing' },
108
+ name: 'Conflict on duplicate',
109
+ },
110
+ ],
111
+ input: z.object({ name: z.string() }),
112
+ meta: { owner: 'core', tags: ['write', 'entity'] },
113
+ output: z.object({ id: z.string(), ok: z.boolean() }),
114
+ resources: [dbMain, searchIndex],
115
+ });
116
+
117
+ const entityList = trail('entity.list', {
118
+ blaze: () => Result.ok({ items: ['ada'] }),
119
+ contours: [entityContour],
120
+ crosses: ['entity.add'],
121
+ description: 'List entities',
122
+ idempotent: true,
123
+ input: z.object({}),
124
+ intent: 'read',
125
+ output: z.object({ items: z.array(z.string()) }),
126
+ resources: [dbMain],
127
+ });
128
+
129
+ return topo('projection-app', {
130
+ dbMain,
131
+ entityAdd,
132
+ entityAdded,
133
+ entityContour,
134
+ entityList,
135
+ searchIndex,
136
+ });
137
+ };
138
+
139
+ const readTrailRows = (
140
+ db: ReturnType<typeof openWriteTrailsDb>,
141
+ snapshotId: string
142
+ ) =>
143
+ db
144
+ .query<
145
+ {
146
+ description: string | null;
147
+ example_count: number;
148
+ has_output: number;
149
+ id: string;
150
+ idempotent: number;
151
+ intent: string;
152
+ meta: string | null;
153
+ },
154
+ [string]
155
+ >(
156
+ `SELECT id, intent, idempotent, has_output, example_count, description, meta
157
+ FROM topo_trails
158
+ WHERE snapshot_id = ?
159
+ ORDER BY id ASC`
160
+ )
161
+ .all(snapshotId);
162
+
163
+ const readTrailSignalRows = (
164
+ db: ReturnType<typeof openWriteTrailsDb>,
165
+ snapshotId: string
166
+ ) =>
167
+ db
168
+ .query<{ signal_id: string; trail_id: string }, [string]>(
169
+ `SELECT trail_id, signal_id
170
+ FROM topo_trail_signals
171
+ WHERE snapshot_id = ?`
172
+ )
173
+ .all(snapshotId);
174
+
175
+ const readSurfaceRows = (
176
+ db: ReturnType<typeof openWriteTrailsDb>,
177
+ snapshotId: string
178
+ ) =>
179
+ db
180
+ .query<{ derived_name: string; trail_id: string }, [string]>(
181
+ `SELECT trail_id, derived_name
182
+ FROM topo_surfaces
183
+ WHERE snapshot_id = ?
184
+ ORDER BY trail_id ASC`
185
+ )
186
+ .all(snapshotId);
187
+
188
+ const readExampleRows = (
189
+ db: ReturnType<typeof openWriteTrailsDb>,
190
+ snapshotId: string
191
+ ) =>
192
+ db
193
+ .query<
194
+ {
195
+ error: string | null;
196
+ expected: string | null;
197
+ input: string;
198
+ name: string;
199
+ ordinal: number;
200
+ },
201
+ [string]
202
+ >(
203
+ `SELECT ordinal, name, input, expected, error
204
+ FROM topo_examples
205
+ WHERE snapshot_id = ?
206
+ ORDER BY ordinal ASC`
207
+ )
208
+ .all(snapshotId);
209
+
210
+ const readProjectedTrailIds = (
211
+ db: ReturnType<typeof openWriteTrailsDb>,
212
+ snapshotId: string
213
+ ) =>
214
+ db
215
+ .query<{ id: string }, [string]>(
216
+ 'SELECT id FROM topo_trails WHERE snapshot_id = ? ORDER BY id ASC'
217
+ )
218
+ .all(snapshotId)
219
+ .map((row) => row.id);
220
+
221
+ const requireStoredExport = (
222
+ db: ReturnType<typeof openWriteTrailsDb>,
223
+ snapshotId: string
224
+ ) => {
225
+ const stored = getStoredTopoExport(db, snapshotId);
226
+ if (stored === undefined) {
227
+ throw new Error(`Expected stored topo export for snapshot "${snapshotId}"`);
228
+ }
229
+ return stored;
230
+ };
231
+
232
+ const hasContourTrailheadEntry = (entry: unknown, id: string): boolean => {
233
+ if (typeof entry !== 'object' || entry === null) {
234
+ return false;
235
+ }
236
+ const candidate = entry as { id?: string; kind?: string };
237
+ return candidate.id === id && candidate.kind === 'contour';
238
+ };
239
+
240
+ const expectProjectionCounts = (
241
+ db: ReturnType<typeof openWriteTrailsDb>,
242
+ snapshotId: string
243
+ ): void => {
244
+ expect(countRows(db, 'topo_trails', snapshotId)).toBe(2);
245
+ expect(countRows(db, 'topo_crossings', snapshotId)).toBe(1);
246
+ expect(countRows(db, 'topo_trail_resources', snapshotId)).toBe(3);
247
+ expect(countRows(db, 'topo_resources', snapshotId)).toBe(2);
248
+ expect(countRows(db, 'topo_signals', snapshotId)).toBe(1);
249
+ expect(countRows(db, 'topo_trail_signals', snapshotId)).toBe(1);
250
+ expect(countRows(db, 'topo_surfaces', snapshotId)).toBe(2);
251
+ expect(countRows(db, 'topo_examples', snapshotId)).toBe(2);
252
+ expect(countRows(db, 'topo_schemas', snapshotId)).toBe(5);
253
+ expect(countRows(db, 'topo_exports')).toBeGreaterThanOrEqual(1);
254
+ };
255
+
256
+ const expectProjectedFixtureRows = (
257
+ db: ReturnType<typeof openWriteTrailsDb>,
258
+ snapshotId: string
259
+ ): void => {
260
+ expect(readTrailRows(db, snapshotId)).toEqual([
261
+ {
262
+ description: 'Add a new entity',
263
+ example_count: 2,
264
+ has_output: 1,
265
+ id: 'entity.add',
266
+ idempotent: 0,
267
+ intent: 'write',
268
+ meta: JSON.stringify({
269
+ owner: 'core',
270
+ tags: ['write', 'entity'],
271
+ }),
272
+ },
273
+ {
274
+ description: 'List entities',
275
+ example_count: 0,
276
+ has_output: 1,
277
+ id: 'entity.list',
278
+ idempotent: 1,
279
+ intent: 'read',
280
+ meta: null,
281
+ },
282
+ ]);
283
+ expect(readTrailSignalRows(db, snapshotId)).toEqual([
284
+ { signal_id: 'entity.added', trail_id: 'entity.add' },
285
+ ]);
286
+ expect(readSurfaceRows(db, snapshotId)).toEqual([
287
+ { derived_name: 'entity add', trail_id: 'entity.add' },
288
+ { derived_name: 'entity list', trail_id: 'entity.list' },
289
+ ]);
290
+ expect(readExampleRows(db, snapshotId)).toEqual([
291
+ {
292
+ error: null,
293
+ expected: JSON.stringify({ id: 'ada', ok: true }),
294
+ input: JSON.stringify({ name: 'Ada' }),
295
+ name: 'Add Ada',
296
+ ordinal: 0,
297
+ },
298
+ {
299
+ error: 'ConflictError',
300
+ expected: null,
301
+ input: JSON.stringify({ name: 'Existing' }),
302
+ name: 'Conflict on duplicate',
303
+ ordinal: 1,
304
+ },
305
+ ]);
306
+ };
307
+
308
+ const simpleProjectionApp = (withList: boolean) =>
309
+ topo('projection-app', {
310
+ entityAdd: trail('entity.add', {
311
+ blaze: noop,
312
+ input: z.object({}),
313
+ output: z.object({ ok: z.boolean() }),
314
+ }),
315
+ ...(withList
316
+ ? {
317
+ entityList: trail('entity.list', {
318
+ blaze: () => Result.ok({ items: ['one'] }),
319
+ input: z.object({}),
320
+ intent: 'read',
321
+ output: z.object({ items: z.array(z.string()) }),
322
+ }),
323
+ }
324
+ : {}),
325
+ });
326
+
327
+ const expectDisposableSaveCascaded = (
328
+ db: ReturnType<typeof openWriteTrailsDb>,
329
+ disposableId: string
330
+ ): void => {
331
+ expect(countRows(db, 'topo_trails', disposableId)).toBe(0);
332
+ expect(countRows(db, 'topo_crossings', disposableId)).toBe(0);
333
+ expect(countRows(db, 'topo_examples', disposableId)).toBe(0);
334
+ expect(countRows(db, 'topo_schemas', disposableId)).toBe(0);
335
+ expect(countRows(db, 'topo_trail_fires', disposableId)).toBe(0);
336
+ expect(countRows(db, 'topo_trail_on', disposableId)).toBe(0);
337
+ };
338
+
339
+ const expectSignalEdgeCounts = (
340
+ db: ReturnType<typeof openWriteTrailsDb>,
341
+ snapshotId: string,
342
+ count: number
343
+ ): void => {
344
+ expect(countRows(db, 'topo_trail_fires', snapshotId)).toBe(count);
345
+ expect(countRows(db, 'topo_trail_on', snapshotId)).toBe(count);
346
+ };
347
+
348
+ const buildSignalPruneApp = () => {
349
+ const created = signal('entity.created', {
350
+ from: ['entity.create'],
351
+ payload: z.object({ id: z.string() }),
352
+ });
353
+ const createTrail = trail('entity.create', {
354
+ blaze: () => Result.ok({ id: 'x' }),
355
+ fires: ['entity.created'],
356
+ input: z.object({}),
357
+ output: z.object({ id: z.string() }),
358
+ });
359
+ const indexTrail = trail('entity.index', {
360
+ blaze: () => Result.ok({ ok: true }),
361
+ input: z.object({}),
362
+ on: ['entity.created'],
363
+ output: z.object({ ok: z.boolean() }),
364
+ });
365
+ return topo('prune-signal-app', {
366
+ createTrail,
367
+ created,
368
+ indexTrail,
369
+ });
370
+ };
371
+
372
+ const captureReadError = (run: () => unknown): unknown => {
373
+ try {
374
+ run();
375
+ return undefined;
376
+ } catch (error) {
377
+ return error;
378
+ }
379
+ };
380
+
381
+ const withWriteDb = (
382
+ rootDir: string,
383
+ run: (db: ReturnType<typeof openWriteTrailsDb>) => void
384
+ ): void => {
385
+ const db = openWriteTrailsDb({ rootDir });
386
+ try {
387
+ run(db);
388
+ } finally {
389
+ db.close();
390
+ }
391
+ };
392
+
393
+ const seedCurrentSchemaStore = (rootDir: string): void => {
394
+ const db = openWriteTrailsDb({ rootDir });
395
+ try {
396
+ ensureTopoSnapshotSchema(db);
397
+ } finally {
398
+ db.close();
399
+ }
400
+ };
401
+
402
+ const assertNoWriteEscalationOnReads = (rootDir: string): void => {
403
+ const baselineEscalations = __topoStoreMigrationStats.writeEscalations;
404
+ const baselinePeeks = __topoStoreMigrationStats.peekCalls;
405
+
406
+ // First read: peek must happen, no write-mode escalation.
407
+ const caught = captureReadError(() =>
408
+ createTopoStore({ rootDir }).snapshots.latest()
409
+ );
410
+ expect(caught).toBeInstanceOf(NotFoundError);
411
+ expect(__topoStoreMigrationStats.peekCalls).toBe(baselinePeeks + 1);
412
+ expect(__topoStoreMigrationStats.writeEscalations).toBe(baselineEscalations);
413
+
414
+ // Second read is served entirely from the memoized identity.
415
+ captureReadError(() => createTopoStore({ rootDir }).snapshots.latest());
416
+ expect(__topoStoreMigrationStats.peekCalls).toBe(baselinePeeks + 1);
417
+ expect(__topoStoreMigrationStats.writeEscalations).toBe(baselineEscalations);
418
+ };
419
+
420
+ const seedHistoryOnlyTopoSchema = (
421
+ db: ReturnType<typeof openWriteTrailsDb>
422
+ ): void => {
423
+ db.run(
424
+ `INSERT INTO meta_schema_versions (subsystem, version, updated_at)
425
+ VALUES ('topo', 1, ?)`,
426
+ ['2026-04-03T11:00:00.000Z']
427
+ );
428
+ db.run(`CREATE TABLE IF NOT EXISTS topo_saves (
429
+ id TEXT PRIMARY KEY,
430
+ git_sha TEXT,
431
+ git_dirty INTEGER NOT NULL DEFAULT 0,
432
+ trail_count INTEGER NOT NULL DEFAULT 0,
433
+ signal_count INTEGER NOT NULL DEFAULT 0,
434
+ resource_count INTEGER NOT NULL DEFAULT 0,
435
+ created_at TEXT NOT NULL
436
+ )`);
437
+ db.run(`CREATE TABLE IF NOT EXISTS topo_pins (
438
+ name TEXT PRIMARY KEY,
439
+ save_id TEXT NOT NULL UNIQUE,
440
+ created_at TEXT NOT NULL
441
+ )`);
442
+ db.run(
443
+ `INSERT INTO topo_saves (
444
+ id, git_sha, git_dirty, trail_count, signal_count, resource_count, created_at
445
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
446
+ ['seed-save', 'seed123', 0, 1, 0, 0, '2026-04-03T11:00:00.000Z']
447
+ );
448
+ db.run('INSERT INTO topo_pins (name, save_id, created_at) VALUES (?, ?, ?)', [
449
+ 'seed-pin',
450
+ 'seed-save',
451
+ '2026-04-03T11:01:00.000Z',
452
+ ]);
453
+ };
454
+
455
+ /**
456
+ * Seed a pre-v7 projection store that still uses the `topo_trailheads`
457
+ * table and the `save_id` foreign-key column. The fresh migration should
458
+ * ignore this legacy state and create the snapshot-first schema alongside it.
459
+ */
460
+ const seedLegacyProjectionStore = (
461
+ db: ReturnType<typeof openWriteTrailsDb>
462
+ ): void => {
463
+ db.run(
464
+ `INSERT INTO meta_schema_versions (subsystem, version, updated_at)
465
+ VALUES ('topo', 6, ?)`,
466
+ ['2026-04-03T11:00:00.000Z']
467
+ );
468
+ db.run(`CREATE TABLE IF NOT EXISTS topo_trailheads (
469
+ trail_id TEXT NOT NULL,
470
+ trailhead TEXT NOT NULL,
471
+ derived_name TEXT NOT NULL,
472
+ method TEXT,
473
+ save_id TEXT NOT NULL,
474
+ PRIMARY KEY (trail_id, trailhead, save_id)
475
+ )`);
476
+ db.run(
477
+ `INSERT INTO topo_trailheads (trail_id, trailhead, derived_name, save_id)
478
+ VALUES (?, ?, ?, ?)`,
479
+ ['entity.add', 'cli', 'entity add', 'legacy-save']
480
+ );
481
+ };
482
+
483
+ const replaceStoreWithHistoryOnlyStore = async (
484
+ rootDir: string
485
+ ): Promise<void> => {
486
+ // Ensure mtime/size differs so the cached identity is invalidated even on
487
+ // filesystems with coarse mtime granularity.
488
+ const dbPath = join(rootDir, '.trails', 'trails.db');
489
+ rmSync(dbPath, { force: true });
490
+ rmSync(`${dbPath}-shm`, { force: true });
491
+ rmSync(`${dbPath}-wal`, { force: true });
492
+ await Bun.sleep(10);
493
+ const db = openWriteTrailsDb({ rootDir });
494
+ try {
495
+ seedHistoryOnlyTopoSchema(db);
496
+ } finally {
497
+ db.close();
498
+ }
499
+ };
500
+
501
+ describe('topo store projection', () => {
502
+ let tmpRoot: string | undefined;
503
+
504
+ afterEach(() => {
505
+ if (tmpRoot) {
506
+ rmSync(tmpRoot, { force: true, recursive: true });
507
+ tmpRoot = undefined;
508
+ }
509
+ });
510
+
511
+ const makeRoot = (): string => {
512
+ tmpRoot = mkdtempSync(join(tmpdir(), 'topo-store-'));
513
+ return tmpRoot;
514
+ };
515
+
516
+ const withProjectionDb = (
517
+ run: (db: ReturnType<typeof openWriteTrailsDb>) => void
518
+ ): void => {
519
+ const db = openWriteTrailsDb({ rootDir: makeRoot() });
520
+ try {
521
+ run(db);
522
+ } finally {
523
+ db.close();
524
+ }
525
+ };
526
+
527
+ test('projects a snapshot-scoped relational topo from the established app graph', () => {
528
+ withProjectionDb((db) => {
529
+ const snapshot = unwrap(
530
+ createTopoSnapshot(db, exampleApp(), {
531
+ createdAt: '2026-04-03T12:00:00.000Z',
532
+ gitDirty: false,
533
+ gitSha: 'abc123',
534
+ })
535
+ );
536
+ expectProjectionCounts(db, snapshot.id);
537
+ expectProjectedFixtureRows(db, snapshot.id);
538
+
539
+ const stored = requireStoredExport(db, snapshot.id);
540
+ const surfaceMap = JSON.parse(stored.surfaceMapJson);
541
+ const { entries } = surfaceMap as { entries?: unknown[] };
542
+ expect(surfaceMap).toMatchObject({
543
+ entries: expect.any(Array),
544
+ generatedAt: '2026-04-03T12:00:00.000Z',
545
+ version: '1.0',
546
+ });
547
+ expect(Array.isArray(entries)).toBe(true);
548
+ expect(
549
+ entries?.some((entry) => hasContourTrailheadEntry(entry, 'entity'))
550
+ ).toBe(true);
551
+
552
+ expect(JSON.parse(stored.lockContent)).toMatchObject({
553
+ apps: {
554
+ 'projection-app': {
555
+ contours: {
556
+ entity: expect.objectContaining({
557
+ identity: 'id',
558
+ }),
559
+ },
560
+ resources: expect.any(Object),
561
+ signals: expect.any(Object),
562
+ trails: expect.any(Object),
563
+ },
564
+ },
565
+ generatedAt: '2026-04-03T12:00:00.000Z',
566
+ hash: stored.surfaceHash,
567
+ version: 1,
568
+ });
569
+ });
570
+ });
571
+
572
+ test('keeps projected rows isolated across successive snapshots', () => {
573
+ withProjectionDb((db) => {
574
+ const firstSnapshot = unwrap(
575
+ createTopoSnapshot(db, simpleProjectionApp(false), {
576
+ createdAt: '2026-04-03T12:00:00.000Z',
577
+ })
578
+ );
579
+ const secondSnapshot = unwrap(
580
+ createTopoSnapshot(db, simpleProjectionApp(true), {
581
+ createdAt: '2026-04-03T12:05:00.000Z',
582
+ })
583
+ );
584
+
585
+ expect(firstSnapshot.id).not.toBe(secondSnapshot.id);
586
+ expect(countRows(db, 'topo_trails', firstSnapshot.id)).toBe(1);
587
+ expect(countRows(db, 'topo_trails', secondSnapshot.id)).toBe(2);
588
+ expect(readProjectedTrailIds(db, firstSnapshot.id)).toEqual([
589
+ 'entity.add',
590
+ ]);
591
+ expect(readProjectedTrailIds(db, secondSnapshot.id)).toEqual([
592
+ 'entity.add',
593
+ 'entity.list',
594
+ ]);
595
+ expect(requireStoredExport(db, firstSnapshot.id).surfaceHash).not.toBe(
596
+ requireStoredExport(db, secondSnapshot.id).surfaceHash
597
+ );
598
+ });
599
+ });
600
+
601
+ test("pruning an unpinned snapshot removes only that snapshot's projected rows", () => {
602
+ withProjectionDb((db) => {
603
+ const pinned = unwrap(
604
+ createTopoSnapshot(db, buildSignalPruneApp(), {
605
+ createdAt: '2026-04-03T12:00:00.000Z',
606
+ })
607
+ );
608
+ pinTopoSnapshot(db, { id: pinned.id, name: 'before-auth' });
609
+
610
+ const disposable = unwrap(
611
+ createTopoSnapshot(db, buildSignalPruneApp(), {
612
+ createdAt: '2026-04-03T12:05:00.000Z',
613
+ })
614
+ );
615
+
616
+ // Pre-prune: both snapshots have fires/on rows so the cascade is non-vacuous.
617
+ expectSignalEdgeCounts(db, pinned.id, 1);
618
+ expectSignalEdgeCounts(db, disposable.id, 1);
619
+
620
+ expect(pruneUnpinnedSnapshots(db, { keep: 0 })).toBe(1);
621
+
622
+ // Pinned snapshot retains its projected rows, including fires/on edges.
623
+ expect(countRows(db, 'topo_trails', pinned.id)).toBe(2);
624
+ expectSignalEdgeCounts(db, pinned.id, 1);
625
+
626
+ expectDisposableSaveCascaded(db, disposable.id);
627
+ });
628
+ });
629
+
630
+ test('persists fires and on edges for signal-declaring trails', () => {
631
+ withProjectionDb((db) => {
632
+ const created = signal('entity.created', {
633
+ from: ['entity.create'],
634
+ payload: z.object({ id: z.string() }),
635
+ });
636
+ const updated = signal('entity.updated', {
637
+ from: ['entity.create'],
638
+ payload: z.object({ id: z.string() }),
639
+ });
640
+
641
+ const createTrail = trail('entity.create', {
642
+ blaze: () => Result.ok({ id: 'x' }),
643
+ fires: ['entity.created', 'entity.updated'],
644
+ input: z.object({}),
645
+ output: z.object({ id: z.string() }),
646
+ });
647
+ const indexTrail = trail('entity.index', {
648
+ blaze: () => Result.ok({ ok: true }),
649
+ input: z.object({}),
650
+ on: ['entity.created'],
651
+ output: z.object({ ok: z.boolean() }),
652
+ });
653
+ const auditTrail = trail('entity.audit', {
654
+ blaze: () => Result.ok({ ok: true }),
655
+ input: z.object({}),
656
+ on: ['entity.created', 'entity.updated'],
657
+ output: z.object({ ok: z.boolean() }),
658
+ });
659
+
660
+ const snapshot = unwrap(
661
+ createTopoSnapshot(
662
+ db,
663
+ topo('signal-edges-app', {
664
+ auditTrail,
665
+ createTrail,
666
+ created,
667
+ indexTrail,
668
+ updated,
669
+ })
670
+ )
671
+ );
672
+
673
+ const fires = db
674
+ .query<{ signal_id: string; trail_id: string }, [string]>(
675
+ `SELECT trail_id, signal_id
676
+ FROM topo_trail_fires
677
+ WHERE snapshot_id = ?
678
+ ORDER BY trail_id ASC, signal_id ASC`
679
+ )
680
+ .all(snapshot.id);
681
+ expect(fires).toEqual([
682
+ { signal_id: 'entity.created', trail_id: 'entity.create' },
683
+ { signal_id: 'entity.updated', trail_id: 'entity.create' },
684
+ ]);
685
+
686
+ const on = db
687
+ .query<{ signal_id: string; trail_id: string }, [string]>(
688
+ `SELECT trail_id, signal_id
689
+ FROM topo_trail_on
690
+ WHERE snapshot_id = ?
691
+ ORDER BY trail_id ASC, signal_id ASC`
692
+ )
693
+ .all(snapshot.id);
694
+ expect(on).toEqual([
695
+ { signal_id: 'entity.created', trail_id: 'entity.audit' },
696
+ { signal_id: 'entity.updated', trail_id: 'entity.audit' },
697
+ { signal_id: 'entity.created', trail_id: 'entity.index' },
698
+ ]);
699
+ });
700
+ });
701
+
702
+ describe('snapshot schema migration guardrails', () => {
703
+ test('createTopoStore skips write-mode escalation when schema is current', () => {
704
+ const rootDir = makeRoot();
705
+ seedCurrentSchemaStore(rootDir);
706
+ assertNoWriteEscalationOnReads(rootDir);
707
+ });
708
+
709
+ test('createTopoStore ignores a legacy history-only store during cutover', () => {
710
+ const rootDir = makeRoot();
711
+ withWriteDb(rootDir, (db) => {
712
+ seedHistoryOnlyTopoSchema(db);
713
+ });
714
+
715
+ const caught = captureReadError(() =>
716
+ createTopoStore({ rootDir }).snapshots.latest()
717
+ );
718
+ expect(caught).toBeInstanceOf(NotFoundError);
719
+
720
+ withWriteDb(rootDir, (db) => {
721
+ expect(tableExists(db, 'topo_snapshots')).toBe(true);
722
+ expect(tableExists(db, 'topo_trails')).toBe(true);
723
+ expect(tableExists(db, 'topo_resources')).toBe(true);
724
+ expect(tableExists(db, 'topo_surfaces')).toBe(true);
725
+ expect(tableExists(db, 'topo_saves')).toBe(true);
726
+ expect(tableExists(db, 'topo_pins')).toBe(true);
727
+ expect(tableExists(db, 'topo_trailheads')).toBe(false);
728
+ expect(
729
+ db
730
+ .query<{ version: number }, []>(
731
+ "SELECT version FROM meta_schema_versions WHERE subsystem = 'topo'"
732
+ )
733
+ .get()?.version
734
+ ).toBe(8);
735
+ expect(countRows(db, 'topo_snapshots')).toBe(0);
736
+ });
737
+ });
738
+
739
+ test('createTopoSnapshot succeeds alongside a legacy projection store', () => {
740
+ withProjectionDb((db) => {
741
+ seedLegacyProjectionStore(db);
742
+
743
+ const snapshot = unwrap(
744
+ createTopoSnapshot(db, exampleApp(), {
745
+ createdAt: '2026-04-03T12:00:00.000Z',
746
+ })
747
+ );
748
+
749
+ expect(tableExists(db, 'topo_trailheads')).toBe(true);
750
+ expect(countRows(db, 'topo_snapshots')).toBe(1);
751
+ expectProjectionCounts(db, snapshot.id);
752
+ });
753
+ });
754
+
755
+ test('createTopoStore re-migrates after trails.db is replaced at the same path', async () => {
756
+ const rootDir = makeRoot();
757
+
758
+ // Round 1: seed a current-schema store and read through createTopoStore
759
+ // to prime the migration cache.
760
+ seedCurrentSchemaStore(rootDir);
761
+ captureReadError(() => createTopoStore({ rootDir }).snapshots.latest());
762
+ const baselineEscalations = __topoStoreMigrationStats.writeEscalations;
763
+
764
+ await replaceStoreWithHistoryOnlyStore(rootDir);
765
+
766
+ // Round 2: a fresh createTopoStore call must detect the file swap,
767
+ // re-run the migration, and keep surfacing the empty-store NotFound.
768
+ const caught = captureReadError(() =>
769
+ createTopoStore({ rootDir }).snapshots.latest()
770
+ );
771
+ expect(caught).toBeInstanceOf(NotFoundError);
772
+ expect(__topoStoreMigrationStats.writeEscalations).toBe(
773
+ baselineEscalations + 1
774
+ );
775
+ withWriteDb(rootDir, (db) => {
776
+ expect(tableExists(db, 'topo_snapshots')).toBe(true);
777
+ expect(countRows(db, 'topo_snapshots')).toBe(0);
778
+ });
779
+ });
780
+ });
781
+
782
+ test('history-only topo stores are ignored instead of translated into snapshots', () => {
783
+ withProjectionDb((db) => {
784
+ seedHistoryOnlyTopoSchema(db);
785
+ ensureTopoSnapshotSchema(db);
786
+ expect(
787
+ db
788
+ .query<{ version: number }, []>(
789
+ "SELECT version FROM meta_schema_versions WHERE subsystem = 'topo'"
790
+ )
791
+ .get()?.version
792
+ ).toBe(8);
793
+ for (const table of [
794
+ 'topo_snapshots',
795
+ 'topo_trails',
796
+ 'topo_crossings',
797
+ 'topo_examples',
798
+ 'topo_exports',
799
+ 'topo_schemas',
800
+ 'topo_surfaces',
801
+ 'topo_trail_fires',
802
+ 'topo_trail_on',
803
+ ]) {
804
+ expect(tableExists(db, table)).toBe(true);
805
+ }
806
+ expect(countRows(db, 'topo_snapshots')).toBe(0);
807
+ // Legacy history tables are left untouched during the migration and are
808
+ // simply ignored by the snapshot-first readers.
809
+ expect(tableExists(db, 'topo_saves')).toBe(true);
810
+ expect(tableExists(db, 'topo_pins')).toBe(true);
811
+ expect(tableExists(db, 'topo_trailheads')).toBe(false);
812
+ });
813
+ });
814
+ });
815
+
816
+ describe('signal edge normalizers', () => {
817
+ const makeTrail = (
818
+ id: string,
819
+ opts: {
820
+ readonly fires?: readonly string[];
821
+ readonly on?: readonly string[];
822
+ }
823
+ ) =>
824
+ trail(id, {
825
+ blaze: () => Result.ok({ ok: true }),
826
+ ...(opts.fires ? { fires: opts.fires } : {}),
827
+ input: z.object({}),
828
+ ...(opts.on ? { on: opts.on } : {}),
829
+ output: z.object({ ok: z.boolean() }),
830
+ });
831
+
832
+ test('normalizeFiresRows produces one row per (trail, signal) pair', () => {
833
+ const trails = [
834
+ makeTrail('t.one', { fires: ['s.a', 's.b'] }),
835
+ makeTrail('t.two', { fires: ['s.a'] }),
836
+ ];
837
+ expect(normalizeFiresRows(trails, 'snap-1')).toEqual([
838
+ { signalId: 's.a', snapshotId: 'snap-1', trailId: 't.one' },
839
+ { signalId: 's.b', snapshotId: 'snap-1', trailId: 't.one' },
840
+ { signalId: 's.a', snapshotId: 'snap-1', trailId: 't.two' },
841
+ ]);
842
+ });
843
+
844
+ test('normalizeOnRows produces one row per (trail, signal) pair', () => {
845
+ const trails = [
846
+ makeTrail('t.one', { on: ['s.a', 's.b'] }),
847
+ makeTrail('t.two', { on: ['s.b'] }),
848
+ ];
849
+ expect(normalizeOnRows(trails, 'snap-1')).toEqual([
850
+ { signalId: 's.a', snapshotId: 'snap-1', trailId: 't.one' },
851
+ { signalId: 's.b', snapshotId: 'snap-1', trailId: 't.one' },
852
+ { signalId: 's.b', snapshotId: 'snap-1', trailId: 't.two' },
853
+ ]);
854
+ });
855
+
856
+ test('trails without fires/on produce no rows', () => {
857
+ const trails = [makeTrail('t.plain', {})];
858
+ expect(normalizeFiresRows(trails, 'snap-1')).toEqual([]);
859
+ expect(normalizeOnRows(trails, 'snap-1')).toEqual([]);
860
+ });
861
+
862
+ test('deduplicates and sorts signal ids', () => {
863
+ const trails = [makeTrail('t.one', { fires: ['s.b', 's.a', 's.b'] })];
864
+ expect(normalizeFiresRows(trails, 'snap-1')).toEqual([
865
+ { signalId: 's.a', snapshotId: 'snap-1', trailId: 't.one' },
866
+ { signalId: 's.b', snapshotId: 'snap-1', trailId: 't.one' },
867
+ ]);
868
+ });
869
+ });