@ontrails/core 1.0.0-beta.14 → 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 (280) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +17 -13
  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/draft.d.ts +5 -5
  21. package/dist/draft.d.ts.map +1 -1
  22. package/dist/draft.js +28 -27
  23. package/dist/draft.js.map +1 -1
  24. package/dist/errors.d.ts +62 -4
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +44 -1
  27. package/dist/errors.js.map +1 -1
  28. package/dist/execute.d.ts +26 -9
  29. package/dist/execute.d.ts.map +1 -1
  30. package/dist/execute.js +441 -20
  31. package/dist/execute.js.map +1 -1
  32. package/dist/fire.d.ts +43 -0
  33. package/dist/fire.d.ts.map +1 -0
  34. package/dist/fire.js +185 -0
  35. package/dist/fire.js.map +1 -0
  36. package/dist/index.d.ts +31 -20
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +23 -13
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/cross-batch.d.ts +44 -0
  41. package/dist/internal/cross-batch.d.ts.map +1 -0
  42. package/dist/internal/cross-batch.js +58 -0
  43. package/dist/internal/cross-batch.js.map +1 -0
  44. package/dist/internal/fork-ctx.d.ts +41 -0
  45. package/dist/internal/fork-ctx.d.ts.map +1 -0
  46. package/dist/internal/fork-ctx.js +47 -0
  47. package/dist/internal/fork-ctx.js.map +1 -0
  48. package/dist/internal/signal-ref.d.ts +15 -0
  49. package/dist/internal/signal-ref.d.ts.map +1 -0
  50. package/dist/internal/signal-ref.js +41 -0
  51. package/dist/internal/signal-ref.js.map +1 -0
  52. package/dist/internal/topo-saves.d.ts +8 -2
  53. package/dist/internal/topo-saves.d.ts.map +1 -1
  54. package/dist/internal/topo-saves.js +162 -23
  55. package/dist/internal/topo-saves.js.map +1 -1
  56. package/dist/internal/topo-snapshots.d.ts +57 -0
  57. package/dist/internal/topo-snapshots.d.ts.map +1 -0
  58. package/dist/internal/topo-snapshots.js +372 -0
  59. package/dist/internal/topo-snapshots.js.map +1 -0
  60. package/dist/internal/topo-store-read.d.ts +23 -20
  61. package/dist/internal/topo-store-read.d.ts.map +1 -1
  62. package/dist/internal/topo-store-read.js +82 -83
  63. package/dist/internal/topo-store-read.js.map +1 -1
  64. package/dist/internal/topo-store.d.ts +19 -5
  65. package/dist/internal/topo-store.d.ts.map +1 -1
  66. package/dist/internal/topo-store.js +174 -124
  67. package/dist/internal/topo-store.js.map +1 -1
  68. package/dist/internal/tracing.d.ts +98 -0
  69. package/dist/internal/tracing.d.ts.map +1 -0
  70. package/dist/internal/tracing.js +102 -0
  71. package/dist/internal/tracing.js.map +1 -0
  72. package/dist/internal/trails-db.d.ts +2 -2
  73. package/dist/internal/trails-db.d.ts.map +1 -1
  74. package/dist/internal/trails-db.js +8 -8
  75. package/dist/internal/trails-db.js.map +1 -1
  76. package/dist/internal/zod-wrappers.d.ts +40 -0
  77. package/dist/internal/zod-wrappers.d.ts.map +1 -0
  78. package/dist/internal/zod-wrappers.js +59 -0
  79. package/dist/internal/zod-wrappers.js.map +1 -0
  80. package/dist/layer.d.ts +22 -5
  81. package/dist/layer.d.ts.map +1 -1
  82. package/dist/layer.js +20 -3
  83. package/dist/layer.js.map +1 -1
  84. package/dist/path-security.d.ts +1 -1
  85. package/dist/path-security.d.ts.map +1 -1
  86. package/dist/path-security.js +1 -1
  87. package/dist/path-security.js.map +1 -1
  88. package/dist/resilience.d.ts +1 -1
  89. package/dist/resilience.d.ts.map +1 -1
  90. package/dist/resilience.js +3 -3
  91. package/dist/resilience.js.map +1 -1
  92. package/dist/resource-config.d.ts +22 -0
  93. package/dist/resource-config.d.ts.map +1 -0
  94. package/dist/resource-config.js +210 -0
  95. package/dist/resource-config.js.map +1 -0
  96. package/dist/resource.d.ts +74 -0
  97. package/dist/resource.d.ts.map +1 -0
  98. package/dist/resource.js +61 -0
  99. package/dist/resource.js.map +1 -0
  100. package/dist/run.d.ts +4 -3
  101. package/dist/run.d.ts.map +1 -1
  102. package/dist/run.js +5 -4
  103. package/dist/run.js.map +1 -1
  104. package/dist/serialization.d.ts.map +1 -1
  105. package/dist/serialization.js +2 -1
  106. package/dist/serialization.js.map +1 -1
  107. package/dist/store/accessor-protocol.d.ts +51 -0
  108. package/dist/store/accessor-protocol.d.ts.map +1 -0
  109. package/dist/store/accessor-protocol.js +2 -0
  110. package/dist/store/accessor-protocol.js.map +1 -0
  111. package/dist/store/index.d.ts +2 -0
  112. package/dist/store/index.d.ts.map +1 -0
  113. package/dist/store/index.js +2 -0
  114. package/dist/store/index.js.map +1 -0
  115. package/dist/surface-filter.d.ts +13 -0
  116. package/dist/surface-filter.d.ts.map +1 -0
  117. package/dist/surface-filter.js +76 -0
  118. package/dist/surface-filter.js.map +1 -0
  119. package/dist/topo-store.d.ts +36 -22
  120. package/dist/topo-store.d.ts.map +1 -1
  121. package/dist/topo-store.js +215 -71
  122. package/dist/topo-store.js.map +1 -1
  123. package/dist/topo.d.ts +23 -9
  124. package/dist/topo.d.ts.map +1 -1
  125. package/dist/topo.js +175 -46
  126. package/dist/topo.js.map +1 -1
  127. package/dist/trail.d.ts +86 -19
  128. package/dist/trail.d.ts.map +1 -1
  129. package/dist/trail.js +59 -4
  130. package/dist/trail.js.map +1 -1
  131. package/dist/trails/derive-trail.d.ts +66 -0
  132. package/dist/trails/derive-trail.d.ts.map +1 -0
  133. package/dist/trails/derive-trail.js +378 -0
  134. package/dist/trails/derive-trail.js.map +1 -0
  135. package/dist/trails/index.d.ts +5 -0
  136. package/dist/trails/index.d.ts.map +1 -0
  137. package/dist/trails/index.js +3 -0
  138. package/dist/trails/index.js.map +1 -0
  139. package/dist/trails/ingest.d.ts +24 -0
  140. package/dist/trails/ingest.d.ts.map +1 -0
  141. package/dist/trails/ingest.js +63 -0
  142. package/dist/trails/ingest.js.map +1 -0
  143. package/dist/transport-error-map.d.ts +106 -0
  144. package/dist/transport-error-map.d.ts.map +1 -0
  145. package/dist/transport-error-map.js +24 -0
  146. package/dist/transport-error-map.js.map +1 -0
  147. package/dist/type-checks.test-d.d.ts +71 -0
  148. package/dist/type-checks.test-d.d.ts.map +1 -0
  149. package/dist/type-checks.test-d.js +12 -0
  150. package/dist/type-checks.test-d.js.map +1 -0
  151. package/dist/type-utils.d.ts +11 -2
  152. package/dist/type-utils.d.ts.map +1 -1
  153. package/dist/type-utils.js.map +1 -1
  154. package/dist/types.d.ts +113 -7
  155. package/dist/types.d.ts.map +1 -1
  156. package/dist/types.js.map +1 -1
  157. package/dist/validate-established-topo.js +2 -2
  158. package/dist/validate-established-topo.js.map +1 -1
  159. package/dist/validate-topo.d.ts.map +1 -1
  160. package/dist/validate-topo.js +24 -7
  161. package/dist/validate-topo.js.map +1 -1
  162. package/dist/workspace.d.ts +1 -1
  163. package/dist/workspace.d.ts.map +1 -1
  164. package/dist/workspace.js +1 -1
  165. package/dist/workspace.js.map +1 -1
  166. package/package.json +8 -2
  167. package/src/__tests__/branded.test.ts +5 -5
  168. package/src/__tests__/context.test.ts +3 -3
  169. package/src/__tests__/contour.test.ts +263 -0
  170. package/src/__tests__/derive-trail.test.ts +668 -0
  171. package/src/__tests__/errors.test.ts +118 -0
  172. package/src/__tests__/execute.test.ts +1179 -141
  173. package/src/__tests__/fire.test.ts +1056 -0
  174. package/src/__tests__/fork-ctx.test.ts +83 -0
  175. package/src/__tests__/ingest.test.ts +191 -0
  176. package/src/__tests__/{gate.test.ts → layer.test.ts} +21 -21
  177. package/src/__tests__/path-security.test.ts +7 -7
  178. package/src/__tests__/resilience.test.ts +8 -8
  179. package/src/__tests__/resource.test.ts +204 -0
  180. package/src/__tests__/run.test.ts +13 -13
  181. package/src/__tests__/service-config.test.ts +28 -32
  182. package/src/__tests__/surface-filter.test.ts +196 -0
  183. package/src/__tests__/topo-store-read.test.ts +41 -36
  184. package/src/__tests__/topo-store.test.ts +493 -93
  185. package/src/__tests__/topo.test.ts +350 -54
  186. package/src/__tests__/trail.test.ts +189 -24
  187. package/src/__tests__/trails-db.test.ts +32 -25
  188. package/src/__tests__/transport-error-map.test.ts +82 -0
  189. package/src/__tests__/type-utils.test.ts +29 -1
  190. package/src/__tests__/validate-topo.test.ts +147 -25
  191. package/src/__tests__/workspace.test.ts +6 -6
  192. package/src/__tests__/zod-wrappers.test.ts +62 -0
  193. package/src/branded.ts +1 -1
  194. package/src/context.ts +24 -4
  195. package/src/contour.ts +344 -0
  196. package/src/cross-schema.ts +36 -0
  197. package/src/draft.ts +56 -52
  198. package/src/errors.ts +62 -18
  199. package/src/execute.ts +840 -29
  200. package/src/fire.ts +295 -0
  201. package/src/index.ts +116 -29
  202. package/src/internal/cross-batch.ts +77 -0
  203. package/src/internal/fork-ctx.ts +69 -0
  204. package/src/internal/signal-ref.ts +87 -0
  205. package/src/internal/topo-snapshots.ts +517 -0
  206. package/src/internal/topo-store-read.ts +140 -132
  207. package/src/internal/topo-store.ts +291 -186
  208. package/src/internal/tracing.ts +187 -0
  209. package/src/internal/trails-db.ts +9 -11
  210. package/src/internal/zod-wrappers.ts +78 -0
  211. package/src/layer.ts +50 -0
  212. package/src/path-security.ts +1 -1
  213. package/src/resilience.ts +3 -3
  214. package/src/{provision-config.ts → resource-config.ts} +109 -123
  215. package/src/resource.ts +155 -0
  216. package/src/run.ts +5 -4
  217. package/src/serialization.ts +2 -0
  218. package/src/store/accessor-protocol.ts +56 -0
  219. package/src/store/index.ts +4 -0
  220. package/src/surface-filter.ts +182 -0
  221. package/src/topo-store.ts +313 -104
  222. package/src/topo.ts +328 -57
  223. package/src/trail.ts +199 -31
  224. package/src/trails/derive-trail.ts +842 -0
  225. package/src/trails/index.ts +9 -0
  226. package/src/trails/ingest.ts +146 -0
  227. package/src/transport-error-map.ts +56 -0
  228. package/src/type-checks.test-d.ts +106 -0
  229. package/src/type-utils.ts +17 -2
  230. package/src/types.ts +148 -11
  231. package/src/validate-established-topo.ts +2 -2
  232. package/src/validate-topo.ts +32 -8
  233. package/src/workspace.ts +1 -1
  234. package/tsconfig.tests.json +10 -0
  235. package/tsconfig.tsbuildinfo +1 -1
  236. package/dist/adapters.d.ts +0 -39
  237. package/dist/adapters.d.ts.map +0 -1
  238. package/dist/adapters.js +0 -2
  239. package/dist/adapters.js.map +0 -1
  240. package/dist/dispatch.d.ts +0 -27
  241. package/dist/dispatch.d.ts.map +0 -1
  242. package/dist/dispatch.js +0 -34
  243. package/dist/dispatch.js.map +0 -1
  244. package/dist/event.d.ts +0 -8
  245. package/dist/event.d.ts.map +0 -1
  246. package/dist/event.js +0 -7
  247. package/dist/event.js.map +0 -1
  248. package/dist/gate.d.ts +0 -17
  249. package/dist/gate.d.ts.map +0 -1
  250. package/dist/gate.js +0 -21
  251. package/dist/gate.js.map +0 -1
  252. package/dist/health.d.ts +0 -18
  253. package/dist/health.d.ts.map +0 -1
  254. package/dist/health.js +0 -5
  255. package/dist/health.js.map +0 -1
  256. package/dist/job.d.ts +0 -24
  257. package/dist/job.d.ts.map +0 -1
  258. package/dist/job.js +0 -17
  259. package/dist/job.js.map +0 -1
  260. package/dist/provision-config.d.ts +0 -22
  261. package/dist/provision-config.d.ts.map +0 -1
  262. package/dist/provision-config.js +0 -210
  263. package/dist/provision-config.js.map +0 -1
  264. package/dist/provision.d.ts +0 -71
  265. package/dist/provision.d.ts.map +0 -1
  266. package/dist/provision.js +0 -56
  267. package/dist/provision.js.map +0 -1
  268. package/dist/service-config.d.ts +0 -22
  269. package/dist/service-config.d.ts.map +0 -1
  270. package/dist/service-config.js +0 -210
  271. package/dist/service-config.js.map +0 -1
  272. package/dist/service.d.ts +0 -71
  273. package/dist/service.d.ts.map +0 -1
  274. package/dist/service.js +0 -56
  275. package/dist/service.js.map +0 -1
  276. package/src/__tests__/service.test.ts +0 -197
  277. package/src/event.ts +0 -15
  278. package/src/gate.ts +0 -44
  279. package/src/internal/topo-saves.ts +0 -429
  280. package/src/provision.ts +0 -148
@@ -5,15 +5,19 @@ import { join } from 'node:path';
5
5
 
6
6
  import { z } from 'zod';
7
7
 
8
- import { Result, provision, signal, topo, trail } from '../index.js';
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';
9
11
  import {
10
- ensureTopoHistorySchema,
11
- pinTopoSave,
12
- pruneUnpinnedTopoSaves,
13
- } from '../internal/topo-saves.js';
12
+ ensureTopoSnapshotSchema,
13
+ pinTopoSnapshot,
14
+ pruneUnpinnedSnapshots,
15
+ } from '../internal/topo-snapshots.js';
14
16
  import {
17
+ createTopoSnapshot,
15
18
  getStoredTopoExport,
16
- persistEstablishedTopoSave,
19
+ normalizeFiresRows,
20
+ normalizeOnRows,
17
21
  } from '../internal/topo-store.js';
18
22
  import { openWriteTrailsDb } from '../internal/trails-db.js';
19
23
 
@@ -30,10 +34,10 @@ const unwrap = <T>(result: Result<T, Error>): T => {
30
34
  const countRows = (
31
35
  db: ReturnType<typeof openWriteTrailsDb>,
32
36
  tableName: string,
33
- saveId?: string
37
+ snapshotId?: string
34
38
  ): number => {
35
39
  const row =
36
- saveId === undefined
40
+ snapshotId === undefined
37
41
  ? db
38
42
  .query<{ count: number }, []>(
39
43
  `SELECT COUNT(*) as count FROM ${tableName}`
@@ -41,9 +45,9 @@ const countRows = (
41
45
  .get()
42
46
  : db
43
47
  .query<{ count: number }, [string]>(
44
- `SELECT COUNT(*) as count FROM ${tableName} WHERE save_id = ?`
48
+ `SELECT COUNT(*) as count FROM ${tableName} WHERE snapshot_id = ?`
45
49
  )
46
- .get(saveId);
50
+ .get(snapshotId);
47
51
  return row?.count ?? 0;
48
52
  };
49
53
 
@@ -60,14 +64,23 @@ const tableExists = (
60
64
  };
61
65
 
62
66
  const exampleApp = () => {
63
- const dbMain = provision('db.main', {
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', {
64
77
  create: () => Result.ok({ source: 'factory' }),
65
78
  description: 'Primary database',
66
79
  health: () => Result.ok({ ok: true }),
67
80
  mock: () => ({ source: 'mock' }),
68
81
  });
69
82
 
70
- const searchIndex = provision('search.index', {
83
+ const searchIndex = resource('search.index', {
71
84
  create: () => Result.ok({ source: 'factory' }),
72
85
  mock: () => ({ source: 'mock' }),
73
86
  });
@@ -81,6 +94,7 @@ const exampleApp = () => {
81
94
  const entityAdd = trail('entity.add', {
82
95
  blaze: (input: { readonly name: string }) =>
83
96
  Result.ok({ id: input.name.toLowerCase(), ok: true }),
97
+ contours: [entityContour],
84
98
  description: 'Add a new entity',
85
99
  examples: [
86
100
  {
@@ -97,24 +111,26 @@ const exampleApp = () => {
97
111
  input: z.object({ name: z.string() }),
98
112
  meta: { owner: 'core', tags: ['write', 'entity'] },
99
113
  output: z.object({ id: z.string(), ok: z.boolean() }),
100
- provisions: [dbMain, searchIndex],
114
+ resources: [dbMain, searchIndex],
101
115
  });
102
116
 
103
117
  const entityList = trail('entity.list', {
104
118
  blaze: () => Result.ok({ items: ['ada'] }),
119
+ contours: [entityContour],
105
120
  crosses: ['entity.add'],
106
121
  description: 'List entities',
107
122
  idempotent: true,
108
123
  input: z.object({}),
109
124
  intent: 'read',
110
125
  output: z.object({ items: z.array(z.string()) }),
111
- provisions: [dbMain],
126
+ resources: [dbMain],
112
127
  });
113
128
 
114
129
  return topo('projection-app', {
115
130
  dbMain,
116
131
  entityAdd,
117
132
  entityAdded,
133
+ entityContour,
118
134
  entityList,
119
135
  searchIndex,
120
136
  });
@@ -122,7 +138,7 @@ const exampleApp = () => {
122
138
 
123
139
  const readTrailRows = (
124
140
  db: ReturnType<typeof openWriteTrailsDb>,
125
- saveId: string
141
+ snapshotId: string
126
142
  ) =>
127
143
  db
128
144
  .query<
@@ -139,39 +155,39 @@ const readTrailRows = (
139
155
  >(
140
156
  `SELECT id, intent, idempotent, has_output, example_count, description, meta
141
157
  FROM topo_trails
142
- WHERE save_id = ?
158
+ WHERE snapshot_id = ?
143
159
  ORDER BY id ASC`
144
160
  )
145
- .all(saveId);
161
+ .all(snapshotId);
146
162
 
147
163
  const readTrailSignalRows = (
148
164
  db: ReturnType<typeof openWriteTrailsDb>,
149
- saveId: string
165
+ snapshotId: string
150
166
  ) =>
151
167
  db
152
168
  .query<{ signal_id: string; trail_id: string }, [string]>(
153
169
  `SELECT trail_id, signal_id
154
170
  FROM topo_trail_signals
155
- WHERE save_id = ?`
171
+ WHERE snapshot_id = ?`
156
172
  )
157
- .all(saveId);
173
+ .all(snapshotId);
158
174
 
159
- const readTrailheadRows = (
175
+ const readSurfaceRows = (
160
176
  db: ReturnType<typeof openWriteTrailsDb>,
161
- saveId: string
177
+ snapshotId: string
162
178
  ) =>
163
179
  db
164
180
  .query<{ derived_name: string; trail_id: string }, [string]>(
165
181
  `SELECT trail_id, derived_name
166
- FROM topo_trailheads
167
- WHERE save_id = ?
182
+ FROM topo_surfaces
183
+ WHERE snapshot_id = ?
168
184
  ORDER BY trail_id ASC`
169
185
  )
170
- .all(saveId);
186
+ .all(snapshotId);
171
187
 
172
188
  const readExampleRows = (
173
189
  db: ReturnType<typeof openWriteTrailsDb>,
174
- saveId: string
190
+ snapshotId: string
175
191
  ) =>
176
192
  db
177
193
  .query<
@@ -186,54 +202,62 @@ const readExampleRows = (
186
202
  >(
187
203
  `SELECT ordinal, name, input, expected, error
188
204
  FROM topo_examples
189
- WHERE save_id = ?
205
+ WHERE snapshot_id = ?
190
206
  ORDER BY ordinal ASC`
191
207
  )
192
- .all(saveId);
208
+ .all(snapshotId);
193
209
 
194
210
  const readProjectedTrailIds = (
195
211
  db: ReturnType<typeof openWriteTrailsDb>,
196
- saveId: string
212
+ snapshotId: string
197
213
  ) =>
198
214
  db
199
215
  .query<{ id: string }, [string]>(
200
- 'SELECT id FROM topo_trails WHERE save_id = ? ORDER BY id ASC'
216
+ 'SELECT id FROM topo_trails WHERE snapshot_id = ? ORDER BY id ASC'
201
217
  )
202
- .all(saveId)
218
+ .all(snapshotId)
203
219
  .map((row) => row.id);
204
220
 
205
221
  const requireStoredExport = (
206
222
  db: ReturnType<typeof openWriteTrailsDb>,
207
- saveId: string
223
+ snapshotId: string
208
224
  ) => {
209
- const stored = getStoredTopoExport(db, saveId);
225
+ const stored = getStoredTopoExport(db, snapshotId);
210
226
  if (stored === undefined) {
211
- throw new Error(`Expected stored topo export for save "${saveId}"`);
227
+ throw new Error(`Expected stored topo export for snapshot "${snapshotId}"`);
212
228
  }
213
229
  return stored;
214
230
  };
215
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
+
216
240
  const expectProjectionCounts = (
217
241
  db: ReturnType<typeof openWriteTrailsDb>,
218
- saveId: string
242
+ snapshotId: string
219
243
  ): void => {
220
- expect(countRows(db, 'topo_trails', saveId)).toBe(2);
221
- expect(countRows(db, 'topo_crossings', saveId)).toBe(1);
222
- expect(countRows(db, 'topo_trail_provisions', saveId)).toBe(3);
223
- expect(countRows(db, 'topo_provisions', saveId)).toBe(2);
224
- expect(countRows(db, 'topo_signals', saveId)).toBe(1);
225
- expect(countRows(db, 'topo_trail_signals', saveId)).toBe(1);
226
- expect(countRows(db, 'topo_trailheads', saveId)).toBe(2);
227
- expect(countRows(db, 'topo_examples', saveId)).toBe(2);
228
- expect(countRows(db, 'topo_schemas', saveId)).toBe(5);
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);
229
253
  expect(countRows(db, 'topo_exports')).toBeGreaterThanOrEqual(1);
230
254
  };
231
255
 
232
256
  const expectProjectedFixtureRows = (
233
257
  db: ReturnType<typeof openWriteTrailsDb>,
234
- saveId: string
258
+ snapshotId: string
235
259
  ): void => {
236
- expect(readTrailRows(db, saveId)).toEqual([
260
+ expect(readTrailRows(db, snapshotId)).toEqual([
237
261
  {
238
262
  description: 'Add a new entity',
239
263
  example_count: 2,
@@ -256,14 +280,14 @@ const expectProjectedFixtureRows = (
256
280
  meta: null,
257
281
  },
258
282
  ]);
259
- expect(readTrailSignalRows(db, saveId)).toEqual([
283
+ expect(readTrailSignalRows(db, snapshotId)).toEqual([
260
284
  { signal_id: 'entity.added', trail_id: 'entity.add' },
261
285
  ]);
262
- expect(readTrailheadRows(db, saveId)).toEqual([
286
+ expect(readSurfaceRows(db, snapshotId)).toEqual([
263
287
  { derived_name: 'entity add', trail_id: 'entity.add' },
264
288
  { derived_name: 'entity list', trail_id: 'entity.list' },
265
289
  ]);
266
- expect(readExampleRows(db, saveId)).toEqual([
290
+ expect(readExampleRows(db, snapshotId)).toEqual([
267
291
  {
268
292
  error: null,
269
293
  expected: JSON.stringify({ id: 'ada', ok: true }),
@@ -300,6 +324,99 @@ const simpleProjectionApp = (withList: boolean) =>
300
324
  : {}),
301
325
  });
302
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
+
303
420
  const seedHistoryOnlyTopoSchema = (
304
421
  db: ReturnType<typeof openWriteTrailsDb>
305
422
  ): void => {
@@ -314,7 +431,7 @@ const seedHistoryOnlyTopoSchema = (
314
431
  git_dirty INTEGER NOT NULL DEFAULT 0,
315
432
  trail_count INTEGER NOT NULL DEFAULT 0,
316
433
  signal_count INTEGER NOT NULL DEFAULT 0,
317
- provision_count INTEGER NOT NULL DEFAULT 0,
434
+ resource_count INTEGER NOT NULL DEFAULT 0,
318
435
  created_at TEXT NOT NULL
319
436
  )`);
320
437
  db.run(`CREATE TABLE IF NOT EXISTS topo_pins (
@@ -324,7 +441,7 @@ const seedHistoryOnlyTopoSchema = (
324
441
  )`);
325
442
  db.run(
326
443
  `INSERT INTO topo_saves (
327
- id, git_sha, git_dirty, trail_count, signal_count, provision_count, created_at
444
+ id, git_sha, git_dirty, trail_count, signal_count, resource_count, created_at
328
445
  ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
329
446
  ['seed-save', 'seed123', 0, 1, 0, 0, '2026-04-03T11:00:00.000Z']
330
447
  );
@@ -335,6 +452,52 @@ const seedHistoryOnlyTopoSchema = (
335
452
  ]);
336
453
  };
337
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
+
338
501
  describe('topo store projection', () => {
339
502
  let tmpRoot: string | undefined;
340
503
 
@@ -361,109 +524,346 @@ describe('topo store projection', () => {
361
524
  }
362
525
  };
363
526
 
364
- test('projects a save-scoped relational topo from the established app graph', () => {
527
+ test('projects a snapshot-scoped relational topo from the established app graph', () => {
365
528
  withProjectionDb((db) => {
366
- const save = unwrap(
367
- persistEstablishedTopoSave(db, exampleApp(), {
529
+ const snapshot = unwrap(
530
+ createTopoSnapshot(db, exampleApp(), {
368
531
  createdAt: '2026-04-03T12:00:00.000Z',
369
532
  gitDirty: false,
370
533
  gitSha: 'abc123',
371
534
  })
372
535
  );
373
- expectProjectionCounts(db, save.id);
374
- expectProjectedFixtureRows(db, save.id);
536
+ expectProjectionCounts(db, snapshot.id);
537
+ expectProjectedFixtureRows(db, snapshot.id);
375
538
 
376
- const stored = requireStoredExport(db, save.id);
377
- expect(JSON.parse(stored.trailheadMapJson)).toMatchObject({
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({
378
543
  entries: expect.any(Array),
379
544
  generatedAt: '2026-04-03T12:00:00.000Z',
380
545
  version: '1.0',
381
546
  });
547
+ expect(Array.isArray(entries)).toBe(true);
548
+ expect(
549
+ entries?.some((entry) => hasContourTrailheadEntry(entry, 'entity'))
550
+ ).toBe(true);
551
+
382
552
  expect(JSON.parse(stored.lockContent)).toMatchObject({
383
553
  apps: {
384
554
  'projection-app': {
385
- provisions: expect.any(Object),
555
+ contours: {
556
+ entity: expect.objectContaining({
557
+ identity: 'id',
558
+ }),
559
+ },
560
+ resources: expect.any(Object),
386
561
  signals: expect.any(Object),
387
562
  trails: expect.any(Object),
388
563
  },
389
564
  },
390
565
  generatedAt: '2026-04-03T12:00:00.000Z',
391
- hash: stored.trailheadHash,
566
+ hash: stored.surfaceHash,
392
567
  version: 1,
393
568
  });
394
569
  });
395
570
  });
396
571
 
397
- test('keeps projected rows isolated across successive saves', () => {
572
+ test('keeps projected rows isolated across successive snapshots', () => {
398
573
  withProjectionDb((db) => {
399
- const firstSave = unwrap(
400
- persistEstablishedTopoSave(db, simpleProjectionApp(false), {
574
+ const firstSnapshot = unwrap(
575
+ createTopoSnapshot(db, simpleProjectionApp(false), {
401
576
  createdAt: '2026-04-03T12:00:00.000Z',
402
577
  })
403
578
  );
404
- const secondSave = unwrap(
405
- persistEstablishedTopoSave(db, simpleProjectionApp(true), {
579
+ const secondSnapshot = unwrap(
580
+ createTopoSnapshot(db, simpleProjectionApp(true), {
406
581
  createdAt: '2026-04-03T12:05:00.000Z',
407
582
  })
408
583
  );
409
584
 
410
- expect(firstSave.id).not.toBe(secondSave.id);
411
- expect(countRows(db, 'topo_trails', firstSave.id)).toBe(1);
412
- expect(countRows(db, 'topo_trails', secondSave.id)).toBe(2);
413
- expect(readProjectedTrailIds(db, firstSave.id)).toEqual(['entity.add']);
414
- expect(readProjectedTrailIds(db, secondSave.id)).toEqual([
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([
415
592
  'entity.add',
416
593
  'entity.list',
417
594
  ]);
418
- expect(requireStoredExport(db, firstSave.id).trailheadHash).not.toBe(
419
- requireStoredExport(db, secondSave.id).trailheadHash
595
+ expect(requireStoredExport(db, firstSnapshot.id).surfaceHash).not.toBe(
596
+ requireStoredExport(db, secondSnapshot.id).surfaceHash
420
597
  );
421
598
  });
422
599
  });
423
600
 
424
- test("pruning an unpinned save removes only that save's projected rows", () => {
601
+ test("pruning an unpinned snapshot removes only that snapshot's projected rows", () => {
425
602
  withProjectionDb((db) => {
426
- const app = exampleApp();
427
603
  const pinned = unwrap(
428
- persistEstablishedTopoSave(db, app, {
604
+ createTopoSnapshot(db, buildSignalPruneApp(), {
429
605
  createdAt: '2026-04-03T12:00:00.000Z',
430
606
  })
431
607
  );
432
- pinTopoSave(db, { name: 'before-auth', saveId: pinned.id });
608
+ pinTopoSnapshot(db, { id: pinned.id, name: 'before-auth' });
433
609
 
434
610
  const disposable = unwrap(
435
- persistEstablishedTopoSave(db, app, {
611
+ createTopoSnapshot(db, buildSignalPruneApp(), {
436
612
  createdAt: '2026-04-03T12:05:00.000Z',
437
613
  })
438
614
  );
439
615
 
440
- expect(pruneUnpinnedTopoSaves(db, { keep: 0 })).toBe(1);
441
- expectProjectionCounts(db, pinned.id);
442
- expect(countRows(db, 'topo_trails', disposable.id)).toBe(0);
443
- expect(countRows(db, 'topo_crossings', disposable.id)).toBe(0);
444
- expect(countRows(db, 'topo_examples', disposable.id)).toBe(0);
445
- expect(countRows(db, 'topo_schemas', disposable.id)).toBe(0);
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);
446
627
  });
447
628
  });
448
629
 
449
- test('upgrades a history-only topo schema to the projected topo schema', () => {
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', () => {
450
783
  withProjectionDb((db) => {
451
784
  seedHistoryOnlyTopoSchema(db);
452
- ensureTopoHistorySchema(db);
785
+ ensureTopoSnapshotSchema(db);
453
786
  expect(
454
787
  db
455
788
  .query<{ version: number }, []>(
456
789
  "SELECT version FROM meta_schema_versions WHERE subsystem = 'topo'"
457
790
  )
458
791
  .get()?.version
459
- ).toBe(3);
460
- expect(tableExists(db, 'topo_trails')).toBe(true);
461
- expect(tableExists(db, 'topo_crossings')).toBe(true);
462
- expect(tableExists(db, 'topo_examples')).toBe(true);
463
- expect(tableExists(db, 'topo_exports')).toBe(true);
464
- expect(tableExists(db, 'topo_schemas')).toBe(true);
465
- expect(countRows(db, 'topo_saves')).toBe(1);
466
- expect(countRows(db, 'topo_pins')).toBe(1);
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);
467
812
  });
468
813
  });
469
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
+ });