@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.
Files changed (139) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +65 -35
  3. package/README.md +21 -20
  4. package/dist/context.d.ts +1 -1
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +5 -4
  7. package/dist/context.js.map +1 -1
  8. package/dist/derive.d.ts +14 -3
  9. package/dist/derive.d.ts.map +1 -1
  10. package/dist/derive.js +36 -12
  11. package/dist/derive.js.map +1 -1
  12. package/dist/draft.d.ts +28 -0
  13. package/dist/draft.d.ts.map +1 -0
  14. package/dist/draft.js +156 -0
  15. package/dist/draft.js.map +1 -0
  16. package/dist/event.d.ts +4 -41
  17. package/dist/event.d.ts.map +1 -1
  18. package/dist/event.js +4 -14
  19. package/dist/event.js.map +1 -1
  20. package/dist/execute.d.ts +10 -10
  21. package/dist/execute.d.ts.map +1 -1
  22. package/dist/execute.js +35 -28
  23. package/dist/execute.js.map +1 -1
  24. package/dist/gate.d.ts +17 -0
  25. package/dist/gate.d.ts.map +1 -0
  26. package/dist/gate.js +21 -0
  27. package/dist/gate.js.map +1 -0
  28. package/dist/index.d.ts +16 -9
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +14 -9
  31. package/dist/index.js.map +1 -1
  32. package/dist/internal/topo-saves.d.ts +47 -0
  33. package/dist/internal/topo-saves.d.ts.map +1 -0
  34. package/dist/internal/topo-saves.js +310 -0
  35. package/dist/internal/topo-saves.js.map +1 -0
  36. package/dist/internal/topo-store-read.d.ts +67 -0
  37. package/dist/internal/topo-store-read.d.ts.map +1 -0
  38. package/dist/internal/topo-store-read.js +222 -0
  39. package/dist/internal/topo-store-read.js.map +1 -0
  40. package/dist/internal/topo-store.d.ts +12 -0
  41. package/dist/internal/topo-store.d.ts.map +1 -0
  42. package/dist/internal/topo-store.js +571 -0
  43. package/dist/internal/topo-store.js.map +1 -0
  44. package/dist/internal/trails-db.d.ts +16 -0
  45. package/dist/internal/trails-db.d.ts.map +1 -0
  46. package/dist/internal/trails-db.js +118 -0
  47. package/dist/internal/trails-db.js.map +1 -0
  48. package/dist/layer.d.ts +5 -16
  49. package/dist/layer.d.ts.map +1 -1
  50. package/dist/layer.js +3 -20
  51. package/dist/layer.js.map +1 -1
  52. package/dist/provision-config.d.ts +22 -0
  53. package/dist/provision-config.d.ts.map +1 -0
  54. package/dist/provision-config.js +210 -0
  55. package/dist/provision-config.js.map +1 -0
  56. package/dist/provision.d.ts +71 -0
  57. package/dist/provision.d.ts.map +1 -0
  58. package/dist/provision.js +56 -0
  59. package/dist/provision.js.map +1 -0
  60. package/dist/run.d.ts +27 -0
  61. package/dist/run.d.ts.map +1 -0
  62. package/dist/run.js +34 -0
  63. package/dist/run.js.map +1 -0
  64. package/dist/service-config.d.ts +7 -7
  65. package/dist/service-config.d.ts.map +1 -1
  66. package/dist/service-config.js +75 -73
  67. package/dist/service-config.js.map +1 -1
  68. package/dist/service.d.ts +32 -36
  69. package/dist/service.d.ts.map +1 -1
  70. package/dist/service.js +21 -21
  71. package/dist/service.js.map +1 -1
  72. package/dist/signal.d.ts +33 -0
  73. package/dist/signal.d.ts.map +1 -0
  74. package/dist/signal.js +17 -0
  75. package/dist/signal.js.map +1 -0
  76. package/dist/topo-store.d.ts +48 -0
  77. package/dist/topo-store.d.ts.map +1 -0
  78. package/dist/topo-store.js +175 -0
  79. package/dist/topo-store.js.map +1 -0
  80. package/dist/topo.d.ts +10 -10
  81. package/dist/topo.d.ts.map +1 -1
  82. package/dist/topo.js +48 -35
  83. package/dist/topo.js.map +1 -1
  84. package/dist/trail.d.ts +16 -16
  85. package/dist/trail.d.ts.map +1 -1
  86. package/dist/trail.js +5 -4
  87. package/dist/trail.js.map +1 -1
  88. package/dist/types.d.ts +10 -10
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js +2 -2
  91. package/dist/types.js.map +1 -1
  92. package/dist/validate-established-topo.d.ts +76 -0
  93. package/dist/validate-established-topo.d.ts.map +1 -0
  94. package/dist/validate-established-topo.js +43 -0
  95. package/dist/validate-established-topo.js.map +1 -0
  96. package/dist/validate-topo.d.ts +2 -2
  97. package/dist/validate-topo.d.ts.map +1 -1
  98. package/dist/validate-topo.js +34 -32
  99. package/dist/validate-topo.js.map +1 -1
  100. package/package.json +4 -1
  101. package/src/__tests__/context.test.ts +8 -8
  102. package/src/__tests__/derive.test.ts +58 -1
  103. package/src/__tests__/execute.test.ts +124 -122
  104. package/src/__tests__/{layer.test.ts → gate.test.ts} +26 -26
  105. package/src/__tests__/{dispatch.test.ts → run.test.ts} +39 -39
  106. package/src/__tests__/service-config.test.ts +41 -37
  107. package/src/__tests__/service.test.ts +71 -71
  108. package/src/__tests__/{event.test.ts → signal.test.ts} +15 -15
  109. package/src/__tests__/topo-store-read.test.ts +251 -0
  110. package/src/__tests__/topo-store.test.ts +469 -0
  111. package/src/__tests__/topo.test.ts +54 -54
  112. package/src/__tests__/trail-permit.test.ts +4 -4
  113. package/src/__tests__/trail.test.ts +58 -58
  114. package/src/__tests__/trails-db.test.ts +191 -0
  115. package/src/__tests__/type-utils.test.ts +3 -3
  116. package/src/__tests__/validate-topo.test.ts +205 -38
  117. package/src/context.ts +5 -4
  118. package/src/derive.ts +47 -16
  119. package/src/draft.ts +334 -0
  120. package/src/event.ts +11 -73
  121. package/src/execute.ts +56 -40
  122. package/src/{layer.ts → gate.ts} +13 -13
  123. package/src/index.ts +54 -23
  124. package/src/internal/topo-saves.ts +429 -0
  125. package/src/internal/topo-store-read.ts +473 -0
  126. package/src/internal/topo-store.ts +1087 -0
  127. package/src/internal/trails-db.ts +189 -0
  128. package/src/{service-config.ts → provision-config.ts} +124 -105
  129. package/src/provision.ts +148 -0
  130. package/src/{dispatch.ts → run.ts} +8 -8
  131. package/src/signal.ts +65 -0
  132. package/src/topo-store.ts +301 -0
  133. package/src/topo.ts +74 -52
  134. package/src/trail.ts +23 -22
  135. package/src/types.ts +11 -11
  136. package/src/validate-established-topo.ts +63 -0
  137. package/src/validate-topo.ts +38 -34
  138. package/tsconfig.tsbuildinfo +1 -1
  139. 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
- follow?: readonly string[];
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
- services?: readonly ReturnType<typeof service>[];
31
+ provisions?: readonly ReturnType<typeof provision>[];
30
32
  }
31
33
  ) => ({
32
- follow: Object.freeze([...(overrides?.follow ?? [])]),
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
- run: noop,
37
- services: Object.freeze([...(overrides?.services ?? [])]),
39
+ provisions: Object.freeze([...(overrides?.provisions ?? [])]),
38
40
  ...overrides,
39
41
  });
40
42
 
41
- const mockService = (id: string) =>
42
- service(id, {
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: 'event' as const,
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
- follow: ['entity.add'],
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 follow', () => {
81
- test('trail following non-existent trail fails', () => {
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
- follow: ['entity.missing'],
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('follow-exists');
106
+ expect(issues[0]?.rule).toBe('cross-exists');
94
107
  expect(issues[0]?.message).toContain('entity.missing');
95
108
  });
96
109
 
97
- test('trail following itself fails', () => {
110
+ test('trail crossing itself fails', () => {
98
111
  const app = topo('app', {
99
- loop: mockTrail('entity.loop', { follow: ['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-follow')).toBe(true);
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', { follow: ['b'] }),
112
- b: mockTrail('b', { follow: ['a'] }),
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 === 'follow-cycle');
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', { follow: ['b'] }),
127
- b: mockTrail('b', { follow: ['c'] }),
128
- c: mockTrail('c', { follow: ['a'] }),
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 === 'follow-cycle');
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', { follow: ['c'] }),
143
- b: mockTrail('b', { follow: ['c'] }),
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('service declarations', () => {
153
- test('trail declaring a registered service passes', () => {
154
- const db = mockService('db.main');
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
- services: [db],
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 service fails', () => {
167
- const db = mockService('db.main');
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
- services: [db],
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('service-exists');
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('event-origin-exists');
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 = mockService('db.main');
329
+ const db = mockProvision('db.main');
305
330
  const app = topo('app', {
306
- broken: mockTrail('entity.broken', { follow: ['entity.missing'] }),
331
+ broken: mockTrail('entity.broken', { crosses: ['entity.missing'] }),
307
332
  missingService: mockTrail('entity.missing-service', {
308
- services: [db],
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 { createServiceLookup } from './service.js';
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
- * - `signal` defaults to a fresh, non-aborted `AbortSignal`
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
- ctx.service = overrides?.service ?? createServiceLookup(() => ctx);
25
+ const lookup = overrides?.provision ?? createProvisionLookup(() => ctx);
26
+ ctx.provision = lookup;
26
27
  return ctx;
27
28
  };