@ontrails/core 1.0.0-beta.12 → 1.0.0-beta.14
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 +65 -35
- package/README.md +21 -20
- package/dist/context.d.ts +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +5 -4
- package/dist/context.js.map +1 -1
- package/dist/derive.d.ts +14 -3
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +36 -12
- 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 +156 -0
- package/dist/draft.js.map +1 -0
- package/dist/event.d.ts +4 -41
- package/dist/event.d.ts.map +1 -1
- package/dist/event.js +4 -14
- package/dist/event.js.map +1 -1
- package/dist/execute.d.ts +10 -10
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +35 -28
- package/dist/execute.js.map +1 -1
- package/dist/gate.d.ts +17 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +21 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +16 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -9
- package/dist/index.js.map +1 -1
- package/dist/internal/topo-saves.d.ts +47 -0
- package/dist/internal/topo-saves.d.ts.map +1 -0
- package/dist/internal/topo-saves.js +310 -0
- package/dist/internal/topo-saves.js.map +1 -0
- package/dist/internal/topo-store-read.d.ts +67 -0
- package/dist/internal/topo-store-read.d.ts.map +1 -0
- package/dist/internal/topo-store-read.js +222 -0
- package/dist/internal/topo-store-read.js.map +1 -0
- package/dist/internal/topo-store.d.ts +12 -0
- package/dist/internal/topo-store.d.ts.map +1 -0
- package/dist/internal/topo-store.js +571 -0
- package/dist/internal/topo-store.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/layer.d.ts +5 -16
- package/dist/layer.d.ts.map +1 -1
- package/dist/layer.js +3 -20
- package/dist/layer.js.map +1 -1
- package/dist/provision-config.d.ts +22 -0
- package/dist/provision-config.d.ts.map +1 -0
- package/dist/provision-config.js +210 -0
- package/dist/provision-config.js.map +1 -0
- package/dist/provision.d.ts +71 -0
- package/dist/provision.d.ts.map +1 -0
- package/dist/provision.js +56 -0
- package/dist/provision.js.map +1 -0
- package/dist/run.d.ts +27 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +34 -0
- package/dist/run.js.map +1 -0
- package/dist/service-config.d.ts +7 -7
- package/dist/service-config.d.ts.map +1 -1
- package/dist/service-config.js +75 -73
- package/dist/service-config.js.map +1 -1
- package/dist/service.d.ts +32 -36
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +21 -21
- package/dist/service.js.map +1 -1
- package/dist/signal.d.ts +33 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +17 -0
- package/dist/signal.js.map +1 -0
- package/dist/topo-store.d.ts +48 -0
- package/dist/topo-store.d.ts.map +1 -0
- package/dist/topo-store.js +175 -0
- package/dist/topo-store.js.map +1 -0
- package/dist/topo.d.ts +10 -10
- package/dist/topo.d.ts.map +1 -1
- package/dist/topo.js +48 -35
- package/dist/topo.js.map +1 -1
- package/dist/trail.d.ts +16 -16
- package/dist/trail.d.ts.map +1 -1
- package/dist/trail.js +5 -4
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +10 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- 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 +2 -2
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +34 -32
- package/dist/validate-topo.js.map +1 -1
- package/package.json +4 -1
- package/src/__tests__/context.test.ts +8 -8
- package/src/__tests__/derive.test.ts +58 -1
- package/src/__tests__/execute.test.ts +124 -122
- package/src/__tests__/{layer.test.ts → gate.test.ts} +26 -26
- package/src/__tests__/{dispatch.test.ts → run.test.ts} +39 -39
- package/src/__tests__/service-config.test.ts +41 -37
- package/src/__tests__/service.test.ts +71 -71
- package/src/__tests__/{event.test.ts → signal.test.ts} +15 -15
- package/src/__tests__/topo-store-read.test.ts +251 -0
- package/src/__tests__/topo-store.test.ts +469 -0
- package/src/__tests__/topo.test.ts +54 -54
- package/src/__tests__/trail-permit.test.ts +4 -4
- package/src/__tests__/trail.test.ts +58 -58
- package/src/__tests__/trails-db.test.ts +191 -0
- package/src/__tests__/type-utils.test.ts +3 -3
- package/src/__tests__/validate-topo.test.ts +205 -38
- package/src/context.ts +5 -4
- package/src/derive.ts +47 -16
- package/src/draft.ts +334 -0
- package/src/event.ts +11 -73
- package/src/execute.ts +56 -40
- package/src/{layer.ts → gate.ts} +13 -13
- package/src/index.ts +54 -23
- package/src/internal/topo-saves.ts +429 -0
- package/src/internal/topo-store-read.ts +473 -0
- package/src/internal/topo-store.ts +1087 -0
- package/src/internal/trails-db.ts +189 -0
- package/src/{service-config.ts → provision-config.ts} +124 -105
- package/src/provision.ts +148 -0
- package/src/{dispatch.ts → run.ts} +8 -8
- package/src/signal.ts +65 -0
- package/src/topo-store.ts +301 -0
- package/src/topo.ts +74 -52
- package/src/trail.ts +23 -22
- package/src/types.ts +11 -11
- package/src/validate-established-topo.ts +63 -0
- package/src/validate-topo.ts +38 -34
- package/tsconfig.tsbuildinfo +1 -1
- package/src/service.ts +0 -145
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ensureSubsystemSchema,
|
|
8
|
+
openReadTrailsDb,
|
|
9
|
+
openWriteTrailsDb,
|
|
10
|
+
resolveTrailsDbPath,
|
|
11
|
+
} from '../internal/trails-db.js';
|
|
12
|
+
import {
|
|
13
|
+
createTopoSave,
|
|
14
|
+
ensureTopoHistorySchema,
|
|
15
|
+
listTopoPins,
|
|
16
|
+
listTopoSaves,
|
|
17
|
+
pinTopoSave,
|
|
18
|
+
pruneUnpinnedTopoSaves,
|
|
19
|
+
} from '../internal/topo-saves.js';
|
|
20
|
+
|
|
21
|
+
describe('trails db foundation', () => {
|
|
22
|
+
let tmpRoot: string | undefined;
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
if (tmpRoot) {
|
|
26
|
+
rmSync(tmpRoot, { force: true, recursive: true });
|
|
27
|
+
tmpRoot = undefined;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const makeRoot = (): string => {
|
|
32
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'trails-db-'));
|
|
33
|
+
return tmpRoot;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const expectWorkspaceLayout = (rootDir: string): void => {
|
|
37
|
+
expect(existsSync(join(rootDir, '.trails', '.gitignore'))).toBe(true);
|
|
38
|
+
expect(existsSync(join(rootDir, '.trails', 'config'))).toBe(true);
|
|
39
|
+
expect(existsSync(join(rootDir, '.trails', 'dev'))).toBe(true);
|
|
40
|
+
expect(existsSync(join(rootDir, '.trails', 'generated'))).toBe(true);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
test('resolveTrailsDbPath places the database in .trails/trails.db', () => {
|
|
44
|
+
const rootDir = '/tmp/example-app';
|
|
45
|
+
expect(resolveTrailsDbPath({ rootDir })).toBe(
|
|
46
|
+
'/tmp/example-app/.trails/trails.db'
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('openWriteTrailsDb creates the database with WAL and NORMAL defaults', () => {
|
|
51
|
+
const rootDir = makeRoot();
|
|
52
|
+
const db = openWriteTrailsDb({ rootDir });
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
expect(existsSync(resolveTrailsDbPath({ rootDir }))).toBe(true);
|
|
56
|
+
expectWorkspaceLayout(rootDir);
|
|
57
|
+
|
|
58
|
+
const journal = db
|
|
59
|
+
.query<{ journal_mode: string }, []>('PRAGMA journal_mode')
|
|
60
|
+
.get();
|
|
61
|
+
const synchronous = db
|
|
62
|
+
.query<{ synchronous: number }, []>('PRAGMA synchronous')
|
|
63
|
+
.get();
|
|
64
|
+
|
|
65
|
+
expect(journal?.journal_mode.toLowerCase()).toBe('wal');
|
|
66
|
+
expect(synchronous?.synchronous).toBe(1);
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('openReadTrailsDb blocks writes at the SQLite connection level', () => {
|
|
73
|
+
const rootDir = makeRoot();
|
|
74
|
+
const writer = openWriteTrailsDb({ rootDir });
|
|
75
|
+
writer.close();
|
|
76
|
+
|
|
77
|
+
const reader = openReadTrailsDb({ rootDir });
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
expect(() => reader.run('CREATE TABLE readonly_probe (id TEXT)')).toThrow(
|
|
81
|
+
/readonly|read-only/i
|
|
82
|
+
);
|
|
83
|
+
} finally {
|
|
84
|
+
reader.close();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('ensureSubsystemSchema only migrates when the subsystem version changes', () => {
|
|
89
|
+
const rootDir = makeRoot();
|
|
90
|
+
const db = openWriteTrailsDb({ rootDir });
|
|
91
|
+
let calls = 0;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
ensureSubsystemSchema(db, {
|
|
95
|
+
migrate: () => {
|
|
96
|
+
calls += 1;
|
|
97
|
+
db.run(
|
|
98
|
+
'CREATE TABLE IF NOT EXISTS track_records (id TEXT PRIMARY KEY)'
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
subsystem: 'track',
|
|
102
|
+
version: 1,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ensureSubsystemSchema(db, {
|
|
106
|
+
migrate: () => {
|
|
107
|
+
calls += 1;
|
|
108
|
+
},
|
|
109
|
+
subsystem: 'track',
|
|
110
|
+
version: 1,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
ensureSubsystemSchema(db, {
|
|
114
|
+
migrate: (currentVersion) => {
|
|
115
|
+
calls += 1;
|
|
116
|
+
expect(currentVersion).toBe(1);
|
|
117
|
+
db.run('ALTER TABLE track_records ADD COLUMN status TEXT');
|
|
118
|
+
},
|
|
119
|
+
subsystem: 'track',
|
|
120
|
+
version: 2,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(calls).toBe(2);
|
|
124
|
+
} finally {
|
|
125
|
+
db.close();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('topo save primitives', () => {
|
|
131
|
+
let tmpRoot: string | undefined;
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
if (tmpRoot) {
|
|
135
|
+
rmSync(tmpRoot, { force: true, recursive: true });
|
|
136
|
+
tmpRoot = undefined;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const makeRoot = (): string => {
|
|
141
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'topo-saves-'));
|
|
142
|
+
return tmpRoot;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const seedHistory = (db: ReturnType<typeof openWriteTrailsDb>) => {
|
|
146
|
+
ensureTopoHistorySchema(db);
|
|
147
|
+
|
|
148
|
+
const pinned = createTopoSave(db, {
|
|
149
|
+
createdAt: '2026-04-01T00:00:00.000Z',
|
|
150
|
+
gitDirty: false,
|
|
151
|
+
gitSha: 'abc123',
|
|
152
|
+
provisionCount: 2,
|
|
153
|
+
signalCount: 1,
|
|
154
|
+
trailCount: 3,
|
|
155
|
+
});
|
|
156
|
+
const disposable = createTopoSave(db, {
|
|
157
|
+
createdAt: '2026-04-02T00:00:00.000Z',
|
|
158
|
+
gitDirty: true,
|
|
159
|
+
gitSha: 'def456',
|
|
160
|
+
provisionCount: 3,
|
|
161
|
+
signalCount: 2,
|
|
162
|
+
trailCount: 4,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
disposable,
|
|
167
|
+
pin: pinTopoSave(db, { name: 'before-auth', saveId: pinned.id }),
|
|
168
|
+
pinned,
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
test('creates saves, pins them, and prunes only unpinned history', () => {
|
|
173
|
+
const rootDir = makeRoot();
|
|
174
|
+
const db = openWriteTrailsDb({ rootDir });
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const { disposable, pin, pinned } = seedHistory(db);
|
|
178
|
+
|
|
179
|
+
expect(listTopoPins(db)).toEqual([pin]);
|
|
180
|
+
expect(listTopoSaves(db).map((save) => save.id)).toEqual([
|
|
181
|
+
disposable.id,
|
|
182
|
+
pinned.id,
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
expect(pruneUnpinnedTopoSaves(db, { keep: 0 })).toBe(1);
|
|
186
|
+
expect(listTopoSaves(db).map((save) => save.id)).toEqual([pinned.id]);
|
|
187
|
+
} finally {
|
|
188
|
+
db.close();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -8,14 +8,14 @@ import type { TrailInput, TrailOutput, TrailResult } from '../type-utils';
|
|
|
8
8
|
import { inputOf, outputOf } from '../type-utils';
|
|
9
9
|
|
|
10
10
|
const greetTrail = trail('greet', {
|
|
11
|
+
blaze: (input) => Result.ok({ message: `Hello, ${input.name}!` }),
|
|
11
12
|
input: z.object({ name: z.string() }),
|
|
12
13
|
output: z.object({ message: z.string() }),
|
|
13
|
-
run: (input) => Result.ok({ message: `Hello, ${input.name}!` }),
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
const noOutputTrail = trail('ping', {
|
|
17
|
+
blaze: () => Result.ok(),
|
|
17
18
|
input: z.object({}),
|
|
18
|
-
run: () => Result.ok(),
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
describe('type-utils', () => {
|
|
@@ -71,9 +71,9 @@ describe('type-utils', () => {
|
|
|
71
71
|
describe('TrailResult', () => {
|
|
72
72
|
test('extracts Result<Output, Error> from a trail', () => {
|
|
73
73
|
const t = trail('test.result', {
|
|
74
|
+
blaze: (input) => Result.ok({ answer: input.q }),
|
|
74
75
|
input: z.object({ q: z.string() }),
|
|
75
76
|
output: z.object({ answer: z.string() }),
|
|
76
|
-
run: (input) => Result.ok({ answer: input.q }),
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
type Expected = Result<{ answer: string }, Error>;
|
|
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
|
+
import { analyzeDraftState, isDraftId } from '../draft.js';
|
|
6
|
+
import { validateEstablishedTopo } from '../validate-established-topo.js';
|
|
7
|
+
import { provision } from '../provision.js';
|
|
5
8
|
import { Result } from '../result.js';
|
|
6
|
-
import { service } from '../service.js';
|
|
7
9
|
import { topo } from '../topo.js';
|
|
8
10
|
import type { TopoIssue } from '../validate-topo.js';
|
|
9
11
|
import { validateTopo } from '../validate-topo.js';
|
|
@@ -18,7 +20,7 @@ const noop = async () => Result.ok();
|
|
|
18
20
|
const mockTrail = (
|
|
19
21
|
id: string,
|
|
20
22
|
overrides?: {
|
|
21
|
-
|
|
23
|
+
crosses?: readonly string[];
|
|
22
24
|
examples?: readonly {
|
|
23
25
|
name: string;
|
|
24
26
|
input: unknown;
|
|
@@ -26,27 +28,27 @@ const mockTrail = (
|
|
|
26
28
|
error?: string;
|
|
27
29
|
}[];
|
|
28
30
|
output?: z.ZodType;
|
|
29
|
-
|
|
31
|
+
provisions?: readonly ReturnType<typeof provision>[];
|
|
30
32
|
}
|
|
31
33
|
) => ({
|
|
32
|
-
|
|
34
|
+
blaze: noop,
|
|
35
|
+
crosses: Object.freeze([...(overrides?.crosses ?? [])]),
|
|
33
36
|
id,
|
|
34
37
|
input: z.object({ name: z.string() }),
|
|
35
38
|
kind: 'trail' as const,
|
|
36
|
-
|
|
37
|
-
services: Object.freeze([...(overrides?.services ?? [])]),
|
|
39
|
+
provisions: Object.freeze([...(overrides?.provisions ?? [])]),
|
|
38
40
|
...overrides,
|
|
39
41
|
});
|
|
40
42
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
+
const mockProvision = (id: string) =>
|
|
44
|
+
provision(id, {
|
|
43
45
|
create: () => Result.ok({ id }),
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
const mockEvent = (id: string, from?: readonly string[]) => ({
|
|
47
49
|
from,
|
|
48
50
|
id,
|
|
49
|
-
kind: '
|
|
51
|
+
kind: 'signal' as const,
|
|
50
52
|
payload: z.object({ data: z.string() }),
|
|
51
53
|
});
|
|
52
54
|
|
|
@@ -68,7 +70,7 @@ describe('validateTopo', () => {
|
|
|
68
70
|
const app = topo('app', {
|
|
69
71
|
add: mockTrail('entity.add'),
|
|
70
72
|
onboard: mockTrail('entity.onboard', {
|
|
71
|
-
|
|
73
|
+
crosses: ['entity.add'],
|
|
72
74
|
}),
|
|
73
75
|
updated: mockEvent('entity.updated', ['entity.add']),
|
|
74
76
|
});
|
|
@@ -77,11 +79,22 @@ describe('validateTopo', () => {
|
|
|
77
79
|
expect(result.isOk()).toBe(true);
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
describe('trail
|
|
81
|
-
test('
|
|
82
|
+
describe('trail crossing', () => {
|
|
83
|
+
test('draft crossings are allowed in the authored graph', () => {
|
|
84
|
+
const app = topo('app', {
|
|
85
|
+
exportTrail: mockTrail('entity.export', {
|
|
86
|
+
crosses: ['_draft.entity.prepare'],
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const result = validateTopo(app);
|
|
91
|
+
expect(result.isOk()).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('trail crossing a non-existent trail fails', () => {
|
|
82
95
|
const app = topo('app', {
|
|
83
96
|
onboard: mockTrail('entity.onboard', {
|
|
84
|
-
|
|
97
|
+
crosses: ['entity.missing'],
|
|
85
98
|
}),
|
|
86
99
|
});
|
|
87
100
|
|
|
@@ -90,57 +103,57 @@ describe('validateTopo', () => {
|
|
|
90
103
|
|
|
91
104
|
const issues = extractIssues(result);
|
|
92
105
|
expect(issues).toHaveLength(1);
|
|
93
|
-
expect(issues[0]?.rule).toBe('
|
|
106
|
+
expect(issues[0]?.rule).toBe('cross-exists');
|
|
94
107
|
expect(issues[0]?.message).toContain('entity.missing');
|
|
95
108
|
});
|
|
96
109
|
|
|
97
|
-
test('trail
|
|
110
|
+
test('trail crossing itself fails', () => {
|
|
98
111
|
const app = topo('app', {
|
|
99
|
-
loop: mockTrail('entity.loop', {
|
|
112
|
+
loop: mockTrail('entity.loop', { crosses: ['entity.loop'] }),
|
|
100
113
|
});
|
|
101
114
|
|
|
102
115
|
const result = validateTopo(app);
|
|
103
116
|
expect(result.isErr()).toBe(true);
|
|
104
117
|
|
|
105
118
|
const issues = extractIssues(result);
|
|
106
|
-
expect(issues.some((i) => i.rule === 'no-self-
|
|
119
|
+
expect(issues.some((i) => i.rule === 'no-self-cross')).toBe(true);
|
|
107
120
|
});
|
|
108
121
|
|
|
109
122
|
test('two-node cycle (a→b→a) is detected', () => {
|
|
110
123
|
const app = topo('app', {
|
|
111
|
-
a: mockTrail('a', {
|
|
112
|
-
b: mockTrail('b', {
|
|
124
|
+
a: mockTrail('a', { crosses: ['b'] }),
|
|
125
|
+
b: mockTrail('b', { crosses: ['a'] }),
|
|
113
126
|
});
|
|
114
127
|
|
|
115
128
|
const result = validateTopo(app);
|
|
116
129
|
expect(result.isErr()).toBe(true);
|
|
117
130
|
|
|
118
131
|
const issues = extractIssues(result);
|
|
119
|
-
const cycleIssues = issues.filter((i) => i.rule === '
|
|
132
|
+
const cycleIssues = issues.filter((i) => i.rule === 'cross-cycle');
|
|
120
133
|
expect(cycleIssues.length).toBeGreaterThanOrEqual(1);
|
|
121
134
|
expect(cycleIssues[0]?.message).toContain('Cycle detected');
|
|
122
135
|
});
|
|
123
136
|
|
|
124
137
|
test('three-node cycle (a→b→c→a) is detected', () => {
|
|
125
138
|
const app = topo('app', {
|
|
126
|
-
a: mockTrail('a', {
|
|
127
|
-
b: mockTrail('b', {
|
|
128
|
-
c: mockTrail('c', {
|
|
139
|
+
a: mockTrail('a', { crosses: ['b'] }),
|
|
140
|
+
b: mockTrail('b', { crosses: ['c'] }),
|
|
141
|
+
c: mockTrail('c', { crosses: ['a'] }),
|
|
129
142
|
});
|
|
130
143
|
|
|
131
144
|
const result = validateTopo(app);
|
|
132
145
|
expect(result.isErr()).toBe(true);
|
|
133
146
|
|
|
134
147
|
const issues = extractIssues(result);
|
|
135
|
-
const cycleIssues = issues.filter((i) => i.rule === '
|
|
148
|
+
const cycleIssues = issues.filter((i) => i.rule === 'cross-cycle');
|
|
136
149
|
expect(cycleIssues.length).toBeGreaterThanOrEqual(1);
|
|
137
150
|
expect(cycleIssues[0]?.message).toContain('Cycle detected');
|
|
138
151
|
});
|
|
139
152
|
|
|
140
153
|
test('valid DAG with shared targets is not flagged', () => {
|
|
141
154
|
const app = topo('app', {
|
|
142
|
-
a: mockTrail('a', {
|
|
143
|
-
b: mockTrail('b', {
|
|
155
|
+
a: mockTrail('a', { crosses: ['c'] }),
|
|
156
|
+
b: mockTrail('b', { crosses: ['c'] }),
|
|
144
157
|
c: mockTrail('c'),
|
|
145
158
|
});
|
|
146
159
|
|
|
@@ -149,13 +162,25 @@ describe('validateTopo', () => {
|
|
|
149
162
|
});
|
|
150
163
|
});
|
|
151
164
|
|
|
152
|
-
describe('
|
|
153
|
-
test('
|
|
154
|
-
const db =
|
|
165
|
+
describe('provision declarations', () => {
|
|
166
|
+
test('draft provision references are allowed in the authored graph', () => {
|
|
167
|
+
const db = mockProvision('_draft.db.main');
|
|
168
|
+
const app = topo('app', {
|
|
169
|
+
show: mockTrail('entity.show', {
|
|
170
|
+
provisions: [db],
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = validateTopo(app);
|
|
175
|
+
expect(result.isOk()).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('trail declaring a registered provision passes', () => {
|
|
179
|
+
const db = mockProvision('db.main');
|
|
155
180
|
const app = topo('app', {
|
|
156
181
|
db,
|
|
157
182
|
show: mockTrail('entity.show', {
|
|
158
|
-
|
|
183
|
+
provisions: [db],
|
|
159
184
|
}),
|
|
160
185
|
});
|
|
161
186
|
|
|
@@ -163,11 +188,11 @@ describe('validateTopo', () => {
|
|
|
163
188
|
expect(result.isOk()).toBe(true);
|
|
164
189
|
});
|
|
165
190
|
|
|
166
|
-
test('trail declaring a missing
|
|
167
|
-
const db =
|
|
191
|
+
test('trail declaring a missing provision fails', () => {
|
|
192
|
+
const db = mockProvision('db.main');
|
|
168
193
|
const app = topo('app', {
|
|
169
194
|
show: mockTrail('entity.show', {
|
|
170
|
-
|
|
195
|
+
provisions: [db],
|
|
171
196
|
}),
|
|
172
197
|
});
|
|
173
198
|
|
|
@@ -176,7 +201,7 @@ describe('validateTopo', () => {
|
|
|
176
201
|
|
|
177
202
|
const issues = extractIssues(result);
|
|
178
203
|
expect(issues).toHaveLength(1);
|
|
179
|
-
expect(issues[0]?.rule).toBe('
|
|
204
|
+
expect(issues[0]?.rule).toBe('provision-exists');
|
|
180
205
|
expect(issues[0]?.message).toContain('db.main');
|
|
181
206
|
});
|
|
182
207
|
});
|
|
@@ -286,7 +311,7 @@ describe('validateTopo', () => {
|
|
|
286
311
|
|
|
287
312
|
const issues = extractIssues(result);
|
|
288
313
|
expect(issues).toHaveLength(1);
|
|
289
|
-
expect(issues[0]?.rule).toBe('
|
|
314
|
+
expect(issues[0]?.rule).toBe('signal-origin-exists');
|
|
290
315
|
expect(issues[0]?.message).toContain('entity.ghost');
|
|
291
316
|
});
|
|
292
317
|
|
|
@@ -301,11 +326,11 @@ describe('validateTopo', () => {
|
|
|
301
326
|
});
|
|
302
327
|
|
|
303
328
|
test('collects multiple issues', () => {
|
|
304
|
-
const db =
|
|
329
|
+
const db = mockProvision('db.main');
|
|
305
330
|
const app = topo('app', {
|
|
306
|
-
broken: mockTrail('entity.broken', {
|
|
331
|
+
broken: mockTrail('entity.broken', { crosses: ['entity.missing'] }),
|
|
307
332
|
missingService: mockTrail('entity.missing-service', {
|
|
308
|
-
|
|
333
|
+
provisions: [db],
|
|
309
334
|
}),
|
|
310
335
|
show: mockTrail('entity.show', {
|
|
311
336
|
examples: [{ input: { name: 123 }, name: 'Bad' }],
|
|
@@ -319,4 +344,146 @@ describe('validateTopo', () => {
|
|
|
319
344
|
const issues = extractIssues(result);
|
|
320
345
|
expect(issues).toHaveLength(4);
|
|
321
346
|
});
|
|
347
|
+
|
|
348
|
+
describe('signal origins', () => {
|
|
349
|
+
test('draft signal origins are allowed in the authored graph', () => {
|
|
350
|
+
const app = topo('app', {
|
|
351
|
+
updated: mockEvent('entity.updated', ['_draft.entity.prepare']),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const result = validateTopo(app);
|
|
355
|
+
expect(result.isOk()).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('draft state analysis', () => {
|
|
361
|
+
test('detects declared draft ids', () => {
|
|
362
|
+
const app = topo('app', {
|
|
363
|
+
draftTrail: mockTrail('_draft.entity.prepare'),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const analysis = analyzeDraftState(app);
|
|
367
|
+
|
|
368
|
+
expect(analysis.declaredDraftIds.has('_draft.entity.prepare')).toBe(true);
|
|
369
|
+
expect(analysis.contaminatedIds.has('_draft.entity.prepare')).toBe(true);
|
|
370
|
+
expect(analysis.findings.map((finding) => finding.rule)).toContain(
|
|
371
|
+
'draft-id'
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('propagates contamination through dependencies', () => {
|
|
376
|
+
const app = topo('app', {
|
|
377
|
+
exportTrail: mockTrail('entity.export', {
|
|
378
|
+
crosses: ['entity.prepare'],
|
|
379
|
+
}),
|
|
380
|
+
prepareTrail: mockTrail('entity.prepare', {
|
|
381
|
+
crosses: ['_draft.entity.store'],
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const analysis = analyzeDraftState(app);
|
|
386
|
+
const exportFinding = analysis.findings.find(
|
|
387
|
+
(finding) => finding.id === 'entity.export'
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
expect(analysis.contaminatedIds.has('entity.prepare')).toBe(true);
|
|
391
|
+
expect(analysis.contaminatedIds.has('entity.export')).toBe(true);
|
|
392
|
+
expect(exportFinding).toBeDefined();
|
|
393
|
+
expect(exportFinding?.rule).toBe('draft-contamination');
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('validateEstablishedTopo', () => {
|
|
398
|
+
test('passes for an established topo', () => {
|
|
399
|
+
const app = topo('app', {
|
|
400
|
+
show: mockTrail('entity.show'),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const result = validateEstablishedTopo(app);
|
|
404
|
+
expect(result.isOk()).toBe(true);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('fails when draft declarations remain', () => {
|
|
408
|
+
const app = topo('app', {
|
|
409
|
+
draftTrail: mockTrail('_draft.entity.prepare'),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const result = validateEstablishedTopo(app);
|
|
413
|
+
expect(result.isErr()).toBe(true);
|
|
414
|
+
|
|
415
|
+
const issues = extractIssues(result);
|
|
416
|
+
expect(issues).toHaveLength(1);
|
|
417
|
+
expect(issues[0]?.rule).toBe('draft-id');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('fails when established trails depend on draft state', () => {
|
|
421
|
+
const app = topo('app', {
|
|
422
|
+
exportTrail: mockTrail('entity.export', {
|
|
423
|
+
crosses: ['_draft.entity.prepare'],
|
|
424
|
+
}),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const result = validateEstablishedTopo(app);
|
|
428
|
+
expect(result.isErr()).toBe(true);
|
|
429
|
+
|
|
430
|
+
const issues = extractIssues(result);
|
|
431
|
+
expect(issues).toHaveLength(1);
|
|
432
|
+
expect(issues[0]?.rule).toBe('draft-contamination');
|
|
433
|
+
expect(issues[0]?.message).toContain('entity.export');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('fails when authored structural validation still fails', () => {
|
|
437
|
+
const app = topo('app', {
|
|
438
|
+
exportTrail: mockTrail('entity.export', {
|
|
439
|
+
crosses: ['entity.missing'],
|
|
440
|
+
}),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const result = validateEstablishedTopo(app);
|
|
444
|
+
expect(result.isErr()).toBe(true);
|
|
445
|
+
|
|
446
|
+
const issues = extractIssues(result);
|
|
447
|
+
expect(issues).toHaveLength(1);
|
|
448
|
+
expect(issues[0]?.rule).toBe('cross-exists');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('fails when provision declarations are not established in the topo', () => {
|
|
452
|
+
const app = topo('app', {
|
|
453
|
+
show: mockTrail('entity.show', {
|
|
454
|
+
provisions: [mockProvision('db.main')],
|
|
455
|
+
}),
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const result = validateEstablishedTopo(app);
|
|
459
|
+
expect(result.isErr()).toBe(true);
|
|
460
|
+
|
|
461
|
+
const issues = extractIssues(result);
|
|
462
|
+
expect(issues).toHaveLength(1);
|
|
463
|
+
expect(issues[0]?.rule).toBe('provision-exists');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('allows authoring-only example issues outside projection checks', () => {
|
|
467
|
+
const app = topo('app', {
|
|
468
|
+
show: mockTrail('entity.show', {
|
|
469
|
+
examples: [
|
|
470
|
+
{
|
|
471
|
+
expected: { ok: true },
|
|
472
|
+
input: { name: 'test' },
|
|
473
|
+
name: 'Has expected',
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
}),
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const result = validateEstablishedTopo(app);
|
|
480
|
+
expect(result.isOk()).toBe(true);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe('isDraftId', () => {
|
|
485
|
+
test('recognizes the reserved draft prefix', () => {
|
|
486
|
+
expect(isDraftId('_draft.entity.show')).toBe(true);
|
|
487
|
+
expect(isDraftId('entity.show')).toBe(false);
|
|
488
|
+
});
|
|
322
489
|
});
|
package/src/context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createProvisionLookup } from './provision.js';
|
|
2
2
|
import type { TrailContext, TrailContextInit } from './types.js';
|
|
3
3
|
|
|
4
4
|
type MutableTrailContext = {
|
|
@@ -9,19 +9,20 @@ type MutableTrailContext = {
|
|
|
9
9
|
* Create a TrailContext with sensible defaults.
|
|
10
10
|
*
|
|
11
11
|
* - `requestId` defaults to `Bun.randomUUIDv7()` (sortable v7 UUID)
|
|
12
|
-
* - `
|
|
12
|
+
* - `abortSignal` defaults to a fresh, non-aborted `AbortSignal`
|
|
13
13
|
* - All other fields come from `overrides`
|
|
14
14
|
*/
|
|
15
15
|
export const createTrailContext = (
|
|
16
16
|
overrides?: Partial<TrailContextInit>
|
|
17
17
|
): TrailContext => {
|
|
18
18
|
const ctx = {
|
|
19
|
+
abortSignal: new AbortController().signal,
|
|
19
20
|
cwd: process.cwd(),
|
|
20
21
|
env: process.env as Record<string, string | undefined>,
|
|
21
22
|
requestId: Bun.randomUUIDv7(),
|
|
22
|
-
signal: new AbortController().signal,
|
|
23
23
|
...overrides,
|
|
24
24
|
} as MutableTrailContext;
|
|
25
|
-
|
|
25
|
+
const lookup = overrides?.provision ?? createProvisionLookup(() => ctx);
|
|
26
|
+
ctx.provision = lookup;
|
|
26
27
|
return ctx;
|
|
27
28
|
};
|