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