@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +17 -13
- package/dist/branded.d.ts +1 -1
- package/dist/branded.d.ts.map +1 -1
- package/dist/branded.js +1 -1
- package/dist/branded.js.map +1 -1
- package/dist/context.d.ts +13 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +19 -3
- package/dist/context.js.map +1 -1
- package/dist/contour.d.ts +81 -0
- package/dist/contour.d.ts.map +1 -0
- package/dist/contour.js +176 -0
- package/dist/contour.js.map +1 -0
- package/dist/cross-schema.d.ts +18 -0
- package/dist/cross-schema.d.ts.map +1 -0
- package/dist/cross-schema.js +29 -0
- package/dist/cross-schema.js.map +1 -0
- package/dist/draft.d.ts +5 -5
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +28 -27
- package/dist/draft.js.map +1 -1
- package/dist/errors.d.ts +62 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -1
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +26 -9
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +441 -20
- package/dist/execute.js.map +1 -1
- package/dist/fire.d.ts +43 -0
- package/dist/fire.d.ts.map +1 -0
- package/dist/fire.js +185 -0
- package/dist/fire.js.map +1 -0
- package/dist/index.d.ts +31 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -13
- package/dist/index.js.map +1 -1
- package/dist/internal/cross-batch.d.ts +44 -0
- package/dist/internal/cross-batch.d.ts.map +1 -0
- package/dist/internal/cross-batch.js +58 -0
- package/dist/internal/cross-batch.js.map +1 -0
- package/dist/internal/fork-ctx.d.ts +41 -0
- package/dist/internal/fork-ctx.d.ts.map +1 -0
- package/dist/internal/fork-ctx.js +47 -0
- package/dist/internal/fork-ctx.js.map +1 -0
- package/dist/internal/signal-ref.d.ts +15 -0
- package/dist/internal/signal-ref.d.ts.map +1 -0
- package/dist/internal/signal-ref.js +41 -0
- package/dist/internal/signal-ref.js.map +1 -0
- package/dist/internal/topo-saves.d.ts +8 -2
- package/dist/internal/topo-saves.d.ts.map +1 -1
- package/dist/internal/topo-saves.js +162 -23
- package/dist/internal/topo-saves.js.map +1 -1
- package/dist/internal/topo-snapshots.d.ts +57 -0
- package/dist/internal/topo-snapshots.d.ts.map +1 -0
- package/dist/internal/topo-snapshots.js +372 -0
- package/dist/internal/topo-snapshots.js.map +1 -0
- package/dist/internal/topo-store-read.d.ts +23 -20
- package/dist/internal/topo-store-read.d.ts.map +1 -1
- package/dist/internal/topo-store-read.js +82 -83
- package/dist/internal/topo-store-read.js.map +1 -1
- package/dist/internal/topo-store.d.ts +19 -5
- package/dist/internal/topo-store.d.ts.map +1 -1
- package/dist/internal/topo-store.js +174 -124
- package/dist/internal/topo-store.js.map +1 -1
- package/dist/internal/tracing.d.ts +98 -0
- package/dist/internal/tracing.d.ts.map +1 -0
- package/dist/internal/tracing.js +102 -0
- package/dist/internal/tracing.js.map +1 -0
- package/dist/internal/trails-db.d.ts +2 -2
- package/dist/internal/trails-db.d.ts.map +1 -1
- package/dist/internal/trails-db.js +8 -8
- package/dist/internal/trails-db.js.map +1 -1
- package/dist/internal/zod-wrappers.d.ts +40 -0
- package/dist/internal/zod-wrappers.d.ts.map +1 -0
- package/dist/internal/zod-wrappers.js +59 -0
- package/dist/internal/zod-wrappers.js.map +1 -0
- package/dist/layer.d.ts +22 -5
- package/dist/layer.d.ts.map +1 -1
- package/dist/layer.js +20 -3
- package/dist/layer.js.map +1 -1
- package/dist/path-security.d.ts +1 -1
- package/dist/path-security.d.ts.map +1 -1
- package/dist/path-security.js +1 -1
- package/dist/path-security.js.map +1 -1
- package/dist/resilience.d.ts +1 -1
- package/dist/resilience.d.ts.map +1 -1
- package/dist/resilience.js +3 -3
- package/dist/resilience.js.map +1 -1
- package/dist/resource-config.d.ts +22 -0
- package/dist/resource-config.d.ts.map +1 -0
- package/dist/resource-config.js +210 -0
- package/dist/resource-config.js.map +1 -0
- package/dist/resource.d.ts +74 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +61 -0
- package/dist/resource.js.map +1 -0
- package/dist/run.d.ts +4 -3
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +5 -4
- package/dist/run.js.map +1 -1
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +2 -1
- package/dist/serialization.js.map +1 -1
- package/dist/store/accessor-protocol.d.ts +51 -0
- package/dist/store/accessor-protocol.d.ts.map +1 -0
- package/dist/store/accessor-protocol.js +2 -0
- package/dist/store/accessor-protocol.js.map +1 -0
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +2 -0
- package/dist/store/index.js.map +1 -0
- package/dist/surface-filter.d.ts +13 -0
- package/dist/surface-filter.d.ts.map +1 -0
- package/dist/surface-filter.js +76 -0
- package/dist/surface-filter.js.map +1 -0
- package/dist/topo-store.d.ts +36 -22
- package/dist/topo-store.d.ts.map +1 -1
- package/dist/topo-store.js +215 -71
- package/dist/topo-store.js.map +1 -1
- package/dist/topo.d.ts +23 -9
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +175 -46
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +86 -19
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +59 -4
- package/dist/trail.js.map +1 -1
- package/dist/trails/derive-trail.d.ts +66 -0
- package/dist/trails/derive-trail.d.ts.map +1 -0
- package/dist/trails/derive-trail.js +378 -0
- package/dist/trails/derive-trail.js.map +1 -0
- package/dist/trails/index.d.ts +5 -0
- package/dist/trails/index.d.ts.map +1 -0
- package/dist/trails/index.js +3 -0
- package/dist/trails/index.js.map +1 -0
- package/dist/trails/ingest.d.ts +24 -0
- package/dist/trails/ingest.d.ts.map +1 -0
- package/dist/trails/ingest.js +63 -0
- package/dist/trails/ingest.js.map +1 -0
- package/dist/transport-error-map.d.ts +106 -0
- package/dist/transport-error-map.d.ts.map +1 -0
- package/dist/transport-error-map.js +24 -0
- package/dist/transport-error-map.js.map +1 -0
- package/dist/type-checks.test-d.d.ts +71 -0
- package/dist/type-checks.test-d.d.ts.map +1 -0
- package/dist/type-checks.test-d.js +12 -0
- package/dist/type-checks.test-d.js.map +1 -0
- package/dist/type-utils.d.ts +11 -2
- package/dist/type-utils.d.ts.map +1 -1
- package/dist/type-utils.js.map +1 -1
- package/dist/types.d.ts +113 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/validate-established-topo.js +2 -2
- package/dist/validate-established-topo.js.map +1 -1
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +24 -7
- package/dist/validate-topo.js.map +1 -1
- package/dist/workspace.d.ts +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +1 -1
- package/dist/workspace.js.map +1 -1
- package/package.json +8 -2
- package/src/__tests__/branded.test.ts +5 -5
- package/src/__tests__/context.test.ts +3 -3
- package/src/__tests__/contour.test.ts +263 -0
- package/src/__tests__/derive-trail.test.ts +668 -0
- package/src/__tests__/errors.test.ts +118 -0
- package/src/__tests__/execute.test.ts +1179 -141
- package/src/__tests__/fire.test.ts +1056 -0
- package/src/__tests__/fork-ctx.test.ts +83 -0
- package/src/__tests__/ingest.test.ts +191 -0
- package/src/__tests__/{gate.test.ts → layer.test.ts} +21 -21
- package/src/__tests__/path-security.test.ts +7 -7
- package/src/__tests__/resilience.test.ts +8 -8
- package/src/__tests__/resource.test.ts +204 -0
- package/src/__tests__/run.test.ts +13 -13
- package/src/__tests__/service-config.test.ts +28 -32
- package/src/__tests__/surface-filter.test.ts +196 -0
- package/src/__tests__/topo-store-read.test.ts +41 -36
- package/src/__tests__/topo-store.test.ts +493 -93
- package/src/__tests__/topo.test.ts +350 -54
- package/src/__tests__/trail.test.ts +189 -24
- package/src/__tests__/trails-db.test.ts +32 -25
- package/src/__tests__/transport-error-map.test.ts +82 -0
- package/src/__tests__/type-utils.test.ts +29 -1
- package/src/__tests__/validate-topo.test.ts +147 -25
- package/src/__tests__/workspace.test.ts +6 -6
- package/src/__tests__/zod-wrappers.test.ts +62 -0
- package/src/branded.ts +1 -1
- package/src/context.ts +24 -4
- package/src/contour.ts +344 -0
- package/src/cross-schema.ts +36 -0
- package/src/draft.ts +56 -52
- package/src/errors.ts +62 -18
- package/src/execute.ts +840 -29
- package/src/fire.ts +295 -0
- package/src/index.ts +116 -29
- package/src/internal/cross-batch.ts +77 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/internal/signal-ref.ts +87 -0
- package/src/internal/topo-snapshots.ts +517 -0
- package/src/internal/topo-store-read.ts +140 -132
- package/src/internal/topo-store.ts +291 -186
- package/src/internal/tracing.ts +187 -0
- package/src/internal/trails-db.ts +9 -11
- package/src/internal/zod-wrappers.ts +78 -0
- package/src/layer.ts +50 -0
- package/src/path-security.ts +1 -1
- package/src/resilience.ts +3 -3
- package/src/{provision-config.ts → resource-config.ts} +109 -123
- package/src/resource.ts +155 -0
- package/src/run.ts +5 -4
- package/src/serialization.ts +2 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/surface-filter.ts +182 -0
- package/src/topo-store.ts +313 -104
- package/src/topo.ts +328 -57
- package/src/trail.ts +199 -31
- package/src/trails/derive-trail.ts +842 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +146 -0
- package/src/transport-error-map.ts +56 -0
- package/src/type-checks.test-d.ts +106 -0
- package/src/type-utils.ts +17 -2
- package/src/types.ts +148 -11
- package/src/validate-established-topo.ts +2 -2
- package/src/validate-topo.ts +32 -8
- package/src/workspace.ts +1 -1
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/adapters.d.ts +0 -39
- package/dist/adapters.d.ts.map +0 -1
- package/dist/adapters.js +0 -2
- package/dist/adapters.js.map +0 -1
- package/dist/dispatch.d.ts +0 -27
- package/dist/dispatch.d.ts.map +0 -1
- package/dist/dispatch.js +0 -34
- package/dist/dispatch.js.map +0 -1
- package/dist/event.d.ts +0 -8
- package/dist/event.d.ts.map +0 -1
- package/dist/event.js +0 -7
- package/dist/event.js.map +0 -1
- package/dist/gate.d.ts +0 -17
- package/dist/gate.d.ts.map +0 -1
- package/dist/gate.js +0 -21
- package/dist/gate.js.map +0 -1
- package/dist/health.d.ts +0 -18
- package/dist/health.d.ts.map +0 -1
- package/dist/health.js +0 -5
- package/dist/health.js.map +0 -1
- package/dist/job.d.ts +0 -24
- package/dist/job.d.ts.map +0 -1
- package/dist/job.js +0 -17
- package/dist/job.js.map +0 -1
- package/dist/provision-config.d.ts +0 -22
- package/dist/provision-config.d.ts.map +0 -1
- package/dist/provision-config.js +0 -210
- package/dist/provision-config.js.map +0 -1
- package/dist/provision.d.ts +0 -71
- package/dist/provision.d.ts.map +0 -1
- package/dist/provision.js +0 -56
- package/dist/provision.js.map +0 -1
- package/dist/service-config.d.ts +0 -22
- package/dist/service-config.d.ts.map +0 -1
- package/dist/service-config.js +0 -210
- package/dist/service-config.js.map +0 -1
- package/dist/service.d.ts +0 -71
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -56
- package/dist/service.js.map +0 -1
- package/src/__tests__/service.test.ts +0 -197
- package/src/event.ts +0 -15
- package/src/gate.ts +0 -44
- package/src/internal/topo-saves.ts +0 -429
- 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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from '../internal/topo-
|
|
12
|
+
ensureTopoSnapshotSchema,
|
|
13
|
+
pinTopoSnapshot,
|
|
14
|
+
pruneUnpinnedSnapshots,
|
|
15
|
+
} from '../internal/topo-snapshots.js';
|
|
14
16
|
import {
|
|
17
|
+
createTopoSnapshot,
|
|
15
18
|
getStoredTopoExport,
|
|
16
|
-
|
|
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
|
-
|
|
37
|
+
snapshotId?: string
|
|
34
38
|
): number => {
|
|
35
39
|
const row =
|
|
36
|
-
|
|
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
|
|
48
|
+
`SELECT COUNT(*) as count FROM ${tableName} WHERE snapshot_id = ?`
|
|
45
49
|
)
|
|
46
|
-
.get(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
158
|
+
WHERE snapshot_id = ?
|
|
143
159
|
ORDER BY id ASC`
|
|
144
160
|
)
|
|
145
|
-
.all(
|
|
161
|
+
.all(snapshotId);
|
|
146
162
|
|
|
147
163
|
const readTrailSignalRows = (
|
|
148
164
|
db: ReturnType<typeof openWriteTrailsDb>,
|
|
149
|
-
|
|
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
|
|
171
|
+
WHERE snapshot_id = ?`
|
|
156
172
|
)
|
|
157
|
-
.all(
|
|
173
|
+
.all(snapshotId);
|
|
158
174
|
|
|
159
|
-
const
|
|
175
|
+
const readSurfaceRows = (
|
|
160
176
|
db: ReturnType<typeof openWriteTrailsDb>,
|
|
161
|
-
|
|
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
|
|
167
|
-
WHERE
|
|
182
|
+
FROM topo_surfaces
|
|
183
|
+
WHERE snapshot_id = ?
|
|
168
184
|
ORDER BY trail_id ASC`
|
|
169
185
|
)
|
|
170
|
-
.all(
|
|
186
|
+
.all(snapshotId);
|
|
171
187
|
|
|
172
188
|
const readExampleRows = (
|
|
173
189
|
db: ReturnType<typeof openWriteTrailsDb>,
|
|
174
|
-
|
|
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
|
|
205
|
+
WHERE snapshot_id = ?
|
|
190
206
|
ORDER BY ordinal ASC`
|
|
191
207
|
)
|
|
192
|
-
.all(
|
|
208
|
+
.all(snapshotId);
|
|
193
209
|
|
|
194
210
|
const readProjectedTrailIds = (
|
|
195
211
|
db: ReturnType<typeof openWriteTrailsDb>,
|
|
196
|
-
|
|
212
|
+
snapshotId: string
|
|
197
213
|
) =>
|
|
198
214
|
db
|
|
199
215
|
.query<{ id: string }, [string]>(
|
|
200
|
-
'SELECT id FROM topo_trails WHERE
|
|
216
|
+
'SELECT id FROM topo_trails WHERE snapshot_id = ? ORDER BY id ASC'
|
|
201
217
|
)
|
|
202
|
-
.all(
|
|
218
|
+
.all(snapshotId)
|
|
203
219
|
.map((row) => row.id);
|
|
204
220
|
|
|
205
221
|
const requireStoredExport = (
|
|
206
222
|
db: ReturnType<typeof openWriteTrailsDb>,
|
|
207
|
-
|
|
223
|
+
snapshotId: string
|
|
208
224
|
) => {
|
|
209
|
-
const stored = getStoredTopoExport(db,
|
|
225
|
+
const stored = getStoredTopoExport(db, snapshotId);
|
|
210
226
|
if (stored === undefined) {
|
|
211
|
-
throw new Error(`Expected stored topo export for
|
|
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
|
-
|
|
242
|
+
snapshotId: string
|
|
219
243
|
): void => {
|
|
220
|
-
expect(countRows(db, 'topo_trails',
|
|
221
|
-
expect(countRows(db, 'topo_crossings',
|
|
222
|
-
expect(countRows(db, '
|
|
223
|
-
expect(countRows(db, '
|
|
224
|
-
expect(countRows(db, 'topo_signals',
|
|
225
|
-
expect(countRows(db, 'topo_trail_signals',
|
|
226
|
-
expect(countRows(db, '
|
|
227
|
-
expect(countRows(db, 'topo_examples',
|
|
228
|
-
expect(countRows(db, 'topo_schemas',
|
|
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
|
-
|
|
258
|
+
snapshotId: string
|
|
235
259
|
): void => {
|
|
236
|
-
expect(readTrailRows(db,
|
|
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,
|
|
283
|
+
expect(readTrailSignalRows(db, snapshotId)).toEqual([
|
|
260
284
|
{ signal_id: 'entity.added', trail_id: 'entity.add' },
|
|
261
285
|
]);
|
|
262
|
-
expect(
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
527
|
+
test('projects a snapshot-scoped relational topo from the established app graph', () => {
|
|
365
528
|
withProjectionDb((db) => {
|
|
366
|
-
const
|
|
367
|
-
|
|
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,
|
|
374
|
-
expectProjectedFixtureRows(db,
|
|
536
|
+
expectProjectionCounts(db, snapshot.id);
|
|
537
|
+
expectProjectedFixtureRows(db, snapshot.id);
|
|
375
538
|
|
|
376
|
-
const stored = requireStoredExport(db,
|
|
377
|
-
|
|
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
|
-
|
|
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.
|
|
566
|
+
hash: stored.surfaceHash,
|
|
392
567
|
version: 1,
|
|
393
568
|
});
|
|
394
569
|
});
|
|
395
570
|
});
|
|
396
571
|
|
|
397
|
-
test('keeps projected rows isolated across successive
|
|
572
|
+
test('keeps projected rows isolated across successive snapshots', () => {
|
|
398
573
|
withProjectionDb((db) => {
|
|
399
|
-
const
|
|
400
|
-
|
|
574
|
+
const firstSnapshot = unwrap(
|
|
575
|
+
createTopoSnapshot(db, simpleProjectionApp(false), {
|
|
401
576
|
createdAt: '2026-04-03T12:00:00.000Z',
|
|
402
577
|
})
|
|
403
578
|
);
|
|
404
|
-
const
|
|
405
|
-
|
|
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(
|
|
411
|
-
expect(countRows(db, 'topo_trails',
|
|
412
|
-
expect(countRows(db, 'topo_trails',
|
|
413
|
-
expect(readProjectedTrailIds(db,
|
|
414
|
-
|
|
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,
|
|
419
|
-
requireStoredExport(db,
|
|
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
|
|
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
|
-
|
|
604
|
+
createTopoSnapshot(db, buildSignalPruneApp(), {
|
|
429
605
|
createdAt: '2026-04-03T12:00:00.000Z',
|
|
430
606
|
})
|
|
431
607
|
);
|
|
432
|
-
|
|
608
|
+
pinTopoSnapshot(db, { id: pinned.id, name: 'before-auth' });
|
|
433
609
|
|
|
434
610
|
const disposable = unwrap(
|
|
435
|
-
|
|
611
|
+
createTopoSnapshot(db, buildSignalPruneApp(), {
|
|
436
612
|
createdAt: '2026-04-03T12:05:00.000Z',
|
|
437
613
|
})
|
|
438
614
|
);
|
|
439
615
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
expect(
|
|
445
|
-
|
|
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('
|
|
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
|
-
|
|
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(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
+
});
|