@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.
- package/CHANGELOG.md +329 -0
- package/README.md +290 -0
- package/package.json +56 -0
- package/src/adapter-support.ts +178 -0
- package/src/crud-doctrine.ts +43 -0
- package/src/index.ts +48 -0
- package/src/jsonfile/index.ts +6 -0
- package/src/jsonfile/runtime.ts +700 -0
- package/src/jsonfile/types.ts +50 -0
- package/src/store.ts +528 -0
- package/src/testing.ts +175 -0
- package/src/trails/crud.ts +423 -0
- package/src/trails/index.ts +20 -0
- package/src/trails/reconcile.ts +299 -0
- package/src/trails/sync.ts +274 -0
- package/src/trails/utils.ts +117 -0
- package/src/types.ts +654 -0
|
@@ -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
|
+
};
|