@ontrails/store 1.0.0-beta.39 → 1.0.0-beta.41
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 +31 -0
- package/README.md +1 -1
- package/package.json +7 -2
- package/src/adapter-support.ts +16 -1
- package/src/trails/crud.ts +58 -47
- package/src/trails/index.ts +6 -2
- package/src/trails/reconcile.ts +15 -10
- package/src/trails/sync.ts +35 -30
- package/src/trails/utils.ts +37 -16
- package/src/types.ts +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @ontrails/store
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.41
|
|
4
|
+
|
|
5
|
+
## 1.0.0-beta.40
|
|
6
|
+
|
|
7
|
+
### Minor Changes
|
|
8
|
+
|
|
9
|
+
- [`5adb995`](https://github.com/outfitter-dev/trails/commit/5adb99551c2dda6190d46cce7f60bb08d63c99aa): Complete the v1 hard cutover from the authored `blaze` field to
|
|
10
|
+
`implementation` across trail contracts, surface projections, tests, examples,
|
|
11
|
+
and public source-analysis helpers. Existing applications must rename authored
|
|
12
|
+
trail behavior fields and direct trail-object access before upgrading.
|
|
13
|
+
- [`6712075`](https://github.com/outfitter-dev/trails/commit/67120754df3f614c7f4dd98be1fa0ba9d69b7765): Complete the v1 hard cutover from the `contour` domain-object declaration
|
|
14
|
+
vocabulary to `entity` across contracts, topo facts, store helpers, Warden,
|
|
15
|
+
Wayfinder, operator surfaces, examples, and generated locks. Existing
|
|
16
|
+
applications must rename contour APIs, run `trails dev reset --yes` to discard
|
|
17
|
+
pre-cutover local Topographer snapshots, and then recompile committed
|
|
18
|
+
`trails.lock` artifacts before upgrading. Those derived snapshots are
|
|
19
|
+
intentionally not read through a compatibility layer.
|
|
20
|
+
The entity-shaped wire contract advances `TopoGraph` and split lock manifests
|
|
21
|
+
from schema version 3 to 4; old split artifacts fail with regeneration guidance,
|
|
22
|
+
while the canonical root `trails.lock` remains schema version 5.
|
|
23
|
+
Wayfinder reports those stale rows as topo-store drift while keeping current
|
|
24
|
+
committed lock facts available for inspection.
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- [`9874e0b`](https://github.com/outfitter-dev/trails/commit/9874e0bb034c0f98edeb19833d9d3519c2a07a4c): Add `@ontrails/cloudflare/d1`, an env-bound Cloudflare D1 store resource for `@ontrails/store` definitions. The new subpath exports `cloudflareD1` and `connectD1`, supports the backend-agnostic store accessor contract (`get`, `list`, `upsert`, `remove`), versioned-table optimistic concurrency, fixture/mock seeding, store-derived write signals, Miniflare-backed conformance tests, and Worker env-bridge integration.
|
|
29
|
+
|
|
30
|
+
`@ontrails/core` and `@ontrails/store` no longer require the Bun global for signal fire ids or late-bound store signal tokens, so store definitions and store-derived signal emission work inside Worker modules. `@ontrails/warden` now treats `cloudflareD1` as a required Cloudflare public export with `@example` coverage.
|
|
31
|
+
|
|
32
|
+
- [`9bf592d`](https://github.com/outfitter-dev/trails/commit/9bf592ddba46aa12e3f4e6ffc0f772f7a41ed3df): Declare verified first-party adapter metadata for Drizzle, HTTP/Bun, and Store/Jsonfile so shared adapter checks can dogfood real owner targets.
|
|
33
|
+
|
|
3
34
|
## 1.0.0-beta.39
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ The bound store is a resource. Use it directly in trails:
|
|
|
69
69
|
export const list = trail('gist.list', {
|
|
70
70
|
resources: [db],
|
|
71
71
|
intent: 'read',
|
|
72
|
-
|
|
72
|
+
implementation: async (_input, ctx) => {
|
|
73
73
|
const conn = db.from(ctx);
|
|
74
74
|
const gists = await conn.gists.list();
|
|
75
75
|
return Result.ok(gists);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/store",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.41",
|
|
4
4
|
"files": [
|
|
5
5
|
"src/**/*.ts",
|
|
6
6
|
"!src/**/__tests__/**",
|
|
@@ -26,12 +26,17 @@
|
|
|
26
26
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@ontrails/core": "^1.0.0-beta.
|
|
29
|
+
"@ontrails/core": "^1.0.0-beta.41"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"zod": "^4.3.5"
|
|
33
33
|
},
|
|
34
34
|
"trails": {
|
|
35
|
+
"adapters": {
|
|
36
|
+
"./jsonfile": {
|
|
37
|
+
"target": "store"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
35
40
|
"adapterTargets": {
|
|
36
41
|
"store": {
|
|
37
42
|
"placements": [
|
package/src/adapter-support.ts
CHANGED
|
@@ -19,6 +19,21 @@ type MutableTables<TStore extends AnyStoreDefinition> = {
|
|
|
19
19
|
|
|
20
20
|
type StoreSignalChange = 'created' | 'removed' | 'updated';
|
|
21
21
|
|
|
22
|
+
const storeSignalTokenCounter = Symbol.for(
|
|
23
|
+
'@ontrails/store.late-bound-signal-counter'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const takeStoreSignalTokenCounter = (): number => {
|
|
27
|
+
const globals = globalThis as Record<PropertyKey, unknown>;
|
|
28
|
+
const current = globals[storeSignalTokenCounter];
|
|
29
|
+
const next = typeof current === 'number' ? current : 0;
|
|
30
|
+
globals[storeSignalTokenCounter] = next + 1;
|
|
31
|
+
return next;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const createStoreSignalToken = (change: StoreSignalChange): string =>
|
|
35
|
+
`store-${change}-${takeStoreSignalTokenCounter()}`;
|
|
36
|
+
|
|
22
37
|
const createStoreSignalDescription = (
|
|
23
38
|
tableName: string,
|
|
24
39
|
change: StoreSignalChange
|
|
@@ -51,7 +66,7 @@ const createStoreSignal = <TPayload>(
|
|
|
51
66
|
}),
|
|
52
67
|
{
|
|
53
68
|
kind: 'store-derived',
|
|
54
|
-
token:
|
|
69
|
+
token: createStoreSignalToken(change),
|
|
55
70
|
}
|
|
56
71
|
);
|
|
57
72
|
|
package/src/trails/crud.ts
CHANGED
|
@@ -21,8 +21,8 @@ import type {
|
|
|
21
21
|
UpdateOf,
|
|
22
22
|
} from '../types.js';
|
|
23
23
|
import type { CrudOperation } from '../crud-doctrine.js';
|
|
24
|
-
import {
|
|
25
|
-
import type {
|
|
24
|
+
import { assertCurrentEntityOption, createTableEntity } from './utils.js';
|
|
25
|
+
import type { TableEntity } from './utils.js';
|
|
26
26
|
|
|
27
27
|
type IdentityInputOf<TTable extends AnyStoreTable> = Readonly<
|
|
28
28
|
Record<Extract<TTable['identity'], string>, StoreIdentifierOf<TTable>>
|
|
@@ -32,27 +32,27 @@ type CrudConnection<TTable extends AnyStoreTable> = Readonly<
|
|
|
32
32
|
Record<TTable['name'], StoreAccessor<TTable>>
|
|
33
33
|
>;
|
|
34
34
|
|
|
35
|
-
type
|
|
36
|
-
keyof z.output<
|
|
35
|
+
type TableEntityFieldKey<TTable extends AnyStoreTable> = Extract<
|
|
36
|
+
keyof z.output<TableEntity<TTable>>,
|
|
37
37
|
string
|
|
38
38
|
>;
|
|
39
39
|
|
|
40
40
|
type GeneratedFieldsOf<TTable extends AnyStoreTable> =
|
|
41
|
-
TTable['generated'] extends readonly
|
|
41
|
+
TTable['generated'] extends readonly TableEntityFieldKey<TTable>[]
|
|
42
42
|
? TTable['generated']
|
|
43
43
|
: readonly [];
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Input type `deriveTrail` projects for a given CRUD operation against a
|
|
47
|
-
* store table. Uses `
|
|
48
|
-
* structurally matches the
|
|
47
|
+
* store table. Uses `TableEntity<TTable>` so the projected input
|
|
48
|
+
* structurally matches the entity-backed derivation path in
|
|
49
49
|
* `@ontrails/core`'s `deriveTrail`.
|
|
50
50
|
*/
|
|
51
51
|
type DerivedInput<
|
|
52
52
|
TTable extends AnyStoreTable,
|
|
53
53
|
TOperation extends CrudOperation,
|
|
54
54
|
> = DeriveTrailInput<
|
|
55
|
-
|
|
55
|
+
TableEntity<TTable>,
|
|
56
56
|
TOperation,
|
|
57
57
|
GeneratedFieldsOf<TTable>
|
|
58
58
|
>;
|
|
@@ -64,7 +64,7 @@ type DerivedInput<
|
|
|
64
64
|
type DerivedOutput<
|
|
65
65
|
TTable extends AnyStoreTable,
|
|
66
66
|
TOperation extends CrudOperation,
|
|
67
|
-
> = DeriveTrailOutput<
|
|
67
|
+
> = DeriveTrailOutput<TableEntity<TTable>, TOperation>;
|
|
68
68
|
|
|
69
69
|
type InternalCreateTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
70
70
|
DerivedInput<TTable, 'create'>,
|
|
@@ -144,15 +144,15 @@ export type CrudTrails<TTable extends AnyStoreTable> = readonly [
|
|
|
144
144
|
list: ListTrailOf<TTable>,
|
|
145
145
|
] & {
|
|
146
146
|
/**
|
|
147
|
-
* The table
|
|
148
|
-
* `reconcile({
|
|
149
|
-
* the topo sees one shared
|
|
147
|
+
* The table entity the factory registered on its trails. Pass it to
|
|
148
|
+
* `reconcile({ entity })` (or other factories over the same table) so
|
|
149
|
+
* the topo sees one shared entity instance instead of rejecting two
|
|
150
150
|
* same-named rebuilds as duplicates.
|
|
151
151
|
*/
|
|
152
|
-
readonly
|
|
152
|
+
readonly entity: TableEntity<TTable>;
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
-
export interface
|
|
155
|
+
export interface CrudImplementationOverrides<TTable extends AnyStoreTable> {
|
|
156
156
|
readonly create?: Implementation<InsertOf<TTable>, EntityOf<TTable>>;
|
|
157
157
|
readonly read?: Implementation<IdentityInputOf<TTable>, EntityOf<TTable>>;
|
|
158
158
|
readonly update?: Implementation<
|
|
@@ -164,14 +164,14 @@ export interface CrudBlazeOverrides<TTable extends AnyStoreTable> {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
export interface CrudOptions<TTable extends AnyStoreTable> {
|
|
167
|
-
readonly
|
|
167
|
+
readonly implementation?: CrudImplementationOverrides<TTable>;
|
|
168
168
|
/**
|
|
169
|
-
* Existing table
|
|
169
|
+
* Existing table entity to register on the produced trails. When
|
|
170
170
|
* omitted, the factory builds one from the table. Pass a shared
|
|
171
171
|
* instance when another factory (e.g. `reconcile()`) covers the same
|
|
172
|
-
* table so `topo()` sees a single
|
|
172
|
+
* table so `topo()` sees a single entity registration.
|
|
173
173
|
*/
|
|
174
|
-
readonly
|
|
174
|
+
readonly entity?: TableEntity<TTable>;
|
|
175
175
|
/**
|
|
176
176
|
* Permit requirement declared on every produced trail. Factory trails
|
|
177
177
|
* carry authored defaults like any hand-written trail; per-operation
|
|
@@ -185,7 +185,7 @@ export interface CrudOptions<TTable extends AnyStoreTable> {
|
|
|
185
185
|
readonly permits?: Partial<Record<CrudOperation, PermitRequirement>>;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
interface
|
|
188
|
+
interface InternalCrudImplementationOverrides<TTable extends AnyStoreTable> {
|
|
189
189
|
readonly create?: Implementation<
|
|
190
190
|
DerivedInput<TTable, 'create'>,
|
|
191
191
|
DerivedOutput<TTable, 'create'>
|
|
@@ -209,8 +209,8 @@ interface InternalCrudBlazeOverrides<TTable extends AnyStoreTable> {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
interface InternalCrudOptions<TTable extends AnyStoreTable> {
|
|
212
|
-
readonly
|
|
213
|
-
readonly
|
|
212
|
+
readonly implementation?: InternalCrudImplementationOverrides<TTable>;
|
|
213
|
+
readonly entity?: TableEntity<TTable>;
|
|
214
214
|
readonly permit?: PermitRequirement;
|
|
215
215
|
readonly permits?: Partial<Record<CrudOperation, PermitRequirement>>;
|
|
216
216
|
}
|
|
@@ -256,7 +256,7 @@ const normalizeExamplesForOutput = <TInput, TOutput>(
|
|
|
256
256
|
const finalizeTrail = <TInput, TOutput>(
|
|
257
257
|
base: Trail<TInput, TOutput>,
|
|
258
258
|
options: {
|
|
259
|
-
readonly
|
|
259
|
+
readonly implementation?: Implementation<TInput, TOutput> | undefined;
|
|
260
260
|
readonly output?: z.ZodType<TOutput> | undefined;
|
|
261
261
|
readonly pattern?: string | undefined;
|
|
262
262
|
readonly permit?: PermitRequirement | undefined;
|
|
@@ -264,7 +264,9 @@ const finalizeTrail = <TInput, TOutput>(
|
|
|
264
264
|
): Trail<TInput, TOutput> =>
|
|
265
265
|
Object.freeze({
|
|
266
266
|
...base,
|
|
267
|
-
...(options.
|
|
267
|
+
...(options.implementation === undefined
|
|
268
|
+
? {}
|
|
269
|
+
: { implementation: options.implementation }),
|
|
268
270
|
...(options.output === undefined
|
|
269
271
|
? {}
|
|
270
272
|
: {
|
|
@@ -281,35 +283,35 @@ const deriveCrudBaseTrails = <
|
|
|
281
283
|
>(
|
|
282
284
|
table: TTable,
|
|
283
285
|
resource: Resource<TConnection>,
|
|
284
|
-
|
|
286
|
+
tableEntity: TableEntity<TTable>
|
|
285
287
|
): InternalCrudBaseTrails<TTable> => {
|
|
286
|
-
// Narrow the store's `readonly string[]` to the
|
|
288
|
+
// Narrow the store's `readonly string[]` to the entity's typed field-key
|
|
287
289
|
// array so `deriveTrail`'s `TGenerated` generic picks up the precise
|
|
288
|
-
// key-of shape that `CreateInputOf<
|
|
290
|
+
// key-of shape that `CreateInputOf<Entity, TGenerated>` expects. The
|
|
289
291
|
// runtime value is unchanged — the names in `table.generated` are already
|
|
290
292
|
// keys of `table.schema.shape` by construction in `store()`.
|
|
291
293
|
const generated = table.generated as GeneratedFieldsOf<TTable>;
|
|
292
294
|
|
|
293
295
|
return {
|
|
294
|
-
createBase: deriveTrail(
|
|
296
|
+
createBase: deriveTrail(tableEntity, 'create', {
|
|
295
297
|
generated,
|
|
296
298
|
resource,
|
|
297
299
|
}),
|
|
298
|
-
deleteBase: deriveTrail(
|
|
300
|
+
deleteBase: deriveTrail(tableEntity, 'delete', {
|
|
299
301
|
resource,
|
|
300
302
|
}),
|
|
301
|
-
listBase: deriveTrail(
|
|
303
|
+
listBase: deriveTrail(tableEntity, 'list', {
|
|
302
304
|
resource,
|
|
303
305
|
}),
|
|
304
|
-
readBase: deriveTrail(
|
|
306
|
+
readBase: deriveTrail(tableEntity, 'read', {
|
|
305
307
|
resource,
|
|
306
308
|
}),
|
|
307
|
-
// The `update`
|
|
309
|
+
// The `update` implementation synthesized by `deriveTrail` handles the partial-patch
|
|
308
310
|
// concern: when the accessor lacks a native `update`, the fallback path in
|
|
309
311
|
// `derive-trail.ts` (`updateViaReadAndUpsert`) reads the current entity,
|
|
310
312
|
// merges the patch, strips the `version` field, then calls `upsert` with
|
|
311
313
|
// the full merged payload — so no fields are silently lost.
|
|
312
|
-
updateBase: deriveTrail(
|
|
314
|
+
updateBase: deriveTrail(tableEntity, 'update', {
|
|
313
315
|
generated,
|
|
314
316
|
resource,
|
|
315
317
|
}),
|
|
@@ -322,25 +324,31 @@ const buildCrudTrails = <TTable extends AnyStoreTable>(
|
|
|
322
324
|
entityOutput: z.ZodType<DerivedOutput<TTable, 'create'>>,
|
|
323
325
|
listOutput: z.ZodType<DerivedOutput<TTable, 'list'>>
|
|
324
326
|
): InternalCrudTrails<TTable> => {
|
|
325
|
-
const overrides = options.
|
|
327
|
+
const overrides = options.implementation ?? {};
|
|
326
328
|
const permitFor = (operation: CrudOperation): PermitRequirement | undefined =>
|
|
327
329
|
options.permits?.[operation] ?? options.permit;
|
|
328
330
|
|
|
329
331
|
return Object.freeze([
|
|
330
332
|
finalizeTrail(baseTrails.createBase, {
|
|
331
|
-
...(overrides.create === undefined
|
|
333
|
+
...(overrides.create === undefined
|
|
334
|
+
? {}
|
|
335
|
+
: { implementation: overrides.create }),
|
|
332
336
|
output: entityOutput,
|
|
333
337
|
pattern: 'crud',
|
|
334
338
|
permit: permitFor('create'),
|
|
335
339
|
}),
|
|
336
340
|
finalizeTrail(baseTrails.readBase, {
|
|
337
|
-
...(overrides.read === undefined
|
|
341
|
+
...(overrides.read === undefined
|
|
342
|
+
? {}
|
|
343
|
+
: { implementation: overrides.read }),
|
|
338
344
|
output: entityOutput,
|
|
339
345
|
pattern: 'crud',
|
|
340
346
|
permit: permitFor('read'),
|
|
341
347
|
}),
|
|
342
348
|
finalizeTrail(baseTrails.updateBase, {
|
|
343
|
-
...(overrides.update === undefined
|
|
349
|
+
...(overrides.update === undefined
|
|
350
|
+
? {}
|
|
351
|
+
: { implementation: overrides.update }),
|
|
344
352
|
output: entityOutput,
|
|
345
353
|
pattern: 'crud',
|
|
346
354
|
permit: permitFor('update'),
|
|
@@ -351,12 +359,14 @@ const buildCrudTrails = <TTable extends AnyStoreTable>(
|
|
|
351
359
|
permit: permitFor('delete'),
|
|
352
360
|
})
|
|
353
361
|
: finalizeTrail(baseTrails.deleteBase, {
|
|
354
|
-
|
|
362
|
+
implementation: overrides.delete,
|
|
355
363
|
pattern: 'crud',
|
|
356
364
|
permit: permitFor('delete'),
|
|
357
365
|
}),
|
|
358
366
|
finalizeTrail(baseTrails.listBase, {
|
|
359
|
-
...(overrides.list === undefined
|
|
367
|
+
...(overrides.list === undefined
|
|
368
|
+
? {}
|
|
369
|
+
: { implementation: overrides.list }),
|
|
360
370
|
output: listOutput,
|
|
361
371
|
pattern: 'crud',
|
|
362
372
|
permit: permitFor('list'),
|
|
@@ -367,10 +377,10 @@ const buildCrudTrails = <TTable extends AnyStoreTable>(
|
|
|
367
377
|
/**
|
|
368
378
|
* Produce the standard CRUD trail tuple for one normalized store table.
|
|
369
379
|
*
|
|
370
|
-
* The factory derives schemas, examples, resources, and
|
|
371
|
-
* the table metadata.
|
|
380
|
+
* The factory derives schemas, examples, resources, and entity linkage from
|
|
381
|
+
* the table metadata. Implementations default to the backend-agnostic store accessor
|
|
372
382
|
* contract via `deriveTrail()`'s single-resource synthesis path. Per-operation
|
|
373
|
-
*
|
|
383
|
+
* implementation overrides stay available for callers that need custom persistence
|
|
374
384
|
* behavior and are layered onto the derived trails in a single pass.
|
|
375
385
|
*/
|
|
376
386
|
export function crud<
|
|
@@ -389,11 +399,12 @@ export function crud<
|
|
|
389
399
|
resource: Resource<TConnection>,
|
|
390
400
|
options: InternalCrudOptions<TTable> = {}
|
|
391
401
|
) {
|
|
392
|
-
|
|
393
|
-
const
|
|
402
|
+
assertCurrentEntityOption(options, 'crud() options');
|
|
403
|
+
const tableEntity = options.entity ?? createTableEntity(table);
|
|
404
|
+
const baseTrails = deriveCrudBaseTrails(table, resource, tableEntity);
|
|
394
405
|
// Narrow `table.schema` (typed `StoreObjectSchema`, which is
|
|
395
406
|
// `z.ZodObject<Record<string, z.ZodType>>`) to a ZodObject keyed by the
|
|
396
|
-
// concrete shape so its `z.output` unifies with the
|
|
407
|
+
// concrete shape so its `z.output` unifies with the entity-derived
|
|
397
408
|
// output. Structurally `table.schema` already has `shape:
|
|
398
409
|
// TTable['schema']['shape']` — this only refines the generic parameter.
|
|
399
410
|
const entitySchema = table.schema as z.ZodObject<TTable['schema']['shape']>;
|
|
@@ -402,11 +413,11 @@ export function crud<
|
|
|
402
413
|
entitySchema.array();
|
|
403
414
|
|
|
404
415
|
const trails = buildCrudTrails(baseTrails, options, entityOutput, listOutput);
|
|
405
|
-
// Expose the registered
|
|
416
|
+
// Expose the registered entity so other factories over the same table
|
|
406
417
|
// (reconcile, sync) can share the instance instead of rebuilding it.
|
|
407
418
|
return Object.freeze(
|
|
408
|
-
Object.assign([...trails], {
|
|
419
|
+
Object.assign([...trails], { entity: tableEntity })
|
|
409
420
|
) as unknown as InternalCrudTrails<TTable> & {
|
|
410
|
-
readonly
|
|
421
|
+
readonly entity: TableEntity<TTable>;
|
|
411
422
|
};
|
|
412
423
|
}
|
package/src/trails/index.ts
CHANGED
|
@@ -4,7 +4,11 @@ export type {
|
|
|
4
4
|
CrudOperation,
|
|
5
5
|
} from '../crud-doctrine.js';
|
|
6
6
|
export { crud } from './crud.js';
|
|
7
|
-
export type {
|
|
7
|
+
export type {
|
|
8
|
+
CrudImplementationOverrides,
|
|
9
|
+
CrudOptions,
|
|
10
|
+
CrudTrails,
|
|
11
|
+
} from './crud.js';
|
|
8
12
|
export { reconcile } from './reconcile.js';
|
|
9
13
|
export type {
|
|
10
14
|
ReconcileConflict,
|
|
@@ -13,4 +17,4 @@ export type {
|
|
|
13
17
|
} from './reconcile.js';
|
|
14
18
|
export { sync } from './sync.js';
|
|
15
19
|
export type { SyncEndpoint, SyncOptions, SyncTransform } from './sync.js';
|
|
16
|
-
export type {
|
|
20
|
+
export type { TableEntity } from './utils.js';
|
package/src/trails/reconcile.ts
CHANGED
|
@@ -18,8 +18,12 @@ import type {
|
|
|
18
18
|
UpsertOf,
|
|
19
19
|
} from '../types.js';
|
|
20
20
|
import { versionFieldName } from '../store.js';
|
|
21
|
-
import {
|
|
22
|
-
|
|
21
|
+
import {
|
|
22
|
+
assertCurrentEntityOption,
|
|
23
|
+
createTableEntity,
|
|
24
|
+
mapStoreTrailError,
|
|
25
|
+
} from './utils.js';
|
|
26
|
+
import type { TableEntity } from './utils.js';
|
|
23
27
|
|
|
24
28
|
type ReconcileConnection<TTable extends AnyStoreTable> = Readonly<
|
|
25
29
|
Record<TTable['name'], StoreAccessor<TTable>>
|
|
@@ -42,13 +46,13 @@ export interface ReconcileOptions<
|
|
|
42
46
|
TConnection extends ReconcileConnection<TTable>,
|
|
43
47
|
> {
|
|
44
48
|
/**
|
|
45
|
-
* Existing table
|
|
46
|
-
*
|
|
49
|
+
* Existing table entity to register on the reconcile trail. Pass the
|
|
50
|
+
* entity a `crud()` call over the same table exposes (its `entity`
|
|
47
51
|
* property) so `topo()` sees one shared instance instead of rejecting
|
|
48
52
|
* two same-named rebuilds as duplicates. When omitted, the factory
|
|
49
53
|
* builds its own.
|
|
50
54
|
*/
|
|
51
|
-
readonly
|
|
55
|
+
readonly entity?: TableEntity<TTable>;
|
|
52
56
|
readonly description?: string;
|
|
53
57
|
readonly id?: string;
|
|
54
58
|
readonly on?: readonly (AnySignal | string)[];
|
|
@@ -189,8 +193,8 @@ const buildReconcileInputSchema = <TTable extends AnyStoreTable>(
|
|
|
189
193
|
[versionFieldName]: z.number().int(),
|
|
190
194
|
}) as unknown as z.ZodType<UpsertOf<TTable>>;
|
|
191
195
|
|
|
192
|
-
/** The
|
|
193
|
-
const
|
|
196
|
+
/** The implementation performs only the initial upsert; conflict recovery is handled by the detour. */
|
|
197
|
+
const createReconcileImplementation =
|
|
194
198
|
<
|
|
195
199
|
TTable extends AnyStoreTable,
|
|
196
200
|
TConnection extends ReconcileConnection<TTable>,
|
|
@@ -265,6 +269,7 @@ export const reconcile = <
|
|
|
265
269
|
>(
|
|
266
270
|
options: ReconcileOptions<TTable, TConnection>
|
|
267
271
|
): Trail<UpsertOf<TTable>, EntityOf<TTable>> => {
|
|
272
|
+
assertCurrentEntityOption(options, 'reconcile() options');
|
|
268
273
|
if (!options.table.versioned) {
|
|
269
274
|
throw new ValidationError(
|
|
270
275
|
`reconcile("${options.table.name}") requires a versioned store table.`
|
|
@@ -272,17 +277,17 @@ export const reconcile = <
|
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
const id = options.id ?? `${options.table.name}.reconcile`;
|
|
275
|
-
const
|
|
280
|
+
const tableEntity = options.entity ?? createTableEntity(options.table);
|
|
276
281
|
const strategy = options.strategy ?? 'last-write-wins';
|
|
277
282
|
|
|
278
283
|
return trail(id, {
|
|
279
|
-
blaze: createReconcileBlaze(options, id),
|
|
280
|
-
contours: [entityContour],
|
|
281
284
|
description:
|
|
282
285
|
options.description ??
|
|
283
286
|
`Reconcile version conflicts for "${options.table.name}" entities.`,
|
|
284
287
|
detours: [createReconcileDetour(options, id, strategy)],
|
|
288
|
+
entities: [tableEntity],
|
|
285
289
|
examples: deriveExamples(options.table),
|
|
290
|
+
implementation: createReconcileImplementation(options, id),
|
|
286
291
|
input: buildReconcileInputSchema(options.table),
|
|
287
292
|
intent: 'write',
|
|
288
293
|
on: options.on,
|
package/src/trails/sync.ts
CHANGED
|
@@ -17,8 +17,12 @@ import type {
|
|
|
17
17
|
StoreIdentifierOf,
|
|
18
18
|
UpsertOf,
|
|
19
19
|
} from '../types.js';
|
|
20
|
-
import {
|
|
21
|
-
|
|
20
|
+
import {
|
|
21
|
+
assertCurrentEntityOption,
|
|
22
|
+
createTableEntity,
|
|
23
|
+
mapStoreTrailError,
|
|
24
|
+
} from './utils.js';
|
|
25
|
+
import type { TableEntity } from './utils.js';
|
|
22
26
|
|
|
23
27
|
type IdentityInputOf<TTable extends AnyStoreTable> = Readonly<
|
|
24
28
|
Record<Extract<TTable['identity'], string>, StoreIdentifierOf<TTable>>
|
|
@@ -37,13 +41,13 @@ export interface SyncEndpoint<
|
|
|
37
41
|
TConnection extends SourceConnection<TTable> | TargetConnection<TTable>,
|
|
38
42
|
> {
|
|
39
43
|
/**
|
|
40
|
-
* Existing table
|
|
41
|
-
* endpoint. Pass the
|
|
42
|
-
* exposes (its `
|
|
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
|
|
43
47
|
* instance instead of rejecting two same-named rebuilds as
|
|
44
48
|
* duplicates. When omitted, the factory builds one from the table.
|
|
45
49
|
*/
|
|
46
|
-
readonly
|
|
50
|
+
readonly entity?: TableEntity<TTable>;
|
|
47
51
|
readonly resource: Resource<TConnection>;
|
|
48
52
|
readonly table: TTable;
|
|
49
53
|
}
|
|
@@ -189,24 +193,39 @@ export const sync = <
|
|
|
189
193
|
TTargetConnection
|
|
190
194
|
>
|
|
191
195
|
): Trail<IdentityInputOf<TSourceTable>, EntityOf<TTargetTable>> => {
|
|
196
|
+
assertCurrentEntityOption(options.from, 'sync() from options');
|
|
197
|
+
assertCurrentEntityOption(options.to, 'sync() to options');
|
|
192
198
|
const id = options.id ?? `${options.to.table.name}.sync`;
|
|
193
|
-
const
|
|
194
|
-
options.from.
|
|
195
|
-
const
|
|
196
|
-
options.to.contour ?? createTableContour(options.to.table);
|
|
199
|
+
const sourceEntity =
|
|
200
|
+
options.from.entity ?? createTableEntity(options.from.table);
|
|
201
|
+
const targetEntity = options.to.entity ?? createTableEntity(options.to.table);
|
|
197
202
|
|
|
198
203
|
return trail(id, {
|
|
199
|
-
|
|
200
|
-
|
|
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) => {
|
|
201
220
|
try {
|
|
202
221
|
const identifier = input[
|
|
203
222
|
options.from.table.identity as keyof typeof input
|
|
204
223
|
] as StoreIdentifierOf<TSourceTable>;
|
|
205
|
-
const
|
|
224
|
+
const sourceRecord = await resolveSourceAccessor(options.from, ctx).get(
|
|
206
225
|
identifier
|
|
207
226
|
);
|
|
208
227
|
|
|
209
|
-
if (
|
|
228
|
+
if (sourceRecord === null) {
|
|
210
229
|
return Result.err(sourceMissingError(options.from.table, identifier));
|
|
211
230
|
}
|
|
212
231
|
|
|
@@ -218,7 +237,7 @@ export const sync = <
|
|
|
218
237
|
// a mismatched payload.
|
|
219
238
|
const next =
|
|
220
239
|
options.transform === undefined
|
|
221
|
-
? options.to.table.fixtureSchema.safeParse(
|
|
240
|
+
? options.to.table.fixtureSchema.safeParse(sourceRecord)
|
|
222
241
|
: undefined;
|
|
223
242
|
|
|
224
243
|
if (next !== undefined && !next.success) {
|
|
@@ -232,7 +251,7 @@ export const sync = <
|
|
|
232
251
|
const payload =
|
|
233
252
|
options.transform === undefined
|
|
234
253
|
? (next?.data as unknown as UpsertOf<TTargetTable>)
|
|
235
|
-
: await options.transform(
|
|
254
|
+
: await options.transform(sourceRecord, ctx);
|
|
236
255
|
|
|
237
256
|
const synced = await resolveTargetAccessor(options.to, ctx).upsert(
|
|
238
257
|
payload
|
|
@@ -242,20 +261,6 @@ export const sync = <
|
|
|
242
261
|
return Result.err(mapStoreTrailError(id, error));
|
|
243
262
|
}
|
|
244
263
|
},
|
|
245
|
-
contours: [sourceContour, targetContour],
|
|
246
|
-
description:
|
|
247
|
-
options.description ??
|
|
248
|
-
`Sync one "${options.from.table.name}" entity into "${options.to.table.name}".`,
|
|
249
|
-
examples: deriveExamples(
|
|
250
|
-
options.from.table,
|
|
251
|
-
options.to.table,
|
|
252
|
-
options.transform
|
|
253
|
-
) as
|
|
254
|
-
| readonly TrailExample<
|
|
255
|
-
IdentityInputOf<TSourceTable>,
|
|
256
|
-
EntityOf<TTargetTable>
|
|
257
|
-
>[]
|
|
258
|
-
| undefined,
|
|
259
264
|
input: identityInputSchema(options.from.table),
|
|
260
265
|
intent: 'write',
|
|
261
266
|
on: options.on,
|
package/src/trails/utils.ts
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
ValidationError,
|
|
4
|
+
entity,
|
|
5
|
+
isTrailsError,
|
|
6
|
+
} from '@ontrails/core';
|
|
7
|
+
import type { Entity } from '@ontrails/core';
|
|
3
8
|
import type { z } from 'zod';
|
|
4
9
|
|
|
5
10
|
import type { AnyStoreTable } from '../types.js';
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
|
-
* The
|
|
13
|
+
* The entity type produced by {@link createTableEntity} for a given store
|
|
9
14
|
* table. Threads the table's name, schema shape, and identity through the
|
|
10
|
-
*
|
|
15
|
+
* entity generics so downstream `deriveTrail()` calls project concrete
|
|
11
16
|
* input/output types instead of widening back to
|
|
12
|
-
* `
|
|
17
|
+
* `Entity<string, z.ZodRawShape, string>` (the `AnyEntity` alias).
|
|
13
18
|
*/
|
|
14
|
-
export type
|
|
19
|
+
export type TableEntity<TTable extends AnyStoreTable> = Entity<
|
|
15
20
|
TTable['name'],
|
|
16
21
|
TTable['schema']['shape'],
|
|
17
22
|
Extract<TTable['identity'], keyof TTable['schema']['shape'] & string>
|
|
18
23
|
>;
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
|
-
* Build the shape used when deriving
|
|
26
|
+
* Build the shape used when deriving an entity view of a store table.
|
|
22
27
|
*
|
|
23
|
-
*
|
|
28
|
+
* Entity validates every example against the shape passed in, so the shape
|
|
24
29
|
* must match how fixtures are actually shaped. Store fixtures may omit
|
|
25
30
|
* framework-generated fields (`createdAt`, `version`, ...) because the
|
|
26
31
|
* adapter populates them, so we mirror `fixtureSchema`'s treatment of
|
|
@@ -30,7 +35,7 @@ export type TableContour<TTable extends AnyStoreTable> = Contour<
|
|
|
30
35
|
* `table.schema.shape` directly and crashed when a fixture omitted
|
|
31
36
|
* `createdAt` or another generated field.
|
|
32
37
|
*/
|
|
33
|
-
export const
|
|
38
|
+
export const buildEntityShape = (
|
|
34
39
|
table: AnyStoreTable
|
|
35
40
|
): Record<string, z.ZodType> => {
|
|
36
41
|
const shape = table.schema.shape as unknown as Record<string, z.ZodType>;
|
|
@@ -52,25 +57,41 @@ export const buildContourShape = (
|
|
|
52
57
|
};
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
|
-
* Derive
|
|
60
|
+
* Derive an entity view of a store table.
|
|
56
61
|
*
|
|
57
62
|
* Both `sync` and `reconcile` use this helper so they pick up the
|
|
58
63
|
* fixture-shape treatment (generated fields optional).
|
|
59
64
|
*
|
|
60
65
|
* @remarks
|
|
61
|
-
* Intentionally not cached. `
|
|
66
|
+
* Intentionally not cached. `entity()` brands the identity schema via
|
|
62
67
|
* `Object.defineProperty(..., { writable: false })`, and re-invoking on a
|
|
63
68
|
* schema that's already been branded throws TypeError. Factory call sites
|
|
64
|
-
* already build the
|
|
69
|
+
* already build the entity once per trail instance, so rebuilding on a
|
|
65
70
|
* warm call is cheap and side-effect-free.
|
|
66
71
|
*/
|
|
67
|
-
export const
|
|
72
|
+
export const createTableEntity = <TTable extends AnyStoreTable>(
|
|
68
73
|
table: TTable
|
|
69
|
-
):
|
|
70
|
-
|
|
74
|
+
): TableEntity<TTable> =>
|
|
75
|
+
entity(table.name, buildEntityShape(table), {
|
|
71
76
|
examples: table.fixtures as readonly Record<string, unknown>[],
|
|
72
77
|
identity: table.identity,
|
|
73
|
-
}) as
|
|
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
|
+
};
|
|
74
95
|
|
|
75
96
|
/**
|
|
76
97
|
* Coerce an unknown thrown value into an Error instance, preserving the
|
package/src/types.ts
CHANGED
|
@@ -70,7 +70,7 @@ type ObjectOutputOf<TShape extends z.ZodRawShape> = z.core.$InferObjectOutput<
|
|
|
70
70
|
* create/upsert inputs they meet at store/core trail boundaries.
|
|
71
71
|
*
|
|
72
72
|
* This is not the same type-level path core's `deriveTrail()` uses — core
|
|
73
|
-
* still goes through `z.input<
|
|
73
|
+
* still goes through `z.input<TEntity>` / `z.output<TEntity>` — but at
|
|
74
74
|
* concrete instantiations it collapses to the same structural fixture shape
|
|
75
75
|
* while preserving generic equality across the store/core seam.
|
|
76
76
|
*/
|
|
@@ -103,7 +103,7 @@ export type StoreFixtureInput<
|
|
|
103
103
|
*
|
|
104
104
|
* Mirror of {@link StoreFixtureInput} on the output side — computed through
|
|
105
105
|
* `$InferObjectOutput<TShape, Record<never, never>>` so row types stay
|
|
106
|
-
* structurally aligned with the
|
|
106
|
+
* structurally aligned with the entity output shapes they compose against.
|
|
107
107
|
*
|
|
108
108
|
* As with {@link StoreFixtureInput}, this is a shape-based equivalent rather
|
|
109
109
|
* than the identical `z.output<TSchema>` inference path core uses directly.
|
|
@@ -433,7 +433,7 @@ export type GeneratedKeysOf<TTable extends AnyStoreTable> = Extract<
|
|
|
433
433
|
* this collapses to the same structural shape as
|
|
434
434
|
* `Omit<z.input<TTable['schema']>, GeneratedKeysOf<TTable>>`, but the
|
|
435
435
|
* shape-based form lets TypeScript prove structural equality with
|
|
436
|
-
* `CreateInputOf<
|
|
436
|
+
* `CreateInputOf<Entity, ...>` at trail boundaries without widening
|
|
437
437
|
* generic call sites to `Record<string, unknown>`.
|
|
438
438
|
*
|
|
439
439
|
* Defaulted fields remain optional because `$InferObjectInput` honors
|
|
@@ -532,7 +532,7 @@ export interface StoreAccessor<
|
|
|
532
532
|
// Compile-time assertion: StoreAccessor satisfies the core accessor protocol.
|
|
533
533
|
//
|
|
534
534
|
// `@ontrails/core/store` declares a structural protocol that `deriveTrail()`
|
|
535
|
-
// uses to synthesize default
|
|
535
|
+
// uses to synthesize default implementations without importing `@ontrails/store`. We
|
|
536
536
|
// pin the relationship here rather than in core so that any drift between
|
|
537
537
|
// the two shapes fails the store build immediately. If this check fails, the
|
|
538
538
|
// protocol in core has diverged from the store accessor contract — fix the
|