@ontrails/core 0.2.0

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 (86) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +190 -0
  3. package/package.json +36 -0
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-derivation.ts +227 -0
  7. package/src/activation-source.ts +93 -0
  8. package/src/blob-ref.ts +90 -0
  9. package/src/branded.ts +135 -0
  10. package/src/collections.ts +99 -0
  11. package/src/compose-batch.ts +69 -0
  12. package/src/compose-schema.ts +36 -0
  13. package/src/context.ts +66 -0
  14. package/src/derive.ts +485 -0
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/entity.ts +346 -0
  19. package/src/error-rendering.ts +87 -0
  20. package/src/errors.ts +483 -0
  21. package/src/execute.ts +1577 -0
  22. package/src/fetch.ts +138 -0
  23. package/src/fire.ts +1172 -0
  24. package/src/glob.ts +81 -0
  25. package/src/guards.ts +37 -0
  26. package/src/index.ts +704 -0
  27. package/src/internal/fork-ctx.ts +69 -0
  28. package/src/layer-field-rendering.ts +193 -0
  29. package/src/layer.ts +81 -0
  30. package/src/observe.ts +361 -0
  31. package/src/path-scope.ts +66 -0
  32. package/src/path-security.ts +98 -0
  33. package/src/patterns/bulk.ts +16 -0
  34. package/src/patterns/change.ts +12 -0
  35. package/src/patterns/date-range.ts +12 -0
  36. package/src/patterns/index.ts +8 -0
  37. package/src/patterns/pagination.ts +22 -0
  38. package/src/patterns/progress.ts +13 -0
  39. package/src/patterns/sorting.ts +14 -0
  40. package/src/patterns/status.ts +11 -0
  41. package/src/patterns/timestamps.ts +12 -0
  42. package/src/permits.ts +12 -0
  43. package/src/queue.ts +163 -0
  44. package/src/redaction/index.ts +3 -0
  45. package/src/redaction/patterns.ts +50 -0
  46. package/src/redaction/redactor.ts +178 -0
  47. package/src/resilience.ts +234 -0
  48. package/src/resource-config.ts +804 -0
  49. package/src/resource.ts +194 -0
  50. package/src/result.ts +212 -0
  51. package/src/run.ts +76 -0
  52. package/src/runtime-builtins.ts +69 -0
  53. package/src/schedule-runtime.ts +689 -0
  54. package/src/schedule.ts +326 -0
  55. package/src/serialization.ts +265 -0
  56. package/src/sha256.ts +136 -0
  57. package/src/signal-diagnostics.ts +633 -0
  58. package/src/signal-ref.ts +111 -0
  59. package/src/signal.ts +104 -0
  60. package/src/store/accessor-protocol.ts +56 -0
  61. package/src/store/index.ts +4 -0
  62. package/src/structured-examples.ts +248 -0
  63. package/src/surface-derivation.ts +91 -0
  64. package/src/surface-filter.ts +101 -0
  65. package/src/surface-overlay.ts +694 -0
  66. package/src/surface-versioning.ts +42 -0
  67. package/src/topo.ts +835 -0
  68. package/src/tracing.ts +346 -0
  69. package/src/trail-id-glob.ts +15 -0
  70. package/src/trail.ts +1351 -0
  71. package/src/trails/derive-trail.ts +835 -0
  72. package/src/trails/index.ts +9 -0
  73. package/src/trails/ingest.ts +152 -0
  74. package/src/trails-db.ts +212 -0
  75. package/src/transport-error-map.ts +163 -0
  76. package/src/type-utils.ts +87 -0
  77. package/src/types.ts +300 -0
  78. package/src/validate-established-topo.ts +73 -0
  79. package/src/validate-topo.ts +725 -0
  80. package/src/validation.ts +330 -0
  81. package/src/version-marker.ts +716 -0
  82. package/src/version-resolution.ts +308 -0
  83. package/src/version-runtime.ts +120 -0
  84. package/src/webhook.ts +461 -0
  85. package/src/workspace.ts +244 -0
  86. package/src/zod-wrappers.ts +72 -0
@@ -0,0 +1,835 @@
1
+ import { z } from 'zod';
2
+
3
+ import type { AnyEntity } from '../entity.js';
4
+ import {
5
+ DerivationError,
6
+ InternalError,
7
+ isTrailsError,
8
+ NotFoundError,
9
+ } from '../errors.js';
10
+ import { stripDefaultsFromShape } from '../zod-wrappers.js';
11
+ import type { AnyResource } from '../resource.js';
12
+ import { Result } from '../result.js';
13
+ import type { StoreAccessorProtocol } from '../store/accessor-protocol.js';
14
+ import { trail } from '../trail.js';
15
+ import type { Trail, TrailExample, TrailSpec } from '../trail.js';
16
+ import type { Implementation, TrailContext } from '../types.js';
17
+
18
+ /**
19
+ * CRUD-shaped operations the base trail derivation helper understands.
20
+ */
21
+ export type DeriveTrailOperation =
22
+ | 'create'
23
+ | 'read'
24
+ | 'update'
25
+ | 'delete'
26
+ | 'list';
27
+
28
+ type EntityInput<TEntity extends AnyEntity> = z.input<TEntity>;
29
+ type EntityOutput<TEntity extends AnyEntity> = z.output<TEntity>;
30
+ type EntityFieldKey<TEntity extends AnyEntity> = Extract<
31
+ keyof EntityOutput<TEntity>,
32
+ string
33
+ >;
34
+ type IdentityKey<TEntity extends AnyEntity> = Extract<
35
+ TEntity['identity'],
36
+ keyof EntityInput<TEntity> & string
37
+ >;
38
+
39
+ type GeneratedKey<
40
+ TEntity extends AnyEntity,
41
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined,
42
+ > = TGenerated extends readonly EntityFieldKey<TEntity>[]
43
+ ? TGenerated[number]
44
+ : never;
45
+
46
+ type CreateInputOf<
47
+ TEntity extends AnyEntity,
48
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined,
49
+ > = Omit<
50
+ EntityInput<TEntity>,
51
+ Extract<GeneratedKey<TEntity, TGenerated>, keyof EntityInput<TEntity>>
52
+ >;
53
+
54
+ type ReadInputOf<TEntity extends AnyEntity> = Pick<
55
+ EntityInput<TEntity>,
56
+ IdentityKey<TEntity>
57
+ >;
58
+
59
+ type UpdateInputOf<
60
+ TEntity extends AnyEntity,
61
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined,
62
+ > = ReadInputOf<TEntity> &
63
+ Partial<Omit<CreateInputOf<TEntity, TGenerated>, IdentityKey<TEntity>>>;
64
+
65
+ type ListInputOf<TEntity extends AnyEntity> = Partial<EntityInput<TEntity>>;
66
+
67
+ /**
68
+ * Input shape derived for one operation against one entity.
69
+ */
70
+ export type DeriveTrailInput<
71
+ TEntity extends AnyEntity,
72
+ TOperation extends DeriveTrailOperation,
73
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined =
74
+ | readonly EntityFieldKey<TEntity>[]
75
+ | undefined,
76
+ > = TOperation extends 'create'
77
+ ? CreateInputOf<TEntity, TGenerated>
78
+ : TOperation extends 'read' | 'delete'
79
+ ? ReadInputOf<TEntity>
80
+ : TOperation extends 'update'
81
+ ? UpdateInputOf<TEntity, TGenerated>
82
+ : ListInputOf<TEntity>;
83
+
84
+ /**
85
+ * Output shape derived for one operation against one entity.
86
+ */
87
+ export type DeriveTrailOutput<
88
+ TEntity extends AnyEntity,
89
+ TOperation extends DeriveTrailOperation,
90
+ > = TOperation extends 'delete'
91
+ ? undefined
92
+ : TOperation extends 'list'
93
+ ? EntityOutput<TEntity>[]
94
+ : EntityOutput<TEntity>;
95
+
96
+ /**
97
+ * Extra authored data accepted by `deriveTrail()` in addition to the
98
+ * operation-derived contract pieces.
99
+ *
100
+ * `implementation` is optional for single-resource calls: when omitted, the helper
101
+ * synthesizes a default implementation that delegates to the resource's accessor via
102
+ * the structural {@link StoreAccessorProtocol}. When multiple resources are
103
+ * declared, an explicit `implementation` is required.
104
+ */
105
+ export interface DeriveTrailSpec<
106
+ TEntity extends AnyEntity,
107
+ TOperation extends DeriveTrailOperation,
108
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined =
109
+ | readonly EntityFieldKey<TEntity>[]
110
+ | undefined,
111
+ > extends Omit<
112
+ TrailSpec<
113
+ DeriveTrailInput<TEntity, TOperation, TGenerated>,
114
+ DeriveTrailOutput<TEntity, TOperation>
115
+ >,
116
+ | 'implementation'
117
+ | 'entities'
118
+ | 'examples'
119
+ | 'input'
120
+ | 'intent'
121
+ | 'output'
122
+ | 'resources'
123
+ > {
124
+ /**
125
+ * Implementation of the trail. Optional for single-resource calls: when
126
+ * omitted, the helper derives a default implementation from the resource accessor
127
+ * for standard CRUD operations.
128
+ */
129
+ readonly implementation?: Implementation<
130
+ DeriveTrailInput<TEntity, TOperation, TGenerated>,
131
+ DeriveTrailOutput<TEntity, TOperation>
132
+ >;
133
+ /**
134
+ * Server-managed fields that should not be writable through derived create
135
+ * and update inputs.
136
+ */
137
+ readonly generated?: TGenerated;
138
+ /**
139
+ * Resource dependency declared on the derived trail. Pass a single
140
+ * resource for default-implementation synthesis, or an array for multi-resource
141
+ * trails that must provide an explicit `implementation`.
142
+ */
143
+ readonly resource: AnyResource | readonly AnyResource[];
144
+ }
145
+
146
+ const operationIntent = {
147
+ create: 'write',
148
+ delete: 'destroy',
149
+ list: 'read',
150
+ read: 'read',
151
+ update: 'write',
152
+ } as const;
153
+
154
+ const describeDeriveTrailResourceDeclaration = (
155
+ resourceCount: number
156
+ ): string =>
157
+ resourceCount === 0
158
+ ? 'no resources are declared'
159
+ : 'multiple resources are declared';
160
+
161
+ const titleCase = (value: string): string =>
162
+ value.length === 0 ? value : value.slice(0, 1).toUpperCase() + value.slice(1);
163
+
164
+ const uniqueStrings = (
165
+ values: readonly string[] | undefined
166
+ ): readonly string[] =>
167
+ Object.freeze([...(values === undefined ? [] : new Set(values))]);
168
+
169
+ const buildFieldMask = (fields: readonly string[]): Record<string, true> =>
170
+ Object.fromEntries(fields.map((field) => [field, true] as const)) as Record<
171
+ string,
172
+ true
173
+ >;
174
+
175
+ type AnyObjectSchema = z.ZodObject<Record<string, z.ZodType>>;
176
+
177
+ const asObjectSchema = (schema: z.ZodType): AnyObjectSchema =>
178
+ schema as unknown as AnyObjectSchema;
179
+
180
+ const unsupportedOperation = (operation: never): never => {
181
+ throw new DerivationError(
182
+ `Unsupported deriveTrail() operation: ${String(operation)}`
183
+ );
184
+ };
185
+
186
+ const omitFields = (
187
+ schema: z.ZodType,
188
+ fields: readonly string[]
189
+ ): AnyObjectSchema => {
190
+ const objectSchema = asObjectSchema(schema);
191
+
192
+ return fields.length === 0
193
+ ? objectSchema
194
+ : (objectSchema.omit(buildFieldMask(fields)) as unknown as AnyObjectSchema);
195
+ };
196
+
197
+ const pickFields = (
198
+ schema: z.ZodType,
199
+ fields: readonly string[]
200
+ ): AnyObjectSchema =>
201
+ asObjectSchema(schema).pick(
202
+ buildFieldMask(fields)
203
+ ) as unknown as AnyObjectSchema;
204
+
205
+ const toPartialSchema = (schema: z.ZodType): AnyObjectSchema =>
206
+ asObjectSchema(schema)
207
+ .extend(stripDefaultsFromShape(schema))
208
+ .partial() as unknown as AnyObjectSchema;
209
+
210
+ const normalizeResources = (
211
+ resource: AnyResource | readonly AnyResource[]
212
+ ): readonly AnyResource[] =>
213
+ Object.freeze(Array.isArray(resource) ? [...resource] : [resource]);
214
+
215
+ const identityInputSchema = <TEntity extends AnyEntity>(
216
+ entity: TEntity
217
+ ): z.ZodType<ReadInputOf<TEntity>> =>
218
+ pickFields(entity, [entity.identity]) as unknown as z.ZodType<
219
+ ReadInputOf<TEntity>
220
+ >;
221
+
222
+ const createInputSchema = <
223
+ TEntity extends AnyEntity,
224
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined,
225
+ >(
226
+ entity: TEntity,
227
+ generated: readonly string[]
228
+ ): z.ZodType<CreateInputOf<TEntity, TGenerated>> =>
229
+ omitFields(entity, generated) as unknown as z.ZodType<
230
+ CreateInputOf<TEntity, TGenerated>
231
+ >;
232
+
233
+ const updateInputSchema = <
234
+ TEntity extends AnyEntity,
235
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined,
236
+ >(
237
+ entity: TEntity,
238
+ generated: readonly string[]
239
+ ): z.ZodType<UpdateInputOf<TEntity, TGenerated>> => {
240
+ const mutableSchema = omitFields(entity, [...generated, entity.identity]);
241
+ const identitySchema = asObjectSchema(identityInputSchema(entity));
242
+
243
+ return identitySchema.extend(
244
+ toPartialSchema(mutableSchema).shape
245
+ ) as unknown as z.ZodType<UpdateInputOf<TEntity, TGenerated>>;
246
+ };
247
+
248
+ const listInputSchema = <TEntity extends AnyEntity>(
249
+ entity: TEntity
250
+ ): z.ZodType<ListInputOf<TEntity>> =>
251
+ toPartialSchema(entity) as unknown as z.ZodType<ListInputOf<TEntity>>;
252
+
253
+ const deriveInputSchema = <
254
+ TEntity extends AnyEntity,
255
+ TOperation extends DeriveTrailOperation,
256
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined,
257
+ >(
258
+ entity: TEntity,
259
+ operation: TOperation,
260
+ generated: readonly string[]
261
+ ): z.ZodType<DeriveTrailInput<TEntity, TOperation, TGenerated>> => {
262
+ switch (operation) {
263
+ case 'create': {
264
+ return createInputSchema<TEntity, TGenerated>(
265
+ entity,
266
+ generated
267
+ ) as z.ZodType<DeriveTrailInput<TEntity, TOperation, TGenerated>>;
268
+ }
269
+ case 'read':
270
+ case 'delete': {
271
+ return identityInputSchema(entity) as z.ZodType<
272
+ DeriveTrailInput<TEntity, TOperation, TGenerated>
273
+ >;
274
+ }
275
+ case 'update': {
276
+ return updateInputSchema<TEntity, TGenerated>(
277
+ entity,
278
+ generated
279
+ ) as z.ZodType<DeriveTrailInput<TEntity, TOperation, TGenerated>>;
280
+ }
281
+ case 'list': {
282
+ return listInputSchema(entity) as z.ZodType<
283
+ DeriveTrailInput<TEntity, TOperation, TGenerated>
284
+ >;
285
+ }
286
+ default: {
287
+ return unsupportedOperation(operation);
288
+ }
289
+ }
290
+ };
291
+
292
+ const deriveOutputSchema = <
293
+ TEntity extends AnyEntity,
294
+ TOperation extends DeriveTrailOperation,
295
+ >(
296
+ entity: TEntity,
297
+ operation: TOperation
298
+ ): z.ZodType<DeriveTrailOutput<TEntity, TOperation>> => {
299
+ switch (operation) {
300
+ case 'delete': {
301
+ return z.void() as unknown as z.ZodType<
302
+ DeriveTrailOutput<TEntity, TOperation>
303
+ >;
304
+ }
305
+ case 'list': {
306
+ return entity.array() as unknown as z.ZodType<
307
+ DeriveTrailOutput<TEntity, TOperation>
308
+ >;
309
+ }
310
+ case 'create':
311
+ case 'read':
312
+ case 'update': {
313
+ return entity as unknown as z.ZodType<
314
+ DeriveTrailOutput<TEntity, TOperation>
315
+ >;
316
+ }
317
+ default: {
318
+ return unsupportedOperation(operation);
319
+ }
320
+ }
321
+ };
322
+
323
+ type ExampleRecord = Readonly<Record<string, unknown>>;
324
+
325
+ const pickValueFields = (
326
+ example: ExampleRecord,
327
+ fields: readonly string[]
328
+ ): Record<string, unknown> =>
329
+ Object.fromEntries(
330
+ fields.flatMap((field) =>
331
+ Object.hasOwn(example, field) ? [[field, example[field]]] : []
332
+ )
333
+ );
334
+
335
+ const omitValueFields = (
336
+ example: ExampleRecord,
337
+ fields: readonly string[]
338
+ ): Record<string, unknown> => {
339
+ const omitted = new Set(fields);
340
+
341
+ return Object.fromEntries(
342
+ Object.entries(example).filter(([field]) => !omitted.has(field))
343
+ );
344
+ };
345
+
346
+ const formatExampleName = (
347
+ entity: AnyEntity,
348
+ operation: DeriveTrailOperation,
349
+ example: ExampleRecord,
350
+ index: number
351
+ ): string => {
352
+ const identifier = example[entity.identity];
353
+ const suffix =
354
+ identifier === undefined ? String(index + 1) : String(identifier);
355
+ return `${titleCase(operation)} ${entity.name} ${suffix}`;
356
+ };
357
+
358
+ /**
359
+ * Derive a single trail example from a entity fixture.
360
+ *
361
+ * @remarks
362
+ * For `list` operations, each derived example wraps a single fixture in an
363
+ * array (`expected: [example]`) and uses the fixture's identity as input
364
+ * filters. This means the expected output is always a one-element array,
365
+ * which may not match the real accessor behavior when multiple fixtures
366
+ * share the same filter. A custom `implementation` with hand-authored examples is
367
+ * required for multi-result list assertions.
368
+ */
369
+ const deriveExample = (
370
+ entity: AnyEntity,
371
+ operation: DeriveTrailOperation,
372
+ example: ExampleRecord,
373
+ index: number,
374
+ generated: readonly string[]
375
+ ): TrailExample<unknown, unknown> => {
376
+ const name = formatExampleName(entity, operation, example, index);
377
+ const identity = pickValueFields(example, [entity.identity]);
378
+
379
+ switch (operation) {
380
+ case 'create': {
381
+ return {
382
+ expected: example,
383
+ input: omitValueFields(example, generated),
384
+ name,
385
+ };
386
+ }
387
+ case 'read': {
388
+ return {
389
+ expected: example,
390
+ input: identity,
391
+ name,
392
+ };
393
+ }
394
+ case 'update': {
395
+ return {
396
+ expected: example,
397
+ input: {
398
+ ...omitValueFields(example, [...generated, entity.identity]),
399
+ ...identity,
400
+ },
401
+ name,
402
+ };
403
+ }
404
+ case 'delete': {
405
+ return {
406
+ input: identity,
407
+ name,
408
+ };
409
+ }
410
+ case 'list': {
411
+ return {
412
+ expected: [example],
413
+ input: {},
414
+ name,
415
+ };
416
+ }
417
+ default: {
418
+ return unsupportedOperation(operation);
419
+ }
420
+ }
421
+ };
422
+
423
+ const deriveExamples = (
424
+ entity: AnyEntity,
425
+ operation: DeriveTrailOperation,
426
+ generated: readonly string[]
427
+ ): readonly TrailExample<unknown, unknown>[] | undefined => {
428
+ if (entity.examples === undefined || entity.examples.length === 0) {
429
+ return undefined;
430
+ }
431
+
432
+ // List examples use a single example with all fixtures in expected,
433
+ // since input: {} returns the full set from a seeded mock. Expected
434
+ // values keep generated fields (createdAt, etc.) because the mock
435
+ // populates them — stripping them would fail output schema validation.
436
+ if (operation === 'list') {
437
+ return Object.freeze([
438
+ {
439
+ expected: entity.examples,
440
+ input: {},
441
+ name: `${entity.name} list example`,
442
+ },
443
+ ]);
444
+ }
445
+
446
+ return Object.freeze(
447
+ entity.examples.map((example, index) =>
448
+ deriveExample(
449
+ entity,
450
+ operation,
451
+ example as ExampleRecord,
452
+ index,
453
+ generated
454
+ )
455
+ )
456
+ );
457
+ };
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Default-implementation synthesis
461
+ // ---------------------------------------------------------------------------
462
+
463
+ type GenericAccessor = StoreAccessorProtocol<
464
+ unknown,
465
+ unknown,
466
+ unknown,
467
+ unknown
468
+ >;
469
+
470
+ const wrapUnexpected = (
471
+ entityName: string,
472
+ operation: DeriveTrailOperation,
473
+ error: unknown
474
+ ): Error => {
475
+ if (isTrailsError(error)) {
476
+ return error;
477
+ }
478
+ const cause = error instanceof Error ? error : new Error(String(error));
479
+ return new InternalError(
480
+ `deriveTrail("${entityName}.${operation}") synthesized implementation failed: ${cause.message}`,
481
+ { cause }
482
+ );
483
+ };
484
+
485
+ const notFoundError = (entityName: string, id: unknown): NotFoundError =>
486
+ new NotFoundError(
487
+ `deriveTrail("${entityName}"): entity "${String(id)}" not found`
488
+ );
489
+
490
+ const resolveAccessor = (
491
+ entity: AnyEntity,
492
+ operation: DeriveTrailOperation,
493
+ resource: AnyResource,
494
+ ctx: TrailContext
495
+ ): GenericAccessor | Error => {
496
+ try {
497
+ const connection = resource.from(ctx) as
498
+ | Readonly<Record<string, GenericAccessor>>
499
+ | undefined;
500
+ if (connection === undefined || connection === null) {
501
+ return new InternalError(
502
+ `deriveTrail("${entity.name}.${operation}"): resource "${resource.id}" produced no connection`
503
+ );
504
+ }
505
+ const accessor = connection[entity.name];
506
+ if (accessor === undefined) {
507
+ return new InternalError(
508
+ `deriveTrail("${entity.name}.${operation}"): resource "${resource.id}" does not expose an accessor for "${entity.name}"`
509
+ );
510
+ }
511
+ return accessor;
512
+ } catch (error) {
513
+ return wrapUnexpected(entity.name, operation, error);
514
+ }
515
+ };
516
+
517
+ const extractIdentity = (entity: AnyEntity, input: unknown): unknown => {
518
+ const record = input as Record<string, unknown>;
519
+ return record[entity.identity];
520
+ };
521
+
522
+ const callRead = async (
523
+ entity: AnyEntity,
524
+ accessor: GenericAccessor,
525
+ input: unknown
526
+ ): Promise<Result<unknown, Error>> => {
527
+ if (typeof accessor.get !== 'function') {
528
+ return Result.err(
529
+ new InternalError(
530
+ `deriveTrail("${entity.name}.read"): accessor is missing a \`get\` method`
531
+ )
532
+ );
533
+ }
534
+ try {
535
+ const id = extractIdentity(entity, input);
536
+ const foundEntity = await accessor.get(id);
537
+ if (foundEntity === null || foundEntity === undefined) {
538
+ return Result.err(notFoundError(entity.name, id));
539
+ }
540
+ return Result.ok(foundEntity);
541
+ } catch (error) {
542
+ return Result.err(wrapUnexpected(entity.name, 'read', error));
543
+ }
544
+ };
545
+
546
+ const callCreate = async (
547
+ entity: AnyEntity,
548
+ accessor: GenericAccessor,
549
+ input: unknown,
550
+ ctx: TrailContext
551
+ ): Promise<Result<unknown, Error>> => {
552
+ try {
553
+ if (typeof accessor.insert === 'function') {
554
+ const created = await accessor.insert(input);
555
+ return Result.ok(created);
556
+ }
557
+
558
+ // Fallback: tabular contract allows `upsert` when `insert` is absent.
559
+ // The warden flags this at build time via a pattern rule (trl-251).
560
+ if (typeof accessor.upsert !== 'function') {
561
+ return Result.err(
562
+ new InternalError(
563
+ `deriveTrail("${entity.name}.create"): accessor is missing both \`insert\` and \`upsert\``
564
+ )
565
+ );
566
+ }
567
+ ctx.logger?.debug(
568
+ `deriveTrail("${entity.name}.create"): accessor has no \`insert\`; falling back to \`upsert\``
569
+ );
570
+ const created = await accessor.upsert(input);
571
+ return Result.ok(created);
572
+ } catch (error) {
573
+ return Result.err(wrapUnexpected(entity.name, 'create', error));
574
+ }
575
+ };
576
+
577
+ /**
578
+ * Strip framework-managed generated fields from a merged payload so that
579
+ * the update-via-upsert fallback doesn't carry stale managed values.
580
+ *
581
+ * Only strips fields that appear in the `generated` array — user-defined
582
+ * fields with the same name (e.g. an API `version` string) are preserved.
583
+ */
584
+ const stripGeneratedFields = (
585
+ payload: Record<string, unknown>,
586
+ generated: readonly string[],
587
+ identity: string
588
+ ): Record<string, unknown> => {
589
+ if (generated.length === 0) {
590
+ return payload;
591
+ }
592
+ const managedKeys = new Set(generated);
593
+ return Object.fromEntries(
594
+ Object.entries(payload).filter(
595
+ ([key]) => key === identity || !managedKeys.has(key)
596
+ )
597
+ );
598
+ };
599
+
600
+ /**
601
+ * Fallback for accessors that lack a native `update`: read the current
602
+ * entity, merge the patch, strip any `version` field so versioned tables
603
+ * keep `update`'s "does not participate in optimistic concurrency" semantic,
604
+ * then `upsert`.
605
+ */
606
+ const updateViaReadAndUpsert = async (
607
+ entity: AnyEntity,
608
+ accessor: GenericAccessor,
609
+ id: unknown,
610
+ patch: Record<string, unknown>,
611
+ generated: readonly string[]
612
+ ): Promise<Result<unknown, Error>> => {
613
+ if (typeof accessor.get !== 'function') {
614
+ return Result.err(
615
+ new InternalError(
616
+ `deriveTrail("${entity.name}.update"): accessor is missing both \`update\` and \`get\``
617
+ )
618
+ );
619
+ }
620
+ if (typeof accessor.upsert !== 'function') {
621
+ return Result.err(
622
+ new InternalError(
623
+ `deriveTrail("${entity.name}.update"): accessor is missing both \`update\` and \`upsert\``
624
+ )
625
+ );
626
+ }
627
+ const current = await accessor.get(id);
628
+ if (current === null || current === undefined) {
629
+ return Result.err(notFoundError(entity.name, id));
630
+ }
631
+ const merged = stripGeneratedFields(
632
+ { ...(current as Record<string, unknown>), ...patch },
633
+ generated,
634
+ entity.identity
635
+ );
636
+ const updated = await accessor.upsert(merged);
637
+ return Result.ok(updated);
638
+ };
639
+
640
+ const callUpdate = async (
641
+ entity: AnyEntity,
642
+ accessor: GenericAccessor,
643
+ input: unknown,
644
+ generated: readonly string[]
645
+ ): Promise<Result<unknown, Error>> => {
646
+ const id = extractIdentity(entity, input);
647
+ const patch = Object.fromEntries(
648
+ Object.entries(input as Record<string, unknown>).filter(
649
+ ([field]) => field !== entity.identity
650
+ )
651
+ );
652
+
653
+ try {
654
+ if (typeof accessor.update === 'function') {
655
+ const updated = await accessor.update(id, patch);
656
+ if (updated === null || updated === undefined) {
657
+ return Result.err(notFoundError(entity.name, id));
658
+ }
659
+ return Result.ok(updated);
660
+ }
661
+ return await updateViaReadAndUpsert(entity, accessor, id, patch, generated);
662
+ } catch (error) {
663
+ return Result.err(wrapUnexpected(entity.name, 'update', error));
664
+ }
665
+ };
666
+
667
+ const callDelete = async (
668
+ entity: AnyEntity,
669
+ accessor: GenericAccessor,
670
+ input: unknown
671
+ ): Promise<Result<undefined, Error>> => {
672
+ if (typeof accessor.remove !== 'function') {
673
+ return Result.err(
674
+ new InternalError(
675
+ `deriveTrail("${entity.name}.delete"): accessor is missing a \`remove\` method`
676
+ )
677
+ );
678
+ }
679
+ try {
680
+ const id = extractIdentity(entity, input);
681
+ await accessor.remove(id);
682
+ // `{ deleted: false }` is a no-op on an absent row, not an error —
683
+ // matches the accessor's documented semantic.
684
+ return Result.ok();
685
+ } catch (error) {
686
+ return Result.err(wrapUnexpected(entity.name, 'delete', error));
687
+ }
688
+ };
689
+
690
+ /**
691
+ * Default `list` synthesis passes the entire input as the filter bag. The
692
+ * derived input type is `Partial<EntityInput>` which matches the accessor's
693
+ * filter shape field-for-field. Pagination controls are not derived — callers
694
+ * that need pagination must provide an explicit implementation.
695
+ */
696
+ const callList = async (
697
+ entity: AnyEntity,
698
+ accessor: GenericAccessor,
699
+ input: unknown
700
+ ): Promise<Result<unknown[], Error>> => {
701
+ if (typeof accessor.list !== 'function') {
702
+ return Result.err(
703
+ new InternalError(
704
+ `deriveTrail("${entity.name}.list"): accessor is missing a \`list\` method`
705
+ )
706
+ );
707
+ }
708
+ try {
709
+ const listed = await accessor.list(input);
710
+ return Result.ok([...listed]);
711
+ } catch (error) {
712
+ return Result.err(wrapUnexpected(entity.name, 'list', error));
713
+ }
714
+ };
715
+
716
+ const synthesizeDefaultImplementation = <
717
+ TEntity extends AnyEntity,
718
+ TOperation extends DeriveTrailOperation,
719
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined,
720
+ >(
721
+ entity: TEntity,
722
+ operation: TOperation,
723
+ resource: AnyResource,
724
+ generated: readonly string[]
725
+ ): Implementation<
726
+ DeriveTrailInput<TEntity, TOperation, TGenerated>,
727
+ DeriveTrailOutput<TEntity, TOperation>
728
+ > => {
729
+ const impl: Implementation<unknown, unknown> = (input, ctx) => {
730
+ const accessor = resolveAccessor(entity, operation, resource, ctx);
731
+ if (accessor instanceof Error) {
732
+ return Promise.resolve(Result.err(accessor));
733
+ }
734
+ switch (operation) {
735
+ case 'create': {
736
+ return callCreate(entity, accessor, input, ctx);
737
+ }
738
+ case 'read': {
739
+ return callRead(entity, accessor, input);
740
+ }
741
+ case 'update': {
742
+ return callUpdate(entity, accessor, input, generated);
743
+ }
744
+ case 'delete': {
745
+ return callDelete(entity, accessor, input);
746
+ }
747
+ case 'list': {
748
+ return callList(entity, accessor, input);
749
+ }
750
+ default: {
751
+ return unsupportedOperation(operation);
752
+ }
753
+ }
754
+ };
755
+
756
+ return impl as Implementation<
757
+ DeriveTrailInput<TEntity, TOperation, TGenerated>,
758
+ DeriveTrailOutput<TEntity, TOperation>
759
+ >;
760
+ };
761
+
762
+ /**
763
+ * Mechanically derive one CRUD-shaped trail from a entity declaration.
764
+ *
765
+ * When `spec.implementation` is omitted and the call declares a single resource, the
766
+ * helper derives a default implementation that dispatches to the resource accessor
767
+ * through the structural {@link StoreAccessorProtocol}. Multi-resource calls
768
+ * must supply an explicit implementation and are rejected with {@link DerivationError}
769
+ * at construction time when they do not.
770
+ */
771
+ export const deriveTrail = <
772
+ TEntity extends AnyEntity,
773
+ TOperation extends DeriveTrailOperation,
774
+ TGenerated extends readonly EntityFieldKey<TEntity>[] | undefined =
775
+ | readonly EntityFieldKey<TEntity>[]
776
+ | undefined,
777
+ >(
778
+ entity: TEntity,
779
+ operation: TOperation,
780
+ spec: DeriveTrailSpec<TEntity, TOperation, TGenerated>
781
+ ): Trail<
782
+ DeriveTrailInput<TEntity, TOperation, TGenerated>,
783
+ DeriveTrailOutput<TEntity, TOperation>
784
+ > => {
785
+ const resources = normalizeResources(spec.resource);
786
+ const generated = uniqueStrings(
787
+ spec.generated as readonly string[] | undefined
788
+ );
789
+
790
+ let implementation: Implementation<
791
+ DeriveTrailInput<TEntity, TOperation, TGenerated>,
792
+ DeriveTrailOutput<TEntity, TOperation>
793
+ >;
794
+ if (typeof spec.implementation === 'function') {
795
+ ({ implementation } = spec);
796
+ } else if (resources.length === 1) {
797
+ implementation = synthesizeDefaultImplementation<
798
+ TEntity,
799
+ TOperation,
800
+ TGenerated
801
+ >(entity, operation, resources[0] as AnyResource, generated);
802
+ } else {
803
+ throw new DerivationError(
804
+ `deriveTrail("${entity.name}.${operation}") requires an explicit \`implementation\` when ${describeDeriveTrailResourceDeclaration(resources.length)} — default synthesis is single-resource only`
805
+ );
806
+ }
807
+ const {
808
+ implementation: _implementation,
809
+ resource: _resource,
810
+ generated: _generated,
811
+ ...trailSpec
812
+ } = spec;
813
+ const derivedSpec = {
814
+ ...trailSpec,
815
+ entities: [entity],
816
+ examples: deriveExamples(entity, operation, generated),
817
+ implementation,
818
+ input: deriveInputSchema<TEntity, TOperation, TGenerated>(
819
+ entity,
820
+ operation,
821
+ generated
822
+ ),
823
+ intent: operationIntent[operation],
824
+ output: deriveOutputSchema(entity, operation),
825
+ resources,
826
+ } as unknown as TrailSpec<
827
+ DeriveTrailInput<TEntity, TOperation, TGenerated>,
828
+ DeriveTrailOutput<TEntity, TOperation>
829
+ >;
830
+
831
+ return trail(`${entity.name}.${operation}`, derivedSpec) as unknown as Trail<
832
+ DeriveTrailInput<TEntity, TOperation, TGenerated>,
833
+ DeriveTrailOutput<TEntity, TOperation>
834
+ >;
835
+ };