@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +19 -14
- 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/derive.d.ts +6 -0
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +29 -5
- package/dist/derive.js.map +1 -1
- package/dist/draft.d.ts +28 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +157 -0
- package/dist/draft.js.map +1 -0
- 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 +33 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -12
- 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 +53 -0
- package/dist/internal/topo-saves.d.ts.map +1 -0
- package/dist/internal/topo-saves.js +449 -0
- package/dist/internal/topo-saves.js.map +1 -0
- 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 +70 -0
- package/dist/internal/topo-store-read.d.ts.map +1 -0
- package/dist/internal/topo-store-read.js +221 -0
- package/dist/internal/topo-store-read.js.map +1 -0
- package/dist/internal/topo-store.d.ts +26 -0
- package/dist/internal/topo-store.d.ts.map +1 -0
- package/dist/internal/topo-store.js +621 -0
- package/dist/internal/topo-store.js.map +1 -0
- 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 +16 -0
- package/dist/internal/trails-db.d.ts.map +1 -0
- package/dist/internal/trails-db.js +118 -0
- package/dist/internal/trails-db.js.map +1 -0
- 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 +62 -0
- package/dist/topo-store.d.ts.map +1 -0
- package/dist/topo-store.js +319 -0
- package/dist/topo-store.js.map +1 -0
- 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.d.ts +76 -0
- package/dist/validate-established-topo.d.ts.map +1 -0
- package/dist/validate-established-topo.js +43 -0
- package/dist/validate-established-topo.js.map +1 -0
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +27 -8
- 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 +10 -1
- 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__/derive.test.ts +58 -1
- 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 +256 -0
- package/src/__tests__/topo-store.test.ts +869 -0
- package/src/__tests__/topo.test.ts +350 -54
- package/src/__tests__/trail.test.ts +189 -24
- package/src/__tests__/trails-db.test.ts +198 -0
- 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 +305 -16
- 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/derive.ts +39 -8
- package/src/draft.ts +338 -0
- package/src/errors.ts +62 -18
- package/src/execute.ts +840 -29
- package/src/fire.ts +295 -0
- package/src/index.ts +142 -26
- 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 +481 -0
- package/src/internal/topo-store.ts +1192 -0
- package/src/internal/tracing.ts +187 -0
- package/src/internal/trails-db.ts +187 -0
- 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 +510 -0
- 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 +63 -0
- package/src/validate-topo.ts +37 -9
- 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/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
|
+
});
|