@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.
Files changed (287) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +19 -14
  4. package/dist/branded.d.ts +1 -1
  5. package/dist/branded.d.ts.map +1 -1
  6. package/dist/branded.js +1 -1
  7. package/dist/branded.js.map +1 -1
  8. package/dist/context.d.ts +13 -1
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +19 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/contour.d.ts +81 -0
  13. package/dist/contour.d.ts.map +1 -0
  14. package/dist/contour.js +176 -0
  15. package/dist/contour.js.map +1 -0
  16. package/dist/cross-schema.d.ts +18 -0
  17. package/dist/cross-schema.d.ts.map +1 -0
  18. package/dist/cross-schema.js +29 -0
  19. package/dist/cross-schema.js.map +1 -0
  20. package/dist/derive.d.ts +6 -0
  21. package/dist/derive.d.ts.map +1 -1
  22. package/dist/derive.js +29 -5
  23. package/dist/derive.js.map +1 -1
  24. package/dist/draft.d.ts +28 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +157 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/errors.d.ts +62 -4
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +44 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/execute.d.ts +26 -9
  33. package/dist/execute.d.ts.map +1 -1
  34. package/dist/execute.js +441 -20
  35. package/dist/execute.js.map +1 -1
  36. package/dist/fire.d.ts +43 -0
  37. package/dist/fire.d.ts.map +1 -0
  38. package/dist/fire.js +185 -0
  39. package/dist/fire.js.map +1 -0
  40. package/dist/index.d.ts +33 -17
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +26 -12
  43. package/dist/index.js.map +1 -1
  44. package/dist/internal/cross-batch.d.ts +44 -0
  45. package/dist/internal/cross-batch.d.ts.map +1 -0
  46. package/dist/internal/cross-batch.js +58 -0
  47. package/dist/internal/cross-batch.js.map +1 -0
  48. package/dist/internal/fork-ctx.d.ts +41 -0
  49. package/dist/internal/fork-ctx.d.ts.map +1 -0
  50. package/dist/internal/fork-ctx.js +47 -0
  51. package/dist/internal/fork-ctx.js.map +1 -0
  52. package/dist/internal/signal-ref.d.ts +15 -0
  53. package/dist/internal/signal-ref.d.ts.map +1 -0
  54. package/dist/internal/signal-ref.js +41 -0
  55. package/dist/internal/signal-ref.js.map +1 -0
  56. package/dist/internal/topo-saves.d.ts +53 -0
  57. package/dist/internal/topo-saves.d.ts.map +1 -0
  58. package/dist/internal/topo-saves.js +449 -0
  59. package/dist/internal/topo-saves.js.map +1 -0
  60. package/dist/internal/topo-snapshots.d.ts +57 -0
  61. package/dist/internal/topo-snapshots.d.ts.map +1 -0
  62. package/dist/internal/topo-snapshots.js +372 -0
  63. package/dist/internal/topo-snapshots.js.map +1 -0
  64. package/dist/internal/topo-store-read.d.ts +70 -0
  65. package/dist/internal/topo-store-read.d.ts.map +1 -0
  66. package/dist/internal/topo-store-read.js +221 -0
  67. package/dist/internal/topo-store-read.js.map +1 -0
  68. package/dist/internal/topo-store.d.ts +26 -0
  69. package/dist/internal/topo-store.d.ts.map +1 -0
  70. package/dist/internal/topo-store.js +621 -0
  71. package/dist/internal/topo-store.js.map +1 -0
  72. package/dist/internal/tracing.d.ts +98 -0
  73. package/dist/internal/tracing.d.ts.map +1 -0
  74. package/dist/internal/tracing.js +102 -0
  75. package/dist/internal/tracing.js.map +1 -0
  76. package/dist/internal/trails-db.d.ts +16 -0
  77. package/dist/internal/trails-db.d.ts.map +1 -0
  78. package/dist/internal/trails-db.js +118 -0
  79. package/dist/internal/trails-db.js.map +1 -0
  80. package/dist/internal/zod-wrappers.d.ts +40 -0
  81. package/dist/internal/zod-wrappers.d.ts.map +1 -0
  82. package/dist/internal/zod-wrappers.js +59 -0
  83. package/dist/internal/zod-wrappers.js.map +1 -0
  84. package/dist/layer.d.ts +22 -5
  85. package/dist/layer.d.ts.map +1 -1
  86. package/dist/layer.js +20 -3
  87. package/dist/layer.js.map +1 -1
  88. package/dist/path-security.d.ts +1 -1
  89. package/dist/path-security.d.ts.map +1 -1
  90. package/dist/path-security.js +1 -1
  91. package/dist/path-security.js.map +1 -1
  92. package/dist/resilience.d.ts +1 -1
  93. package/dist/resilience.d.ts.map +1 -1
  94. package/dist/resilience.js +3 -3
  95. package/dist/resilience.js.map +1 -1
  96. package/dist/resource-config.d.ts +22 -0
  97. package/dist/resource-config.d.ts.map +1 -0
  98. package/dist/resource-config.js +210 -0
  99. package/dist/resource-config.js.map +1 -0
  100. package/dist/resource.d.ts +74 -0
  101. package/dist/resource.d.ts.map +1 -0
  102. package/dist/resource.js +61 -0
  103. package/dist/resource.js.map +1 -0
  104. package/dist/run.d.ts +4 -3
  105. package/dist/run.d.ts.map +1 -1
  106. package/dist/run.js +5 -4
  107. package/dist/run.js.map +1 -1
  108. package/dist/serialization.d.ts.map +1 -1
  109. package/dist/serialization.js +2 -1
  110. package/dist/serialization.js.map +1 -1
  111. package/dist/store/accessor-protocol.d.ts +51 -0
  112. package/dist/store/accessor-protocol.d.ts.map +1 -0
  113. package/dist/store/accessor-protocol.js +2 -0
  114. package/dist/store/accessor-protocol.js.map +1 -0
  115. package/dist/store/index.d.ts +2 -0
  116. package/dist/store/index.d.ts.map +1 -0
  117. package/dist/store/index.js +2 -0
  118. package/dist/store/index.js.map +1 -0
  119. package/dist/surface-filter.d.ts +13 -0
  120. package/dist/surface-filter.d.ts.map +1 -0
  121. package/dist/surface-filter.js +76 -0
  122. package/dist/surface-filter.js.map +1 -0
  123. package/dist/topo-store.d.ts +62 -0
  124. package/dist/topo-store.d.ts.map +1 -0
  125. package/dist/topo-store.js +319 -0
  126. package/dist/topo-store.js.map +1 -0
  127. package/dist/topo.d.ts +23 -9
  128. package/dist/topo.d.ts.map +1 -1
  129. package/dist/topo.js +175 -46
  130. package/dist/topo.js.map +1 -1
  131. package/dist/trail.d.ts +86 -19
  132. package/dist/trail.d.ts.map +1 -1
  133. package/dist/trail.js +59 -4
  134. package/dist/trail.js.map +1 -1
  135. package/dist/trails/derive-trail.d.ts +66 -0
  136. package/dist/trails/derive-trail.d.ts.map +1 -0
  137. package/dist/trails/derive-trail.js +378 -0
  138. package/dist/trails/derive-trail.js.map +1 -0
  139. package/dist/trails/index.d.ts +5 -0
  140. package/dist/trails/index.d.ts.map +1 -0
  141. package/dist/trails/index.js +3 -0
  142. package/dist/trails/index.js.map +1 -0
  143. package/dist/trails/ingest.d.ts +24 -0
  144. package/dist/trails/ingest.d.ts.map +1 -0
  145. package/dist/trails/ingest.js +63 -0
  146. package/dist/trails/ingest.js.map +1 -0
  147. package/dist/transport-error-map.d.ts +106 -0
  148. package/dist/transport-error-map.d.ts.map +1 -0
  149. package/dist/transport-error-map.js +24 -0
  150. package/dist/transport-error-map.js.map +1 -0
  151. package/dist/type-checks.test-d.d.ts +71 -0
  152. package/dist/type-checks.test-d.d.ts.map +1 -0
  153. package/dist/type-checks.test-d.js +12 -0
  154. package/dist/type-checks.test-d.js.map +1 -0
  155. package/dist/type-utils.d.ts +11 -2
  156. package/dist/type-utils.d.ts.map +1 -1
  157. package/dist/type-utils.js.map +1 -1
  158. package/dist/types.d.ts +113 -7
  159. package/dist/types.d.ts.map +1 -1
  160. package/dist/types.js.map +1 -1
  161. package/dist/validate-established-topo.d.ts +76 -0
  162. package/dist/validate-established-topo.d.ts.map +1 -0
  163. package/dist/validate-established-topo.js +43 -0
  164. package/dist/validate-established-topo.js.map +1 -0
  165. package/dist/validate-topo.d.ts.map +1 -1
  166. package/dist/validate-topo.js +27 -8
  167. package/dist/validate-topo.js.map +1 -1
  168. package/dist/workspace.d.ts +1 -1
  169. package/dist/workspace.d.ts.map +1 -1
  170. package/dist/workspace.js +1 -1
  171. package/dist/workspace.js.map +1 -1
  172. package/package.json +10 -1
  173. package/src/__tests__/branded.test.ts +5 -5
  174. package/src/__tests__/context.test.ts +3 -3
  175. package/src/__tests__/contour.test.ts +263 -0
  176. package/src/__tests__/derive-trail.test.ts +668 -0
  177. package/src/__tests__/derive.test.ts +58 -1
  178. package/src/__tests__/errors.test.ts +118 -0
  179. package/src/__tests__/execute.test.ts +1179 -141
  180. package/src/__tests__/fire.test.ts +1056 -0
  181. package/src/__tests__/fork-ctx.test.ts +83 -0
  182. package/src/__tests__/ingest.test.ts +191 -0
  183. package/src/__tests__/{gate.test.ts → layer.test.ts} +21 -21
  184. package/src/__tests__/path-security.test.ts +7 -7
  185. package/src/__tests__/resilience.test.ts +8 -8
  186. package/src/__tests__/resource.test.ts +204 -0
  187. package/src/__tests__/run.test.ts +13 -13
  188. package/src/__tests__/service-config.test.ts +28 -32
  189. package/src/__tests__/surface-filter.test.ts +196 -0
  190. package/src/__tests__/topo-store-read.test.ts +256 -0
  191. package/src/__tests__/topo-store.test.ts +869 -0
  192. package/src/__tests__/topo.test.ts +350 -54
  193. package/src/__tests__/trail.test.ts +189 -24
  194. package/src/__tests__/trails-db.test.ts +198 -0
  195. package/src/__tests__/transport-error-map.test.ts +82 -0
  196. package/src/__tests__/type-utils.test.ts +29 -1
  197. package/src/__tests__/validate-topo.test.ts +305 -16
  198. package/src/__tests__/workspace.test.ts +6 -6
  199. package/src/__tests__/zod-wrappers.test.ts +62 -0
  200. package/src/branded.ts +1 -1
  201. package/src/context.ts +24 -4
  202. package/src/contour.ts +344 -0
  203. package/src/cross-schema.ts +36 -0
  204. package/src/derive.ts +39 -8
  205. package/src/draft.ts +338 -0
  206. package/src/errors.ts +62 -18
  207. package/src/execute.ts +840 -29
  208. package/src/fire.ts +295 -0
  209. package/src/index.ts +142 -26
  210. package/src/internal/cross-batch.ts +77 -0
  211. package/src/internal/fork-ctx.ts +69 -0
  212. package/src/internal/signal-ref.ts +87 -0
  213. package/src/internal/topo-snapshots.ts +517 -0
  214. package/src/internal/topo-store-read.ts +481 -0
  215. package/src/internal/topo-store.ts +1192 -0
  216. package/src/internal/tracing.ts +187 -0
  217. package/src/internal/trails-db.ts +187 -0
  218. package/src/internal/zod-wrappers.ts +78 -0
  219. package/src/layer.ts +50 -0
  220. package/src/path-security.ts +1 -1
  221. package/src/resilience.ts +3 -3
  222. package/src/{provision-config.ts → resource-config.ts} +109 -123
  223. package/src/resource.ts +155 -0
  224. package/src/run.ts +5 -4
  225. package/src/serialization.ts +2 -0
  226. package/src/store/accessor-protocol.ts +56 -0
  227. package/src/store/index.ts +4 -0
  228. package/src/surface-filter.ts +182 -0
  229. package/src/topo-store.ts +510 -0
  230. package/src/topo.ts +328 -57
  231. package/src/trail.ts +199 -31
  232. package/src/trails/derive-trail.ts +842 -0
  233. package/src/trails/index.ts +9 -0
  234. package/src/trails/ingest.ts +146 -0
  235. package/src/transport-error-map.ts +56 -0
  236. package/src/type-checks.test-d.ts +106 -0
  237. package/src/type-utils.ts +17 -2
  238. package/src/types.ts +148 -11
  239. package/src/validate-established-topo.ts +63 -0
  240. package/src/validate-topo.ts +37 -9
  241. package/src/workspace.ts +1 -1
  242. package/tsconfig.tests.json +10 -0
  243. package/tsconfig.tsbuildinfo +1 -1
  244. package/dist/adapters.d.ts +0 -39
  245. package/dist/adapters.d.ts.map +0 -1
  246. package/dist/adapters.js +0 -2
  247. package/dist/adapters.js.map +0 -1
  248. package/dist/dispatch.d.ts +0 -27
  249. package/dist/dispatch.d.ts.map +0 -1
  250. package/dist/dispatch.js +0 -34
  251. package/dist/dispatch.js.map +0 -1
  252. package/dist/event.d.ts +0 -8
  253. package/dist/event.d.ts.map +0 -1
  254. package/dist/event.js +0 -7
  255. package/dist/event.js.map +0 -1
  256. package/dist/gate.d.ts +0 -17
  257. package/dist/gate.d.ts.map +0 -1
  258. package/dist/gate.js +0 -21
  259. package/dist/gate.js.map +0 -1
  260. package/dist/health.d.ts +0 -18
  261. package/dist/health.d.ts.map +0 -1
  262. package/dist/health.js +0 -5
  263. package/dist/health.js.map +0 -1
  264. package/dist/job.d.ts +0 -24
  265. package/dist/job.d.ts.map +0 -1
  266. package/dist/job.js +0 -17
  267. package/dist/job.js.map +0 -1
  268. package/dist/provision-config.d.ts +0 -22
  269. package/dist/provision-config.d.ts.map +0 -1
  270. package/dist/provision-config.js +0 -210
  271. package/dist/provision-config.js.map +0 -1
  272. package/dist/provision.d.ts +0 -71
  273. package/dist/provision.d.ts.map +0 -1
  274. package/dist/provision.js +0 -56
  275. package/dist/provision.js.map +0 -1
  276. package/dist/service-config.d.ts +0 -22
  277. package/dist/service-config.d.ts.map +0 -1
  278. package/dist/service-config.js +0 -210
  279. package/dist/service-config.js.map +0 -1
  280. package/dist/service.d.ts +0 -71
  281. package/dist/service.d.ts.map +0 -1
  282. package/dist/service.js +0 -56
  283. package/dist/service.js.map +0 -1
  284. package/src/__tests__/service.test.ts +0 -197
  285. package/src/event.ts +0 -15
  286. package/src/gate.ts +0 -44
  287. package/src/provision.ts +0 -148
@@ -0,0 +1,668 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+
3
+ import { z } from 'zod';
4
+
5
+ import { contour } from '../contour.js';
6
+ import { createTrailContext } from '../context.js';
7
+ import { DerivationError, InternalError, NotFoundError } from '../errors.js';
8
+ import { Result } from '../result.js';
9
+ import { resource } from '../resource.js';
10
+ import type { DeriveTrailOperation } from '../trails/index.js';
11
+ import { deriveTrail } from '../trails/index.js';
12
+
13
+ const note = contour(
14
+ 'note',
15
+ {
16
+ body: z.string().default('draft'),
17
+ createdAt: z.string(),
18
+ id: z.string(),
19
+ title: z.string(),
20
+ },
21
+ {
22
+ examples: [
23
+ {
24
+ body: 'Hello, Trails',
25
+ createdAt: '2026-04-10T12:00:00.000Z',
26
+ id: 'note-1',
27
+ title: 'First note',
28
+ },
29
+ ],
30
+ identity: 'id',
31
+ }
32
+ );
33
+
34
+ const noteResource = resource('db.notes', {
35
+ create: () =>
36
+ Result.ok({
37
+ name: 'notes',
38
+ }),
39
+ });
40
+
41
+ const noteExample = note.examples?.[0];
42
+
43
+ const createBlaze = () => Result.ok(noteExample);
44
+ const deleteBlaze = () => Result.ok();
45
+ const listBlaze = () => Result.ok([noteExample]);
46
+
47
+ const unsupportedOperation = (operation: never): never => {
48
+ throw new TypeError(`Unsupported test operation: ${String(operation)}`);
49
+ };
50
+
51
+ const deriveNoteTrail = <TOperation extends DeriveTrailOperation>(
52
+ operation: TOperation
53
+ ) => {
54
+ switch (operation) {
55
+ case 'create': {
56
+ return deriveTrail(note, operation, {
57
+ blaze: createBlaze,
58
+ generated: ['id', 'createdAt'],
59
+ resource: noteResource,
60
+ });
61
+ }
62
+ case 'read': {
63
+ return deriveTrail(note, operation, {
64
+ blaze: createBlaze,
65
+ resource: noteResource,
66
+ });
67
+ }
68
+ case 'update': {
69
+ return deriveTrail(note, operation, {
70
+ blaze: createBlaze,
71
+ generated: ['id', 'createdAt'],
72
+ resource: noteResource,
73
+ });
74
+ }
75
+ case 'delete': {
76
+ return deriveTrail(note, operation, {
77
+ blaze: deleteBlaze,
78
+ resource: noteResource,
79
+ });
80
+ }
81
+ case 'list': {
82
+ return deriveTrail(note, operation, {
83
+ blaze: listBlaze,
84
+ resource: noteResource,
85
+ });
86
+ }
87
+ default: {
88
+ return unsupportedOperation(operation);
89
+ }
90
+ }
91
+ };
92
+
93
+ const deriveCrudTrails = () => ({
94
+ created: deriveNoteTrail('create'),
95
+ listed: deriveNoteTrail('list'),
96
+ read: deriveNoteTrail('read'),
97
+ removed: deriveNoteTrail('delete'),
98
+ updated: deriveNoteTrail('update'),
99
+ });
100
+
101
+ const expectTrailMetadata = (
102
+ trail: ReturnType<typeof deriveNoteTrail>,
103
+ expectedId: string,
104
+ expectedIntent: 'destroy' | 'read' | 'write'
105
+ ) => {
106
+ expect(trail.id).toBe(expectedId);
107
+ expect(trail.intent).toBe(expectedIntent);
108
+ };
109
+
110
+ const expectCreateSchemas = (
111
+ created: ReturnType<typeof deriveNoteTrail<'create'>>
112
+ ) => {
113
+ expect(
114
+ created.input.safeParse({ body: 'Body', title: 'Created note' }).success
115
+ ).toBe(true);
116
+ expect(
117
+ created.input.parse({
118
+ createdAt: '2026-04-10T12:00:00.000Z',
119
+ id: 'note-1',
120
+ title: 'Created note',
121
+ })
122
+ ).toEqual({ body: 'draft', title: 'Created note' });
123
+ expect(created.output?.safeParse(note.examples?.[0]).success).toBe(true);
124
+ };
125
+
126
+ const expectReadSchemas = (
127
+ read: ReturnType<typeof deriveNoteTrail<'read'>>
128
+ ) => {
129
+ expect(read.input.safeParse({ id: 'note-1' }).success).toBe(true);
130
+ expect(read.input.safeParse({ title: 'Nope' }).success).toBe(false);
131
+ expect(read.output?.safeParse(note.examples?.[0]).success).toBe(true);
132
+ };
133
+
134
+ const expectUpdateSchemas = (
135
+ updated: ReturnType<typeof deriveNoteTrail<'update'>>
136
+ ) => {
137
+ expect(updated.input.safeParse({ id: 'note-1' }).success).toBe(true);
138
+ expect(
139
+ updated.input.safeParse({ body: 'Updated', id: 'note-1' }).success
140
+ ).toBe(true);
141
+ expect(
142
+ updated.input.parse({
143
+ createdAt: '2026-04-10T12:00:00.000Z',
144
+ id: 'note-1',
145
+ })
146
+ ).toEqual({ id: 'note-1' });
147
+ };
148
+
149
+ const expectDeleteSchemas = (
150
+ removed: ReturnType<typeof deriveNoteTrail<'delete'>>
151
+ ) => {
152
+ expect(removed.input.safeParse({ id: 'note-1' }).success).toBe(true);
153
+ expect(removed.output?.safeParse().success).toBe(true);
154
+ expect(removed.output?.safeParse(note.examples?.[0]).success).toBe(false);
155
+ };
156
+
157
+ const expectListSchemas = (
158
+ listed: ReturnType<typeof deriveNoteTrail<'list'>>
159
+ ) => {
160
+ expect(listed.input.safeParse({}).success).toBe(true);
161
+ expect(listed.input.safeParse({ id: 'note-1' }).success).toBe(true);
162
+ expect(listed.output?.safeParse([note.examples?.[0]]).success).toBe(true);
163
+ };
164
+
165
+ describe('deriveTrail()', () => {
166
+ test('derives trail ids, intents, contours, and resources for CRUD operations', () => {
167
+ const { created, listed, read, removed, updated } = deriveCrudTrails();
168
+
169
+ expectTrailMetadata(created, 'note.create', 'write');
170
+ expectTrailMetadata(read, 'note.read', 'read');
171
+ expectTrailMetadata(updated, 'note.update', 'write');
172
+ expectTrailMetadata(removed, 'note.delete', 'destroy');
173
+ expectTrailMetadata(listed, 'note.list', 'read');
174
+
175
+ expect(created.contours).toEqual([note]);
176
+ expect(created.resources).toEqual([noteResource]);
177
+ });
178
+
179
+ test('derives input and output schemas for each CRUD operation', () => {
180
+ const { created, listed, read, removed, updated } = deriveCrudTrails();
181
+
182
+ expectCreateSchemas(created);
183
+ expectReadSchemas(read);
184
+ expectUpdateSchemas(updated);
185
+ expectDeleteSchemas(removed);
186
+ expectListSchemas(listed);
187
+ });
188
+
189
+ test('derives per-operation examples from contour examples', () => {
190
+ const { created, listed, read, removed, updated } = deriveCrudTrails();
191
+
192
+ expect(created.examples).toEqual([
193
+ {
194
+ expected: note.examples?.[0],
195
+ input: { body: 'Hello, Trails', title: 'First note' },
196
+ name: 'Create note note-1',
197
+ },
198
+ ]);
199
+ expect(read.examples).toEqual([
200
+ {
201
+ expected: note.examples?.[0],
202
+ input: { id: 'note-1' },
203
+ name: 'Read note note-1',
204
+ },
205
+ ]);
206
+ expect(updated.examples).toEqual([
207
+ {
208
+ expected: note.examples?.[0],
209
+ input: { body: 'Hello, Trails', id: 'note-1', title: 'First note' },
210
+ name: 'Update note note-1',
211
+ },
212
+ ]);
213
+ expect(removed.examples).toEqual([
214
+ {
215
+ input: { id: 'note-1' },
216
+ name: 'Delete note note-1',
217
+ },
218
+ ]);
219
+ expect(listed.examples).toEqual([
220
+ {
221
+ expected: [note.examples?.[0]],
222
+ input: {},
223
+ name: 'note list example',
224
+ },
225
+ ]);
226
+ });
227
+
228
+ test('drops schema defaults from update and list filters', () => {
229
+ const updated = deriveNoteTrail('update');
230
+ const listed = deriveNoteTrail('list');
231
+
232
+ expect(updated.input.parse({ id: 'note-1' })).toEqual({ id: 'note-1' });
233
+ expect(listed.input.parse({})).toEqual({});
234
+ });
235
+
236
+ test('does not auto-stamp a pattern when none is declared', () => {
237
+ const created = deriveNoteTrail('create');
238
+
239
+ expect(created.pattern).toBeUndefined();
240
+ });
241
+
242
+ test('preserves a caller-declared pattern', () => {
243
+ const created = deriveTrail(note, 'create', {
244
+ blaze: createBlaze,
245
+ generated: ['id', 'createdAt'],
246
+ pattern: 'crud',
247
+ resource: noteResource,
248
+ });
249
+
250
+ expect(created.pattern).toBe('crud');
251
+ });
252
+ });
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Default-blaze synthesis
256
+ // ---------------------------------------------------------------------------
257
+
258
+ interface NoteEntity {
259
+ readonly id: string;
260
+ readonly title: string;
261
+ readonly body: string;
262
+ readonly createdAt: string;
263
+ }
264
+
265
+ interface FakeNoteAccessor {
266
+ get: (id: string) => Promise<NoteEntity | null>;
267
+ list: (filters?: Partial<NoteEntity>) => Promise<readonly NoteEntity[]>;
268
+ upsert: (input: Partial<NoteEntity>) => Promise<NoteEntity>;
269
+ remove: (id: string) => Promise<{ readonly deleted: boolean }>;
270
+ insert?: (input: Partial<NoteEntity>) => Promise<NoteEntity>;
271
+ update?: (
272
+ id: string,
273
+ patch: Partial<NoteEntity>
274
+ ) => Promise<NoteEntity | null>;
275
+ }
276
+
277
+ const cloneNote = (value: NoteEntity): NoteEntity => ({ ...value });
278
+
279
+ const seedNote: NoteEntity = {
280
+ body: 'Hello, Trails',
281
+ createdAt: '2026-04-10T12:00:00.000Z',
282
+ id: 'note-1',
283
+ title: 'First note',
284
+ };
285
+
286
+ const matchesFilters = (
287
+ row: NoteEntity,
288
+ filters: Partial<NoteEntity> | undefined
289
+ ): boolean =>
290
+ filters === undefined ||
291
+ Object.entries(filters).every(
292
+ ([field, value]) => row[field as keyof NoteEntity] === value
293
+ );
294
+
295
+ const readOneFromMap = <V>(table: Map<string, V>, id: string): V | null => {
296
+ const entry = table.get(id);
297
+ return entry === undefined ? null : entry;
298
+ };
299
+
300
+ const seedRecords = (seed: NoteEntity | null): Map<string, NoteEntity> => {
301
+ const records = new Map<string, NoteEntity>();
302
+ if (seed !== null) {
303
+ records.set(seed.id, cloneNote(seed));
304
+ }
305
+ return records;
306
+ };
307
+
308
+ const makeBaseAccessor = (
309
+ records: Map<string, NoteEntity>
310
+ ): FakeNoteAccessor => {
311
+ const writePayload = (input: Partial<NoteEntity>) => {
312
+ const payload = { ...seedNote, ...(input as NoteEntity) };
313
+ records.set(payload.id, payload);
314
+ return cloneNote(payload);
315
+ };
316
+ return {
317
+ // eslint-disable-next-line @typescript-eslint/require-await
318
+ get: async (id) => readOneFromMap(records, id),
319
+ // eslint-disable-next-line @typescript-eslint/require-await
320
+ list: async (filters) =>
321
+ [...records.values()].filter((row) => matchesFilters(row, filters)),
322
+ // eslint-disable-next-line @typescript-eslint/require-await
323
+ remove: async (id) => ({ deleted: records.delete(id) }),
324
+ // eslint-disable-next-line @typescript-eslint/require-await
325
+ upsert: async (input) => writePayload(input),
326
+ };
327
+ };
328
+
329
+ const attachInsert = (
330
+ accessor: FakeNoteAccessor,
331
+ records: Map<string, NoteEntity>
332
+ ): void => {
333
+ // eslint-disable-next-line @typescript-eslint/require-await
334
+ accessor.insert = async (input) => {
335
+ const payload = { ...seedNote, ...(input as NoteEntity) };
336
+ records.set(payload.id, payload);
337
+ return cloneNote(payload);
338
+ };
339
+ };
340
+
341
+ const attachUpdate = (
342
+ accessor: FakeNoteAccessor,
343
+ records: Map<string, NoteEntity>
344
+ ): void => {
345
+ // eslint-disable-next-line @typescript-eslint/require-await
346
+ accessor.update = async (id, patch) => {
347
+ const existing = records.get(id);
348
+ if (!existing) {
349
+ return null;
350
+ }
351
+ const merged = { ...existing, ...patch } as NoteEntity;
352
+ records.set(id, merged);
353
+ return cloneNote(merged);
354
+ };
355
+ };
356
+
357
+ const createFakeAccessor = (
358
+ options: {
359
+ insert?: boolean;
360
+ update?: boolean;
361
+ seed?: NoteEntity | null;
362
+ } = {}
363
+ ): FakeNoteAccessor => {
364
+ const seed = options.seed === undefined ? seedNote : options.seed;
365
+ const records = seedRecords(seed);
366
+ const accessor = makeBaseAccessor(records);
367
+ if (options.insert !== false) {
368
+ attachInsert(accessor, records);
369
+ }
370
+ if (options.update !== false) {
371
+ attachUpdate(accessor, records);
372
+ }
373
+ return accessor;
374
+ };
375
+
376
+ interface VersionedFake {
377
+ readonly accessor: FakeNoteAccessor;
378
+ readonly upsertLog: readonly Record<string, unknown>[];
379
+ }
380
+
381
+ const createVersionedFake = (): VersionedFake => {
382
+ const versionedSeed = {
383
+ ...seedNote,
384
+ version: 7,
385
+ } as NoteEntity & { version: number };
386
+ const records = new Map<string, NoteEntity & { version: number }>([
387
+ [versionedSeed.id, versionedSeed],
388
+ ]);
389
+ const upsertLog: Record<string, unknown>[] = [];
390
+ const accessor: FakeNoteAccessor = {
391
+ // eslint-disable-next-line @typescript-eslint/require-await
392
+ get: async (id) => readOneFromMap(records, id),
393
+ // eslint-disable-next-line @typescript-eslint/require-await
394
+ list: async () => [...records.values()],
395
+ // eslint-disable-next-line @typescript-eslint/require-await
396
+ remove: async (id) => ({ deleted: records.delete(id) }),
397
+ // eslint-disable-next-line @typescript-eslint/require-await
398
+ upsert: async (input) => {
399
+ const payload = input as Record<string, unknown>;
400
+ upsertLog.push(payload);
401
+ const merged = {
402
+ ...versionedSeed,
403
+ ...(payload as NoteEntity & { version?: number }),
404
+ };
405
+ records.set(merged.id, merged);
406
+ return cloneNote(merged);
407
+ },
408
+ };
409
+ return { accessor, upsertLog };
410
+ };
411
+
412
+ const expectOk = <T>(result: Result<T, Error>): T => {
413
+ if (result.isErr()) {
414
+ throw result.error;
415
+ }
416
+ return result.value;
417
+ };
418
+
419
+ const expectErr = <T>(result: Result<T, Error>): Error => {
420
+ if (result.isOk()) {
421
+ throw new Error(`Expected error, got ok: ${JSON.stringify(result.value)}`);
422
+ }
423
+ return result.error;
424
+ };
425
+
426
+ const makeNotesResource = (accessor: FakeNoteAccessor) =>
427
+ resource('db.notes', {
428
+ create: () => Result.ok({ note: accessor }),
429
+ });
430
+
431
+ const ctxFor = (accessor: FakeNoteAccessor) =>
432
+ createTrailContext({
433
+ extensions: {
434
+ 'db.notes': { note: accessor },
435
+ },
436
+ });
437
+
438
+ describe('deriveTrail() default blaze synthesis — create', () => {
439
+ test('synthesizes create blaze that calls accessor.insert', async () => {
440
+ const accessor = createFakeAccessor();
441
+ const notesResource = makeNotesResource(accessor);
442
+
443
+ const created = deriveTrail(note, 'create', {
444
+ generated: ['id', 'createdAt'],
445
+ resource: notesResource,
446
+ });
447
+
448
+ const result = await created.blaze(
449
+ { body: 'Bye, Trails', title: 'Second note' },
450
+ ctxFor(accessor)
451
+ );
452
+ expect(result.isOk()).toBe(true);
453
+ });
454
+
455
+ test('create falls back to upsert when insert is absent', async () => {
456
+ const accessor = createFakeAccessor({ insert: false });
457
+ const notesResource = makeNotesResource(accessor);
458
+
459
+ const created = deriveTrail(note, 'create', {
460
+ generated: ['id', 'createdAt'],
461
+ resource: notesResource,
462
+ });
463
+
464
+ const result = await created.blaze(
465
+ { body: 'Upsert body', title: 'Upsert note' },
466
+ ctxFor(accessor)
467
+ );
468
+ expect(result.isOk()).toBe(true);
469
+ });
470
+
471
+ test('custom blaze wins over default synthesis', async () => {
472
+ const accessor = createFakeAccessor();
473
+ const notesResource = makeNotesResource(accessor);
474
+ // eslint-disable-next-line @typescript-eslint/require-await
475
+ const customBlaze = mock(async () => Result.ok(seedNote));
476
+
477
+ const created = deriveTrail(note, 'create', {
478
+ blaze: customBlaze,
479
+ generated: ['id', 'createdAt'],
480
+ resource: notesResource,
481
+ });
482
+
483
+ await created.blaze(
484
+ { body: 'ignored', title: 'ignored' },
485
+ ctxFor(accessor)
486
+ );
487
+ expect(customBlaze).toHaveBeenCalledTimes(1);
488
+ });
489
+ });
490
+
491
+ describe('deriveTrail() default blaze synthesis — read', () => {
492
+ test('synthesizes read blaze that returns entity', async () => {
493
+ const accessor = createFakeAccessor();
494
+ const notesResource = makeNotesResource(accessor);
495
+
496
+ const read = deriveTrail(note, 'read', { resource: notesResource });
497
+ const result = await read.blaze({ id: 'note-1' }, ctxFor(accessor));
498
+ const value = expectOk(result);
499
+ expect(value.id).toBe('note-1');
500
+ });
501
+
502
+ test('read returns InternalError when accessor has no get', async () => {
503
+ const accessor = createFakeAccessor();
504
+ const broken = {
505
+ ...accessor,
506
+ get: undefined,
507
+ } as unknown as FakeNoteAccessor;
508
+ const notesResource = makeNotesResource(broken);
509
+
510
+ const read = deriveTrail(note, 'read', { resource: notesResource });
511
+ const result = await read.blaze({ id: 'note-1' }, ctxFor(broken));
512
+ expect(expectErr(result)).toBeInstanceOf(InternalError);
513
+ });
514
+
515
+ test('read returns Result.err when resource lookup throws', async () => {
516
+ const accessor = createFakeAccessor();
517
+ const notesResource = makeNotesResource(accessor);
518
+
519
+ const read = deriveTrail(note, 'read', { resource: notesResource });
520
+ const result = await read.blaze(
521
+ { id: 'note-1' },
522
+ createTrailContext({ extensions: {} })
523
+ );
524
+
525
+ expect(expectErr(result)).toBeInstanceOf(InternalError);
526
+ });
527
+ });
528
+
529
+ describe('deriveTrail() default blaze synthesis — update', () => {
530
+ test('update uses accessor.update when present', async () => {
531
+ const accessor = createFakeAccessor();
532
+ const notesResource = makeNotesResource(accessor);
533
+
534
+ const updated = deriveTrail(note, 'update', {
535
+ generated: ['id', 'createdAt'],
536
+ resource: notesResource,
537
+ });
538
+
539
+ const result = await updated.blaze(
540
+ { id: 'note-1', title: 'Renamed' },
541
+ ctxFor(accessor)
542
+ );
543
+ const value = expectOk(result);
544
+ expect(value.title).toBe('Renamed');
545
+ });
546
+
547
+ test('update returns NotFoundError when accessor.update returns null', async () => {
548
+ const accessor = createFakeAccessor({ seed: null });
549
+ const notesResource = makeNotesResource(accessor);
550
+
551
+ const updated = deriveTrail(note, 'update', {
552
+ generated: ['id', 'createdAt'],
553
+ resource: notesResource,
554
+ });
555
+
556
+ const result = await updated.blaze(
557
+ { id: 'missing', title: 'Nope' },
558
+ ctxFor(accessor)
559
+ );
560
+ expect(expectErr(result)).toBeInstanceOf(NotFoundError);
561
+ });
562
+
563
+ test('update fallback read-null returns NotFoundError', async () => {
564
+ const accessor = createFakeAccessor({ seed: null, update: false });
565
+ const notesResource = makeNotesResource(accessor);
566
+
567
+ const updated = deriveTrail(note, 'update', {
568
+ generated: ['id', 'createdAt'],
569
+ resource: notesResource,
570
+ });
571
+
572
+ const result = await updated.blaze(
573
+ { id: 'missing', title: 'Nope' },
574
+ ctxFor(accessor)
575
+ );
576
+ expect(expectErr(result)).toBeInstanceOf(NotFoundError);
577
+ });
578
+
579
+ test('update fallback reads, merges, and upserts when update method is absent', async () => {
580
+ const accessor = createFakeAccessor({ update: false });
581
+ const upsertSpy = mock(accessor.upsert);
582
+ accessor.upsert = upsertSpy;
583
+ const notesResource = makeNotesResource(accessor);
584
+
585
+ const updated = deriveTrail(note, 'update', {
586
+ generated: ['id', 'createdAt'],
587
+ resource: notesResource,
588
+ });
589
+
590
+ const result = await updated.blaze(
591
+ { id: 'note-1', title: 'Via fallback' },
592
+ ctxFor(accessor)
593
+ );
594
+ expect(result.isOk()).toBe(true);
595
+ expect(upsertSpy).toHaveBeenCalledTimes(1);
596
+ });
597
+
598
+ test('update fallback strips generated fields from merged payload', async () => {
599
+ const { accessor, upsertLog } = createVersionedFake();
600
+
601
+ const updated = deriveTrail(note, 'update', {
602
+ generated: ['id', 'createdAt'],
603
+ resource: makeNotesResource(accessor),
604
+ });
605
+
606
+ const result = await updated.blaze(
607
+ { id: 'note-1', title: 'No version' },
608
+ ctxFor(accessor)
609
+ );
610
+ expectOk(result);
611
+ expect(upsertLog.length).toBe(1);
612
+ const keys = Object.keys(upsertLog[0] as Record<string, unknown>);
613
+ // Identity preserved even though it's in generated; non-identity generated stripped
614
+ expect(keys).toContain('id');
615
+ expect(keys).not.toContain('createdAt');
616
+ expect(keys).toContain('version');
617
+ });
618
+ });
619
+
620
+ describe('deriveTrail() default blaze synthesis — delete and list', () => {
621
+ test('synthesizes delete blaze returning Result.ok(undefined)', async () => {
622
+ const accessor = createFakeAccessor();
623
+ const notesResource = makeNotesResource(accessor);
624
+
625
+ const removed = deriveTrail(note, 'delete', { resource: notesResource });
626
+ const result = await removed.blaze({ id: 'note-1' }, ctxFor(accessor));
627
+
628
+ expect(expectOk(result)).toBeUndefined();
629
+ });
630
+
631
+ test('synthesizes list blaze returning array', async () => {
632
+ const accessor = createFakeAccessor();
633
+ const notesResource = makeNotesResource(accessor);
634
+
635
+ const listed = deriveTrail(note, 'list', { resource: notesResource });
636
+ const result = await listed.blaze({}, ctxFor(accessor));
637
+
638
+ const value = expectOk(result);
639
+ expect(Array.isArray(value)).toBe(true);
640
+ expect(value.length).toBe(1);
641
+ });
642
+ });
643
+
644
+ describe('deriveTrail() multi-resource and invalid input', () => {
645
+ test('rejects multi-resource derivation without a blaze', () => {
646
+ const accessor = createFakeAccessor();
647
+ const a = makeNotesResource(accessor);
648
+ const b = makeNotesResource(accessor);
649
+
650
+ expect(() =>
651
+ deriveTrail(note, 'create', {
652
+ generated: ['id', 'createdAt'],
653
+ resource: [a, b],
654
+ })
655
+ ).toThrow(DerivationError);
656
+ });
657
+
658
+ test('rejects derivation with empty resource array and no blaze', () => {
659
+ expect(() =>
660
+ deriveTrail(note, 'create', {
661
+ generated: ['id', 'createdAt'],
662
+ resource: [],
663
+ })
664
+ ).toThrow(
665
+ 'deriveTrail("note.create") requires an explicit `blaze` when no resources are declared'
666
+ );
667
+ });
668
+ });