@ontrails/store 1.0.0-beta.14 → 1.0.0-beta.16

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 (57) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +144 -63
  3. package/package.json +14 -9
  4. package/src/adapter-support.ts +163 -0
  5. package/src/crud-doctrine.ts +43 -0
  6. package/src/index.ts +17 -8
  7. package/src/jsonfile/index.ts +6 -0
  8. package/src/jsonfile/runtime.ts +700 -0
  9. package/src/jsonfile/types.ts +50 -0
  10. package/src/store.ts +298 -137
  11. package/src/testing.ts +175 -0
  12. package/src/trails/crud.ts +356 -0
  13. package/src/trails/index.ts +15 -0
  14. package/src/trails/reconcile.ts +281 -0
  15. package/src/trails/sync.ts +251 -0
  16. package/src/trails/utils.ts +96 -0
  17. package/src/types.ts +361 -68
  18. package/.agents/notes/2026-04-04/handoff-202604032309-9e85a104.md +0 -38
  19. package/.turbo/turbo-build.log +0 -1
  20. package/.turbo/turbo-lint.log +0 -3
  21. package/.turbo/turbo-typecheck.log +0 -1
  22. package/dist/drizzle/index.d.ts +0 -3
  23. package/dist/drizzle/index.d.ts.map +0 -1
  24. package/dist/drizzle/index.js +0 -2
  25. package/dist/drizzle/index.js.map +0 -1
  26. package/dist/drizzle/runtime.d.ts +0 -21
  27. package/dist/drizzle/runtime.d.ts.map +0 -1
  28. package/dist/drizzle/runtime.js +0 -458
  29. package/dist/drizzle/runtime.js.map +0 -1
  30. package/dist/drizzle/schema.d.ts +0 -15
  31. package/dist/drizzle/schema.d.ts.map +0 -1
  32. package/dist/drizzle/schema.js +0 -322
  33. package/dist/drizzle/schema.js.map +0 -1
  34. package/dist/drizzle/types.d.ts +0 -40
  35. package/dist/drizzle/types.d.ts.map +0 -1
  36. package/dist/drizzle/types.js +0 -2
  37. package/dist/drizzle/types.js.map +0 -1
  38. package/dist/index.d.ts +0 -3
  39. package/dist/index.d.ts.map +0 -1
  40. package/dist/index.js +0 -2
  41. package/dist/index.js.map +0 -1
  42. package/dist/store.d.ts +0 -26
  43. package/dist/store.d.ts.map +0 -1
  44. package/dist/store.js +0 -192
  45. package/dist/store.js.map +0 -1
  46. package/dist/types.d.ts +0 -224
  47. package/dist/types.d.ts.map +0 -1
  48. package/dist/types.js +0 -2
  49. package/dist/types.js.map +0 -1
  50. package/src/__tests__/store.test.ts +0 -333
  51. package/src/drizzle/__tests__/drizzle.test.ts +0 -469
  52. package/src/drizzle/index.ts +0 -17
  53. package/src/drizzle/runtime.ts +0 -853
  54. package/src/drizzle/schema.ts +0 -577
  55. package/src/drizzle/types.ts +0 -70
  56. package/tsconfig.json +0 -9
  57. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,281 @@
1
+ import { ConflictError, Result, ValidationError, trail } from '@ontrails/core';
2
+ import type {
3
+ AnySignal,
4
+ Detour,
5
+ Resource,
6
+ Trail,
7
+ TrailContext,
8
+ TrailExample,
9
+ TrailsError,
10
+ } from '@ontrails/core';
11
+ import { z } from 'zod';
12
+
13
+ import type {
14
+ AnyStoreTable,
15
+ EntityOf,
16
+ StoreAccessor,
17
+ UpsertOf,
18
+ } from '../types.js';
19
+ import { versionFieldName } from '../store.js';
20
+ import { createTableContour, mapStoreTrailError } from './utils.js';
21
+
22
+ type ReconcileConnection<TTable extends AnyStoreTable> = Readonly<
23
+ Record<TTable['name'], StoreAccessor<TTable>>
24
+ >;
25
+
26
+ export interface ReconcileConflict<TTable extends AnyStoreTable> {
27
+ readonly current: EntityOf<TTable>;
28
+ readonly incoming: UpsertOf<TTable>;
29
+ }
30
+
31
+ export type ReconcileStrategy<TTable extends AnyStoreTable> =
32
+ | 'last-write-wins'
33
+ | ((
34
+ conflict: ReconcileConflict<TTable>,
35
+ ctx: TrailContext
36
+ ) => Promise<UpsertOf<TTable>> | UpsertOf<TTable>);
37
+
38
+ export interface ReconcileOptions<
39
+ TTable extends AnyStoreTable,
40
+ TConnection extends ReconcileConnection<TTable>,
41
+ > {
42
+ readonly description?: string;
43
+ readonly id?: string;
44
+ readonly on?: readonly (AnySignal | string)[];
45
+ readonly resource: Resource<TConnection>;
46
+ readonly strategy?: ReconcileStrategy<TTable>;
47
+ readonly table: TTable;
48
+ }
49
+
50
+ const resolveAccessor = <
51
+ TTable extends AnyStoreTable,
52
+ TConnection extends ReconcileConnection<TTable>,
53
+ >(
54
+ table: TTable,
55
+ resource: Resource<TConnection>,
56
+ ctx: TrailContext
57
+ ): StoreAccessor<TTable> => {
58
+ const connection = resource.from(ctx);
59
+ return connection[table.name as keyof TConnection] as StoreAccessor<TTable>;
60
+ };
61
+
62
+ const omitUndefined = <T extends Record<string, unknown>>(value: T): T =>
63
+ Object.fromEntries(
64
+ Object.entries(value).filter(([, candidate]) => candidate !== undefined)
65
+ ) as T;
66
+
67
+ const currentVersion = <TTable extends AnyStoreTable>(
68
+ current: EntityOf<TTable>
69
+ ): number => current[versionFieldName as keyof EntityOf<TTable>] as number;
70
+
71
+ const lastWriteWins = <TTable extends AnyStoreTable>(
72
+ conflict: ReconcileConflict<TTable>
73
+ ): UpsertOf<TTable> =>
74
+ ({
75
+ ...conflict.current,
76
+ ...omitUndefined(conflict.incoming as Record<string, unknown>),
77
+ [versionFieldName]: currentVersion(conflict.current),
78
+ }) as UpsertOf<TTable>;
79
+
80
+ const normalizeResolvedInput = <TTable extends AnyStoreTable>(
81
+ table: TTable,
82
+ current: EntityOf<TTable>,
83
+ resolved: UpsertOf<TTable>
84
+ ): UpsertOf<TTable> =>
85
+ ({
86
+ ...current,
87
+ ...omitUndefined(resolved as Record<string, unknown>),
88
+ [table.identity]: current[
89
+ table.identity as keyof EntityOf<TTable>
90
+ ] as EntityOf<TTable>[keyof EntityOf<TTable>],
91
+ [versionFieldName]: currentVersion(current),
92
+ }) as UpsertOf<TTable>;
93
+
94
+ const deriveExamples = <TTable extends AnyStoreTable>(
95
+ table: TTable
96
+ ): readonly TrailExample<UpsertOf<TTable>, EntityOf<TTable>>[] | undefined => {
97
+ const examples = table.fixtures.flatMap((fixture) => {
98
+ const parsed = table.schema.safeParse(fixture);
99
+ if (!parsed.success) {
100
+ return [];
101
+ }
102
+
103
+ return [
104
+ {
105
+ expected: parsed.data as EntityOf<TTable>,
106
+ input: fixture as UpsertOf<TTable>,
107
+ name: `Reconcile ${table.name} ${String(
108
+ fixture[table.identity as keyof typeof fixture]
109
+ )}`,
110
+ },
111
+ ];
112
+ });
113
+
114
+ return examples.length === 0 ? undefined : Object.freeze(examples);
115
+ };
116
+
117
+ const buildConflict = async <TTable extends AnyStoreTable>(
118
+ table: TTable,
119
+ input: UpsertOf<TTable>,
120
+ accessor: StoreAccessor<TTable>,
121
+ error: ConflictError
122
+ ): Promise<ConflictError | ReconcileConflict<TTable>> => {
123
+ const identifier = input[table.identity as keyof typeof input] as
124
+ | EntityOf<TTable>[keyof EntityOf<TTable>]
125
+ | undefined;
126
+
127
+ if (identifier === undefined) {
128
+ return error;
129
+ }
130
+
131
+ const current = await accessor.get(identifier as never);
132
+ return current === null ? error : { current, incoming: input };
133
+ };
134
+
135
+ const resolveStrategy = async <TTable extends AnyStoreTable>(
136
+ strategy: ReconcileStrategy<TTable>,
137
+ conflict: ReconcileConflict<TTable>,
138
+ ctx: TrailContext
139
+ ): Promise<UpsertOf<TTable>> =>
140
+ strategy === 'last-write-wins'
141
+ ? lastWriteWins(conflict)
142
+ : await strategy(conflict, ctx);
143
+
144
+ /** Resolve a version conflict through the configured strategy and retry the upsert. */
145
+ const recoverConflict = async <TTable extends AnyStoreTable>(
146
+ table: TTable,
147
+ input: UpsertOf<TTable>,
148
+ accessor: StoreAccessor<TTable>,
149
+ error: ConflictError,
150
+ strategy: ReconcileStrategy<TTable>,
151
+ ctx: TrailContext
152
+ ) => {
153
+ const conflict = await buildConflict(table, input, accessor, error);
154
+ if (conflict instanceof ConflictError) {
155
+ return Result.err(conflict);
156
+ }
157
+
158
+ const resolved = await resolveStrategy(strategy, conflict, ctx);
159
+ const normalized = normalizeResolvedInput(table, conflict.current, resolved);
160
+ return Result.ok(await accessor.upsert(normalized));
161
+ };
162
+
163
+ /**
164
+ * Build the input schema for a reconcile trail.
165
+ *
166
+ * `fixtureSchema` makes generated fields (including `version` on versioned
167
+ * tables) optional because adapters populate them. Reconcile, however,
168
+ * relies on optimistic concurrency: the caller must pass the expected
169
+ * `version` so `assertExpectedVersionMatch` can detect stale payloads. We
170
+ * therefore extend `fixtureSchema` with a required `version` field so
171
+ * callers cannot sidestep optimistic concurrency at the input boundary.
172
+ */
173
+ const buildReconcileInputSchema = <TTable extends AnyStoreTable>(
174
+ table: TTable
175
+ ): z.ZodType<UpsertOf<TTable>> =>
176
+ table.fixtureSchema.extend({
177
+ [versionFieldName]: z.number().int(),
178
+ }) as unknown as z.ZodType<UpsertOf<TTable>>;
179
+
180
+ /** The blaze performs only the initial upsert; conflict recovery is handled by the detour. */
181
+ const createReconcileBlaze =
182
+ <
183
+ TTable extends AnyStoreTable,
184
+ TConnection extends ReconcileConnection<TTable>,
185
+ >(
186
+ options: ReconcileOptions<TTable, TConnection>,
187
+ id: string
188
+ ) =>
189
+ async (input: UpsertOf<TTable>, ctx: TrailContext) => {
190
+ try {
191
+ const accessor = resolveAccessor(options.table, options.resource, ctx);
192
+ return Result.ok(await accessor.upsert(input));
193
+ } catch (error) {
194
+ if (error instanceof ConflictError) {
195
+ return Result.err(error);
196
+ }
197
+ return Result.err(mapStoreTrailError(id, error));
198
+ }
199
+ };
200
+
201
+ /** Build the detour that handles ConflictError recovery via the configured strategy. */
202
+ const createReconcileDetour = <
203
+ TTable extends AnyStoreTable,
204
+ TConnection extends ReconcileConnection<TTable>,
205
+ >(
206
+ options: ReconcileOptions<TTable, TConnection>,
207
+ id: string,
208
+ strategy: ReconcileStrategy<TTable>
209
+ ): Detour<UpsertOf<TTable>, EntityOf<TTable>, TrailsError> => ({
210
+ maxAttempts: 1,
211
+ on: ConflictError,
212
+ recover: async (attempt, ctx) => {
213
+ const conflictError = attempt.error as ConflictError;
214
+ try {
215
+ const accessor = resolveAccessor(options.table, options.resource, ctx);
216
+ return await recoverConflict(
217
+ options.table,
218
+ attempt.input,
219
+ accessor,
220
+ conflictError,
221
+ strategy,
222
+ ctx
223
+ );
224
+ } catch (error) {
225
+ if (error instanceof ConflictError) {
226
+ return Result.err(error);
227
+ }
228
+ return Result.err(mapStoreTrailError(id, error) as TrailsError);
229
+ }
230
+ },
231
+ });
232
+
233
+ /**
234
+ * Produce one trail that retries a versioned upsert with a conflict strategy
235
+ * when the incoming entity is stale.
236
+ *
237
+ * Reconcile is bounded to a single retry via a declarative `detour`. If a
238
+ * concurrent writer races the retry and produces a second `ConflictError`,
239
+ * the detour loop wraps it in `RetryExhaustedError<ConflictError>` so
240
+ * callers can distinguish "retry reconcile at a higher level" from
241
+ * "reconcile tried and lost the race".
242
+ *
243
+ * @remarks
244
+ * For versioned tables, the derived input schema requires an explicit
245
+ * `version` field so callers cannot sidestep optimistic concurrency at the
246
+ * input boundary. `fixtureSchema` alone makes `version` optional because
247
+ * adapters populate it for writes; reconcile must reject that relaxed
248
+ * shape.
249
+ */
250
+ export const reconcile = <
251
+ TTable extends AnyStoreTable,
252
+ TConnection extends ReconcileConnection<TTable>,
253
+ >(
254
+ options: ReconcileOptions<TTable, TConnection>
255
+ ): Trail<UpsertOf<TTable>, EntityOf<TTable>> => {
256
+ if (!options.table.versioned) {
257
+ throw new ValidationError(
258
+ `reconcile("${options.table.name}") requires a versioned store table.`
259
+ );
260
+ }
261
+
262
+ const id = options.id ?? `${options.table.name}.reconcile`;
263
+ const entityContour = createTableContour(options.table);
264
+ const strategy = options.strategy ?? 'last-write-wins';
265
+
266
+ return trail<UpsertOf<TTable>, EntityOf<TTable>>(id, {
267
+ blaze: createReconcileBlaze(options, id),
268
+ contours: [entityContour],
269
+ description:
270
+ options.description ??
271
+ `Reconcile version conflicts for "${options.table.name}" entities.`,
272
+ detours: [createReconcileDetour(options, id, strategy)],
273
+ examples: deriveExamples(options.table),
274
+ input: buildReconcileInputSchema(options.table),
275
+ intent: 'write',
276
+ on: options.on,
277
+ output: options.table.schema as unknown as z.ZodType<EntityOf<TTable>>,
278
+ pattern: 'reconcile',
279
+ resources: [options.resource],
280
+ });
281
+ };
@@ -0,0 +1,251 @@
1
+ import { InternalError, NotFoundError, Result, trail } from '@ontrails/core';
2
+ import type {
3
+ AnySignal,
4
+ Resource,
5
+ Trail,
6
+ TrailContext,
7
+ TrailExample,
8
+ } from '@ontrails/core';
9
+ import type { z } from 'zod';
10
+
11
+ import type {
12
+ AnyStoreTable,
13
+ EntityOf,
14
+ ReadOnlyStoreTableAccessor,
15
+ StoreAccessor,
16
+ StoreIdentifierOf,
17
+ UpsertOf,
18
+ } from '../types.js';
19
+ import { createTableContour, mapStoreTrailError } from './utils.js';
20
+
21
+ type IdentityInputOf<TTable extends AnyStoreTable> = Readonly<
22
+ Record<Extract<TTable['identity'], string>, StoreIdentifierOf<TTable>>
23
+ >;
24
+
25
+ type SourceConnection<TTable extends AnyStoreTable> = Readonly<
26
+ Record<TTable['name'], ReadOnlyStoreTableAccessor<TTable>>
27
+ >;
28
+
29
+ type TargetConnection<TTable extends AnyStoreTable> = Readonly<
30
+ Record<TTable['name'], StoreAccessor<TTable>>
31
+ >;
32
+
33
+ export interface SyncEndpoint<
34
+ TTable extends AnyStoreTable,
35
+ TConnection extends SourceConnection<TTable> | TargetConnection<TTable>,
36
+ > {
37
+ readonly resource: Resource<TConnection>;
38
+ readonly table: TTable;
39
+ }
40
+
41
+ export type SyncTransform<
42
+ TSourceTable extends AnyStoreTable,
43
+ TTargetTable extends AnyStoreTable,
44
+ > = (
45
+ entity: EntityOf<TSourceTable>,
46
+ ctx: TrailContext
47
+ ) => Promise<UpsertOf<TTargetTable>> | UpsertOf<TTargetTable>;
48
+
49
+ export interface SyncOptions<
50
+ TSourceTable extends AnyStoreTable,
51
+ TTargetTable extends AnyStoreTable,
52
+ TSourceConnection extends SourceConnection<TSourceTable>,
53
+ TTargetConnection extends TargetConnection<TTargetTable>,
54
+ > {
55
+ readonly description?: string;
56
+ readonly from: SyncEndpoint<TSourceTable, TSourceConnection>;
57
+ readonly id?: string;
58
+ readonly on?: readonly (AnySignal | string)[];
59
+ readonly to: SyncEndpoint<TTargetTable, TTargetConnection>;
60
+ readonly transform?: SyncTransform<TSourceTable, TTargetTable>;
61
+ }
62
+
63
+ const resolveSourceAccessor = <
64
+ TTable extends AnyStoreTable,
65
+ TConnection extends SourceConnection<TTable>,
66
+ >(
67
+ endpoint: SyncEndpoint<TTable, TConnection>,
68
+ ctx: TrailContext
69
+ ): ReadOnlyStoreTableAccessor<TTable> => {
70
+ const connection = endpoint.resource.from(ctx);
71
+ return connection[
72
+ endpoint.table.name as keyof TConnection
73
+ ] as ReadOnlyStoreTableAccessor<TTable>;
74
+ };
75
+
76
+ const resolveTargetAccessor = <
77
+ TTable extends AnyStoreTable,
78
+ TConnection extends TargetConnection<TTable>,
79
+ >(
80
+ endpoint: SyncEndpoint<TTable, TConnection>,
81
+ ctx: TrailContext
82
+ ): StoreAccessor<TTable> => {
83
+ const connection = endpoint.resource.from(ctx);
84
+ return connection[
85
+ endpoint.table.name as keyof TConnection
86
+ ] as StoreAccessor<TTable>;
87
+ };
88
+
89
+ const sourceMissingError = <TTable extends AnyStoreTable>(
90
+ table: TTable,
91
+ id: StoreIdentifierOf<TTable>
92
+ ): NotFoundError =>
93
+ new NotFoundError(
94
+ `Store table "${table.name}" could not find source entity "${String(id)}"`
95
+ );
96
+
97
+ const identityInputSchema = <TTable extends AnyStoreTable>(
98
+ table: TTable
99
+ ): z.ZodType<IdentityInputOf<TTable>> =>
100
+ table.schema.pick({
101
+ [table.identity]: true,
102
+ } as never) as unknown as z.ZodType<IdentityInputOf<TTable>>;
103
+
104
+ const deriveExamples = <
105
+ TSourceTable extends AnyStoreTable,
106
+ TTargetTable extends AnyStoreTable,
107
+ >(
108
+ sourceTable: TSourceTable,
109
+ targetTable: TTargetTable,
110
+ transform: SyncTransform<TSourceTable, TTargetTable> | undefined
111
+ ):
112
+ | readonly TrailExample<
113
+ IdentityInputOf<TSourceTable>,
114
+ EntityOf<TTargetTable>
115
+ >[]
116
+ | undefined => {
117
+ const targetById = new Map<
118
+ StoreIdentifierOf<TTargetTable>,
119
+ EntityOf<TTargetTable>
120
+ >();
121
+ for (const fixture of targetTable.fixtures) {
122
+ const id = fixture[targetTable.identity as keyof typeof fixture] as
123
+ | StoreIdentifierOf<TTargetTable>
124
+ | undefined;
125
+ if (id !== undefined) {
126
+ targetById.set(id, fixture as EntityOf<TTargetTable>);
127
+ }
128
+ }
129
+
130
+ const examples = sourceTable.fixtures.flatMap((fixture) => {
131
+ const id = fixture[sourceTable.identity as keyof typeof fixture] as
132
+ | StoreIdentifierOf<TSourceTable>
133
+ | undefined;
134
+ if (id === undefined) {
135
+ return [];
136
+ }
137
+
138
+ const targetFixture =
139
+ targetById.get(id as unknown as StoreIdentifierOf<TTargetTable>) ??
140
+ (transform === undefined && targetTable.schema.safeParse(fixture).success
141
+ ? (fixture as EntityOf<TTargetTable>)
142
+ : undefined);
143
+
144
+ if (targetFixture === undefined) {
145
+ return [];
146
+ }
147
+
148
+ return [
149
+ {
150
+ expected: targetFixture,
151
+ input: { [sourceTable.identity]: id } as IdentityInputOf<TSourceTable>,
152
+ name: `Sync ${targetTable.name} ${String(id)}`,
153
+ },
154
+ ];
155
+ });
156
+
157
+ return examples.length === 0 ? undefined : Object.freeze(examples);
158
+ };
159
+
160
+ /**
161
+ * Produce one trail that reads one source entity and writes the transformed
162
+ * result into a target store resource.
163
+ */
164
+ export const sync = <
165
+ TSourceTable extends AnyStoreTable,
166
+ TTargetTable extends AnyStoreTable,
167
+ TSourceConnection extends SourceConnection<TSourceTable>,
168
+ TTargetConnection extends TargetConnection<TTargetTable>,
169
+ >(
170
+ options: SyncOptions<
171
+ TSourceTable,
172
+ TTargetTable,
173
+ TSourceConnection,
174
+ TTargetConnection
175
+ >
176
+ ): Trail<IdentityInputOf<TSourceTable>, EntityOf<TTargetTable>> => {
177
+ const id = options.id ?? `${options.to.table.name}.sync`;
178
+ const sourceContour = createTableContour(options.from.table);
179
+ const targetContour = createTableContour(options.to.table);
180
+
181
+ return trail<IdentityInputOf<TSourceTable>, EntityOf<TTargetTable>>(id, {
182
+ // oxlint-disable-next-line max-statements -- sync blaze reads more clearly as one try/catch with schema validation, transform, and accessor call inline
183
+ blaze: async (input, ctx) => {
184
+ try {
185
+ const identifier = input[
186
+ options.from.table.identity as keyof typeof input
187
+ ] as StoreIdentifierOf<TSourceTable>;
188
+ const sourceEntity = await resolveSourceAccessor(options.from, ctx).get(
189
+ identifier
190
+ );
191
+
192
+ if (sourceEntity === null) {
193
+ return Result.err(sourceMissingError(options.from.table, identifier));
194
+ }
195
+
196
+ // No-transform path: the source entity is upserted directly into
197
+ // the target table. The generic signature does not require the two
198
+ // tables to be structurally compatible, so validate `next` against
199
+ // the target table's fixture schema at runtime. This catches
200
+ // accidentally omitted transforms before the underlying store sees
201
+ // a mismatched payload.
202
+ const next =
203
+ options.transform === undefined
204
+ ? options.to.table.fixtureSchema.safeParse(sourceEntity)
205
+ : undefined;
206
+
207
+ if (next !== undefined && !next.success) {
208
+ return Result.err(
209
+ new InternalError(
210
+ `${id} produced an invalid target entity: ${next.error.message}`
211
+ )
212
+ );
213
+ }
214
+
215
+ const payload =
216
+ options.transform === undefined
217
+ ? (next?.data as unknown as UpsertOf<TTargetTable>)
218
+ : await options.transform(sourceEntity, ctx);
219
+
220
+ const synced = await resolveTargetAccessor(options.to, ctx).upsert(
221
+ payload
222
+ );
223
+ return Result.ok(synced);
224
+ } catch (error) {
225
+ return Result.err(mapStoreTrailError(id, error));
226
+ }
227
+ },
228
+ contours: [sourceContour, targetContour],
229
+ description:
230
+ options.description ??
231
+ `Sync one "${options.from.table.name}" entity into "${options.to.table.name}".`,
232
+ examples: deriveExamples(
233
+ options.from.table,
234
+ options.to.table,
235
+ options.transform
236
+ ) as
237
+ | readonly TrailExample<
238
+ IdentityInputOf<TSourceTable>,
239
+ EntityOf<TTargetTable>
240
+ >[]
241
+ | undefined,
242
+ input: identityInputSchema(options.from.table),
243
+ intent: 'write',
244
+ on: options.on,
245
+ output: options.to.table.schema as unknown as z.ZodType<
246
+ EntityOf<TTargetTable>
247
+ >,
248
+ pattern: 'sync',
249
+ resources: [options.from.resource, options.to.resource],
250
+ });
251
+ };
@@ -0,0 +1,96 @@
1
+ import { InternalError, contour, isTrailsError } from '@ontrails/core';
2
+ import type { Contour } from '@ontrails/core';
3
+ import type { z } from 'zod';
4
+
5
+ import type { AnyStoreTable } from '../types.js';
6
+
7
+ /**
8
+ * The contour type produced by {@link createTableContour} for a given store
9
+ * table. Threads the table's name, schema shape, and identity through the
10
+ * contour generics so downstream `deriveTrail()` calls project concrete
11
+ * input/output types instead of widening back to
12
+ * `Contour<string, z.ZodRawShape, string>` (the `AnyContour` alias).
13
+ */
14
+ export type TableContour<TTable extends AnyStoreTable> = Contour<
15
+ TTable['name'],
16
+ TTable['schema']['shape'],
17
+ Extract<TTable['identity'], keyof TTable['schema']['shape'] & string>
18
+ >;
19
+
20
+ /**
21
+ * Build the shape used when deriving a contour view of a store table.
22
+ *
23
+ * Contour validates every example against the shape passed in, so the shape
24
+ * must match how fixtures are actually shaped. Store fixtures may omit
25
+ * framework-generated fields (`createdAt`, `version`, ...) because the
26
+ * adapter populates them, so we mirror `fixtureSchema`'s treatment of
27
+ * generated fields: generated, non-identity fields are made optional; the
28
+ * identity field stays required because read/delete/update all derive their
29
+ * input from it. Previously reconcile.ts and sync.ts passed
30
+ * `table.schema.shape` directly and crashed when a fixture omitted
31
+ * `createdAt` or another generated field.
32
+ */
33
+ export const buildContourShape = (
34
+ table: AnyStoreTable
35
+ ): Record<string, z.ZodType> => {
36
+ const shape = table.schema.shape as unknown as Record<string, z.ZodType>;
37
+ const generatedNonIdentity = new Set(
38
+ table.generated.filter((field) => field !== table.identity)
39
+ );
40
+
41
+ if (generatedNonIdentity.size === 0) {
42
+ return shape;
43
+ }
44
+
45
+ const next: Record<string, z.ZodType> = {};
46
+ for (const [field, fieldSchema] of Object.entries(shape)) {
47
+ next[field] = generatedNonIdentity.has(field)
48
+ ? fieldSchema.optional()
49
+ : fieldSchema;
50
+ }
51
+ return next;
52
+ };
53
+
54
+ /**
55
+ * Derive a contour view of a store table.
56
+ *
57
+ * Both `sync` and `reconcile` use this helper so they pick up the
58
+ * fixture-shape treatment (generated fields optional).
59
+ *
60
+ * @remarks
61
+ * Intentionally not cached. `contour()` brands the identity schema via
62
+ * `Object.defineProperty(..., { writable: false })`, and re-invoking on a
63
+ * schema that's already been branded throws TypeError. Factory call sites
64
+ * already build the contour once per trail instance, so rebuilding on a
65
+ * warm call is cheap and side-effect-free.
66
+ */
67
+ export const createTableContour = <TTable extends AnyStoreTable>(
68
+ table: TTable
69
+ ): TableContour<TTable> =>
70
+ contour(table.name, buildContourShape(table), {
71
+ examples: table.fixtures as readonly Record<string, unknown>[],
72
+ identity: table.identity,
73
+ }) as TableContour<TTable>;
74
+
75
+ /**
76
+ * Coerce an unknown thrown value into an Error instance, preserving the
77
+ * original when possible.
78
+ */
79
+ export const asError = (error: unknown): Error =>
80
+ error instanceof Error ? error : new Error(String(error));
81
+
82
+ /**
83
+ * Map a caught error into a `TrailsError` suitable for surfacing from a store
84
+ * trail factory. Pass-through for errors already in the taxonomy; otherwise
85
+ * wrap in an `InternalError` keyed by the trail id.
86
+ */
87
+ export const mapStoreTrailError = (trailId: string, error: unknown): Error => {
88
+ if (isTrailsError(error)) {
89
+ return error;
90
+ }
91
+
92
+ const resolved = asError(error);
93
+ return new InternalError(`${trailId} failed: ${resolved.message}`, {
94
+ cause: resolved,
95
+ });
96
+ };