@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.
- package/CHANGELOG.md +849 -0
- package/README.md +190 -0
- package/package.json +36 -0
- package/src/activation-provenance.ts +116 -0
- package/src/activation-source-compatibility.ts +430 -0
- package/src/activation-source-derivation.ts +227 -0
- package/src/activation-source.ts +93 -0
- package/src/blob-ref.ts +90 -0
- package/src/branded.ts +135 -0
- package/src/collections.ts +99 -0
- package/src/compose-batch.ts +69 -0
- package/src/compose-schema.ts +36 -0
- package/src/context.ts +66 -0
- package/src/derive.ts +485 -0
- package/src/detours.ts +8 -0
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +350 -0
- package/src/entity.ts +346 -0
- package/src/error-rendering.ts +87 -0
- package/src/errors.ts +483 -0
- package/src/execute.ts +1577 -0
- package/src/fetch.ts +138 -0
- package/src/fire.ts +1172 -0
- package/src/glob.ts +81 -0
- package/src/guards.ts +37 -0
- package/src/index.ts +704 -0
- package/src/internal/fork-ctx.ts +69 -0
- package/src/layer-field-rendering.ts +193 -0
- package/src/layer.ts +81 -0
- package/src/observe.ts +361 -0
- package/src/path-scope.ts +66 -0
- package/src/path-security.ts +98 -0
- package/src/patterns/bulk.ts +16 -0
- package/src/patterns/change.ts +12 -0
- package/src/patterns/date-range.ts +12 -0
- package/src/patterns/index.ts +8 -0
- package/src/patterns/pagination.ts +22 -0
- package/src/patterns/progress.ts +13 -0
- package/src/patterns/sorting.ts +14 -0
- package/src/patterns/status.ts +11 -0
- package/src/patterns/timestamps.ts +12 -0
- package/src/permits.ts +12 -0
- package/src/queue.ts +163 -0
- package/src/redaction/index.ts +3 -0
- package/src/redaction/patterns.ts +50 -0
- package/src/redaction/redactor.ts +178 -0
- package/src/resilience.ts +234 -0
- package/src/resource-config.ts +804 -0
- package/src/resource.ts +194 -0
- package/src/result.ts +212 -0
- package/src/run.ts +76 -0
- package/src/runtime-builtins.ts +69 -0
- package/src/schedule-runtime.ts +689 -0
- package/src/schedule.ts +326 -0
- package/src/serialization.ts +265 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +633 -0
- package/src/signal-ref.ts +111 -0
- package/src/signal.ts +104 -0
- package/src/store/accessor-protocol.ts +56 -0
- package/src/store/index.ts +4 -0
- package/src/structured-examples.ts +248 -0
- package/src/surface-derivation.ts +91 -0
- package/src/surface-filter.ts +101 -0
- package/src/surface-overlay.ts +694 -0
- package/src/surface-versioning.ts +42 -0
- package/src/topo.ts +835 -0
- package/src/tracing.ts +346 -0
- package/src/trail-id-glob.ts +15 -0
- package/src/trail.ts +1351 -0
- package/src/trails/derive-trail.ts +835 -0
- package/src/trails/index.ts +9 -0
- package/src/trails/ingest.ts +152 -0
- package/src/trails-db.ts +212 -0
- package/src/transport-error-map.ts +163 -0
- package/src/type-utils.ts +87 -0
- package/src/types.ts +300 -0
- package/src/validate-established-topo.ts +73 -0
- package/src/validate-topo.ts +725 -0
- package/src/validation.ts +330 -0
- package/src/version-marker.ts +716 -0
- package/src/version-resolution.ts +308 -0
- package/src/version-runtime.ts +120 -0
- package/src/webhook.ts +461 -0
- package/src/workspace.ts +244 -0
- 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
|
+
};
|