@ontrails/store 1.0.0-beta.32 → 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 +50 -0
- package/README.md +1 -1
- package/package.json +7 -2
- package/src/adapter-support.ts +16 -1
- package/src/trails/crud.ts +110 -43
- package/src/trails/index.ts +6 -1
- package/src/trails/reconcile.ts +24 -6
- package/src/trails/sync.ts +46 -23
- package/src/trails/utils.ts +37 -16
- package/src/types.ts +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
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
|
+
|
|
34
|
+
## 1.0.0-beta.39
|
|
35
|
+
|
|
36
|
+
### Patch Changes
|
|
37
|
+
|
|
38
|
+
- [`f7ec225`](https://github.com/outfitter-dev/trails/commit/f7ec225c01482f8fb55afd174add3d961a63171b): `sync()` gains the factory-contract options `crud()` and `reconcile()` received in TRL-1195: a `permit` option declared on the produced trail, and per-endpoint `contour` options on `SyncEndpoint` so a `crud()` bundle's table contour can be shared instead of colliding as a duplicate registration at `topo()`.
|
|
39
|
+
- [`5a38c73`](https://github.com/outfitter-dev/trails/commit/5a38c73092f81612769be4b44944d828c3436e07): Complete the store factory trail contracts (TRL-1195, absorbing TRL-1177 and TRL-1178). `crud()` gains `permit` (applied to every produced trail) and `permits` (per-operation overrides, so destroy trails satisfy permit governance) plus a `contour` option, and the returned tuple now exposes the table contour it registered as a `contour` property. `reconcile()` gains `permit` and accepts a shared `contour` instance, so crud + reconcile on one table register cleanly in a single `topo()` instead of colliding on a duplicate contour name. `TableContour` is exported from `@ontrails/store/trails`. Consuming apps no longer need to post-process factory trails to attach permits or strip contours.
|
|
40
|
+
|
|
41
|
+
## 1.0.0-beta.38
|
|
42
|
+
|
|
43
|
+
## 1.0.0-beta.37
|
|
44
|
+
|
|
45
|
+
## 1.0.0-beta.36
|
|
46
|
+
|
|
47
|
+
## 1.0.0-beta.35
|
|
48
|
+
|
|
49
|
+
## 1.0.0-beta.34
|
|
50
|
+
|
|
51
|
+
## 1.0.0-beta.33
|
|
52
|
+
|
|
3
53
|
## 1.0.0-beta.32
|
|
4
54
|
|
|
5
55
|
### 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
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Implementation,
|
|
3
|
+
PermitRequirement,
|
|
4
|
+
Resource,
|
|
5
|
+
Trail,
|
|
6
|
+
} from '@ontrails/core';
|
|
2
7
|
import { deriveTrail } from '@ontrails/core/trails';
|
|
3
8
|
import type {
|
|
4
9
|
DeriveTrailInput,
|
|
@@ -16,8 +21,8 @@ import type {
|
|
|
16
21
|
UpdateOf,
|
|
17
22
|
} from '../types.js';
|
|
18
23
|
import type { CrudOperation } from '../crud-doctrine.js';
|
|
19
|
-
import {
|
|
20
|
-
import type {
|
|
24
|
+
import { assertCurrentEntityOption, createTableEntity } from './utils.js';
|
|
25
|
+
import type { TableEntity } from './utils.js';
|
|
21
26
|
|
|
22
27
|
type IdentityInputOf<TTable extends AnyStoreTable> = Readonly<
|
|
23
28
|
Record<Extract<TTable['identity'], string>, StoreIdentifierOf<TTable>>
|
|
@@ -27,27 +32,27 @@ type CrudConnection<TTable extends AnyStoreTable> = Readonly<
|
|
|
27
32
|
Record<TTable['name'], StoreAccessor<TTable>>
|
|
28
33
|
>;
|
|
29
34
|
|
|
30
|
-
type
|
|
31
|
-
keyof z.output<
|
|
35
|
+
type TableEntityFieldKey<TTable extends AnyStoreTable> = Extract<
|
|
36
|
+
keyof z.output<TableEntity<TTable>>,
|
|
32
37
|
string
|
|
33
38
|
>;
|
|
34
39
|
|
|
35
40
|
type GeneratedFieldsOf<TTable extends AnyStoreTable> =
|
|
36
|
-
TTable['generated'] extends readonly
|
|
41
|
+
TTable['generated'] extends readonly TableEntityFieldKey<TTable>[]
|
|
37
42
|
? TTable['generated']
|
|
38
43
|
: readonly [];
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
46
|
* Input type `deriveTrail` projects for a given CRUD operation against a
|
|
42
|
-
* store table. Uses `
|
|
43
|
-
* structurally matches the
|
|
47
|
+
* store table. Uses `TableEntity<TTable>` so the projected input
|
|
48
|
+
* structurally matches the entity-backed derivation path in
|
|
44
49
|
* `@ontrails/core`'s `deriveTrail`.
|
|
45
50
|
*/
|
|
46
51
|
type DerivedInput<
|
|
47
52
|
TTable extends AnyStoreTable,
|
|
48
53
|
TOperation extends CrudOperation,
|
|
49
54
|
> = DeriveTrailInput<
|
|
50
|
-
|
|
55
|
+
TableEntity<TTable>,
|
|
51
56
|
TOperation,
|
|
52
57
|
GeneratedFieldsOf<TTable>
|
|
53
58
|
>;
|
|
@@ -59,7 +64,7 @@ type DerivedInput<
|
|
|
59
64
|
type DerivedOutput<
|
|
60
65
|
TTable extends AnyStoreTable,
|
|
61
66
|
TOperation extends CrudOperation,
|
|
62
|
-
> = DeriveTrailOutput<
|
|
67
|
+
> = DeriveTrailOutput<TableEntity<TTable>, TOperation>;
|
|
63
68
|
|
|
64
69
|
type InternalCreateTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
65
70
|
DerivedInput<TTable, 'create'>,
|
|
@@ -137,9 +142,17 @@ export type CrudTrails<TTable extends AnyStoreTable> = readonly [
|
|
|
137
142
|
update: UpdateTrailOf<TTable>,
|
|
138
143
|
remove: DeleteTrailOf<TTable>,
|
|
139
144
|
list: ListTrailOf<TTable>,
|
|
140
|
-
]
|
|
145
|
+
] & {
|
|
146
|
+
/**
|
|
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
|
+
* same-named rebuilds as duplicates.
|
|
151
|
+
*/
|
|
152
|
+
readonly entity: TableEntity<TTable>;
|
|
153
|
+
};
|
|
141
154
|
|
|
142
|
-
export interface
|
|
155
|
+
export interface CrudImplementationOverrides<TTable extends AnyStoreTable> {
|
|
143
156
|
readonly create?: Implementation<InsertOf<TTable>, EntityOf<TTable>>;
|
|
144
157
|
readonly read?: Implementation<IdentityInputOf<TTable>, EntityOf<TTable>>;
|
|
145
158
|
readonly update?: Implementation<
|
|
@@ -151,10 +164,28 @@ export interface CrudBlazeOverrides<TTable extends AnyStoreTable> {
|
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
export interface CrudOptions<TTable extends AnyStoreTable> {
|
|
154
|
-
readonly
|
|
167
|
+
readonly implementation?: CrudImplementationOverrides<TTable>;
|
|
168
|
+
/**
|
|
169
|
+
* Existing table entity to register on the produced trails. When
|
|
170
|
+
* omitted, the factory builds one from the table. Pass a shared
|
|
171
|
+
* instance when another factory (e.g. `reconcile()`) covers the same
|
|
172
|
+
* table so `topo()` sees a single entity registration.
|
|
173
|
+
*/
|
|
174
|
+
readonly entity?: TableEntity<TTable>;
|
|
175
|
+
/**
|
|
176
|
+
* Permit requirement declared on every produced trail. Factory trails
|
|
177
|
+
* carry authored defaults like any hand-written trail; per-operation
|
|
178
|
+
* entries in `permits` override this baseline.
|
|
179
|
+
*/
|
|
180
|
+
readonly permit?: PermitRequirement;
|
|
181
|
+
/**
|
|
182
|
+
* Per-operation permit overrides. At minimum, destroy-intent trails
|
|
183
|
+
* (`delete`) need a declaration to satisfy permit governance.
|
|
184
|
+
*/
|
|
185
|
+
readonly permits?: Partial<Record<CrudOperation, PermitRequirement>>;
|
|
155
186
|
}
|
|
156
187
|
|
|
157
|
-
interface
|
|
188
|
+
interface InternalCrudImplementationOverrides<TTable extends AnyStoreTable> {
|
|
158
189
|
readonly create?: Implementation<
|
|
159
190
|
DerivedInput<TTable, 'create'>,
|
|
160
191
|
DerivedOutput<TTable, 'create'>
|
|
@@ -178,7 +209,10 @@ interface InternalCrudBlazeOverrides<TTable extends AnyStoreTable> {
|
|
|
178
209
|
}
|
|
179
210
|
|
|
180
211
|
interface InternalCrudOptions<TTable extends AnyStoreTable> {
|
|
181
|
-
readonly
|
|
212
|
+
readonly implementation?: InternalCrudImplementationOverrides<TTable>;
|
|
213
|
+
readonly entity?: TableEntity<TTable>;
|
|
214
|
+
readonly permit?: PermitRequirement;
|
|
215
|
+
readonly permits?: Partial<Record<CrudOperation, PermitRequirement>>;
|
|
182
216
|
}
|
|
183
217
|
|
|
184
218
|
const normalizeExampleForOutput = <TInput, TOutput>(
|
|
@@ -222,14 +256,17 @@ const normalizeExamplesForOutput = <TInput, TOutput>(
|
|
|
222
256
|
const finalizeTrail = <TInput, TOutput>(
|
|
223
257
|
base: Trail<TInput, TOutput>,
|
|
224
258
|
options: {
|
|
225
|
-
readonly
|
|
259
|
+
readonly implementation?: Implementation<TInput, TOutput> | undefined;
|
|
226
260
|
readonly output?: z.ZodType<TOutput> | undefined;
|
|
227
261
|
readonly pattern?: string | undefined;
|
|
262
|
+
readonly permit?: PermitRequirement | undefined;
|
|
228
263
|
} = {}
|
|
229
264
|
): Trail<TInput, TOutput> =>
|
|
230
265
|
Object.freeze({
|
|
231
266
|
...base,
|
|
232
|
-
...(options.
|
|
267
|
+
...(options.implementation === undefined
|
|
268
|
+
? {}
|
|
269
|
+
: { implementation: options.implementation }),
|
|
233
270
|
...(options.output === undefined
|
|
234
271
|
? {}
|
|
235
272
|
: {
|
|
@@ -237,6 +274,7 @@ const finalizeTrail = <TInput, TOutput>(
|
|
|
237
274
|
output: options.output,
|
|
238
275
|
}),
|
|
239
276
|
...(options.pattern === undefined ? {} : { pattern: options.pattern }),
|
|
277
|
+
...(options.permit === undefined ? {} : { permit: options.permit }),
|
|
240
278
|
}) as Trail<TInput, TOutput>;
|
|
241
279
|
|
|
242
280
|
const deriveCrudBaseTrails = <
|
|
@@ -244,36 +282,36 @@ const deriveCrudBaseTrails = <
|
|
|
244
282
|
TConnection extends CrudConnection<TTable>,
|
|
245
283
|
>(
|
|
246
284
|
table: TTable,
|
|
247
|
-
resource: Resource<TConnection
|
|
285
|
+
resource: Resource<TConnection>,
|
|
286
|
+
tableEntity: TableEntity<TTable>
|
|
248
287
|
): InternalCrudBaseTrails<TTable> => {
|
|
249
|
-
|
|
250
|
-
// Narrow the store's `readonly string[]` to the contour's typed field-key
|
|
288
|
+
// Narrow the store's `readonly string[]` to the entity's typed field-key
|
|
251
289
|
// array so `deriveTrail`'s `TGenerated` generic picks up the precise
|
|
252
|
-
// key-of shape that `CreateInputOf<
|
|
290
|
+
// key-of shape that `CreateInputOf<Entity, TGenerated>` expects. The
|
|
253
291
|
// runtime value is unchanged — the names in `table.generated` are already
|
|
254
292
|
// keys of `table.schema.shape` by construction in `store()`.
|
|
255
293
|
const generated = table.generated as GeneratedFieldsOf<TTable>;
|
|
256
294
|
|
|
257
295
|
return {
|
|
258
|
-
createBase: deriveTrail(
|
|
296
|
+
createBase: deriveTrail(tableEntity, 'create', {
|
|
259
297
|
generated,
|
|
260
298
|
resource,
|
|
261
299
|
}),
|
|
262
|
-
deleteBase: deriveTrail(
|
|
300
|
+
deleteBase: deriveTrail(tableEntity, 'delete', {
|
|
263
301
|
resource,
|
|
264
302
|
}),
|
|
265
|
-
listBase: deriveTrail(
|
|
303
|
+
listBase: deriveTrail(tableEntity, 'list', {
|
|
266
304
|
resource,
|
|
267
305
|
}),
|
|
268
|
-
readBase: deriveTrail(
|
|
306
|
+
readBase: deriveTrail(tableEntity, 'read', {
|
|
269
307
|
resource,
|
|
270
308
|
}),
|
|
271
|
-
// The `update`
|
|
309
|
+
// The `update` implementation synthesized by `deriveTrail` handles the partial-patch
|
|
272
310
|
// concern: when the accessor lacks a native `update`, the fallback path in
|
|
273
311
|
// `derive-trail.ts` (`updateViaReadAndUpsert`) reads the current entity,
|
|
274
312
|
// merges the patch, strips the `version` field, then calls `upsert` with
|
|
275
313
|
// the full merged payload — so no fields are silently lost.
|
|
276
|
-
updateBase: deriveTrail(
|
|
314
|
+
updateBase: deriveTrail(tableEntity, 'update', {
|
|
277
315
|
generated,
|
|
278
316
|
resource,
|
|
279
317
|
}),
|
|
@@ -282,46 +320,67 @@ const deriveCrudBaseTrails = <
|
|
|
282
320
|
|
|
283
321
|
const buildCrudTrails = <TTable extends AnyStoreTable>(
|
|
284
322
|
baseTrails: InternalCrudBaseTrails<TTable>,
|
|
285
|
-
|
|
323
|
+
options: InternalCrudOptions<TTable>,
|
|
286
324
|
entityOutput: z.ZodType<DerivedOutput<TTable, 'create'>>,
|
|
287
325
|
listOutput: z.ZodType<DerivedOutput<TTable, 'list'>>
|
|
288
|
-
): InternalCrudTrails<TTable> =>
|
|
289
|
-
|
|
326
|
+
): InternalCrudTrails<TTable> => {
|
|
327
|
+
const overrides = options.implementation ?? {};
|
|
328
|
+
const permitFor = (operation: CrudOperation): PermitRequirement | undefined =>
|
|
329
|
+
options.permits?.[operation] ?? options.permit;
|
|
330
|
+
|
|
331
|
+
return Object.freeze([
|
|
290
332
|
finalizeTrail(baseTrails.createBase, {
|
|
291
|
-
...(overrides.create === undefined
|
|
333
|
+
...(overrides.create === undefined
|
|
334
|
+
? {}
|
|
335
|
+
: { implementation: overrides.create }),
|
|
292
336
|
output: entityOutput,
|
|
293
337
|
pattern: 'crud',
|
|
338
|
+
permit: permitFor('create'),
|
|
294
339
|
}),
|
|
295
340
|
finalizeTrail(baseTrails.readBase, {
|
|
296
|
-
...(overrides.read === undefined
|
|
341
|
+
...(overrides.read === undefined
|
|
342
|
+
? {}
|
|
343
|
+
: { implementation: overrides.read }),
|
|
297
344
|
output: entityOutput,
|
|
298
345
|
pattern: 'crud',
|
|
346
|
+
permit: permitFor('read'),
|
|
299
347
|
}),
|
|
300
348
|
finalizeTrail(baseTrails.updateBase, {
|
|
301
|
-
...(overrides.update === undefined
|
|
349
|
+
...(overrides.update === undefined
|
|
350
|
+
? {}
|
|
351
|
+
: { implementation: overrides.update }),
|
|
302
352
|
output: entityOutput,
|
|
303
353
|
pattern: 'crud',
|
|
354
|
+
permit: permitFor('update'),
|
|
304
355
|
}),
|
|
305
356
|
overrides.delete === undefined
|
|
306
|
-
? finalizeTrail(baseTrails.deleteBase, {
|
|
357
|
+
? finalizeTrail(baseTrails.deleteBase, {
|
|
358
|
+
pattern: 'crud',
|
|
359
|
+
permit: permitFor('delete'),
|
|
360
|
+
})
|
|
307
361
|
: finalizeTrail(baseTrails.deleteBase, {
|
|
308
|
-
|
|
362
|
+
implementation: overrides.delete,
|
|
309
363
|
pattern: 'crud',
|
|
364
|
+
permit: permitFor('delete'),
|
|
310
365
|
}),
|
|
311
366
|
finalizeTrail(baseTrails.listBase, {
|
|
312
|
-
...(overrides.list === undefined
|
|
367
|
+
...(overrides.list === undefined
|
|
368
|
+
? {}
|
|
369
|
+
: { implementation: overrides.list }),
|
|
313
370
|
output: listOutput,
|
|
314
371
|
pattern: 'crud',
|
|
372
|
+
permit: permitFor('list'),
|
|
315
373
|
}),
|
|
316
374
|
]) as InternalCrudTrails<TTable>;
|
|
375
|
+
};
|
|
317
376
|
|
|
318
377
|
/**
|
|
319
378
|
* Produce the standard CRUD trail tuple for one normalized store table.
|
|
320
379
|
*
|
|
321
|
-
* The factory derives schemas, examples, resources, and
|
|
322
|
-
* 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
|
|
323
382
|
* contract via `deriveTrail()`'s single-resource synthesis path. Per-operation
|
|
324
|
-
*
|
|
383
|
+
* implementation overrides stay available for callers that need custom persistence
|
|
325
384
|
* behavior and are layered onto the derived trails in a single pass.
|
|
326
385
|
*/
|
|
327
386
|
export function crud<
|
|
@@ -340,11 +399,12 @@ export function crud<
|
|
|
340
399
|
resource: Resource<TConnection>,
|
|
341
400
|
options: InternalCrudOptions<TTable> = {}
|
|
342
401
|
) {
|
|
343
|
-
|
|
344
|
-
const
|
|
402
|
+
assertCurrentEntityOption(options, 'crud() options');
|
|
403
|
+
const tableEntity = options.entity ?? createTableEntity(table);
|
|
404
|
+
const baseTrails = deriveCrudBaseTrails(table, resource, tableEntity);
|
|
345
405
|
// Narrow `table.schema` (typed `StoreObjectSchema`, which is
|
|
346
406
|
// `z.ZodObject<Record<string, z.ZodType>>`) to a ZodObject keyed by the
|
|
347
|
-
// concrete shape so its `z.output` unifies with the
|
|
407
|
+
// concrete shape so its `z.output` unifies with the entity-derived
|
|
348
408
|
// output. Structurally `table.schema` already has `shape:
|
|
349
409
|
// TTable['schema']['shape']` — this only refines the generic parameter.
|
|
350
410
|
const entitySchema = table.schema as z.ZodObject<TTable['schema']['shape']>;
|
|
@@ -352,5 +412,12 @@ export function crud<
|
|
|
352
412
|
const listOutput: z.ZodType<DerivedOutput<TTable, 'list'>> =
|
|
353
413
|
entitySchema.array();
|
|
354
414
|
|
|
355
|
-
|
|
415
|
+
const trails = buildCrudTrails(baseTrails, options, entityOutput, listOutput);
|
|
416
|
+
// Expose the registered entity so other factories over the same table
|
|
417
|
+
// (reconcile, sync) can share the instance instead of rebuilding it.
|
|
418
|
+
return Object.freeze(
|
|
419
|
+
Object.assign([...trails], { entity: tableEntity })
|
|
420
|
+
) as unknown as InternalCrudTrails<TTable> & {
|
|
421
|
+
readonly entity: TableEntity<TTable>;
|
|
422
|
+
};
|
|
356
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,3 +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';
|
|
20
|
+
export type { TableEntity } from './utils.js';
|
package/src/trails/reconcile.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ConflictError, Result, ValidationError, trail } from '@ontrails/core';
|
|
|
2
2
|
import type {
|
|
3
3
|
AnySignal,
|
|
4
4
|
Detour,
|
|
5
|
+
PermitRequirement,
|
|
5
6
|
Resource,
|
|
6
7
|
Trail,
|
|
7
8
|
TrailContext,
|
|
@@ -17,7 +18,12 @@ import type {
|
|
|
17
18
|
UpsertOf,
|
|
18
19
|
} from '../types.js';
|
|
19
20
|
import { versionFieldName } from '../store.js';
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
assertCurrentEntityOption,
|
|
23
|
+
createTableEntity,
|
|
24
|
+
mapStoreTrailError,
|
|
25
|
+
} from './utils.js';
|
|
26
|
+
import type { TableEntity } from './utils.js';
|
|
21
27
|
|
|
22
28
|
type ReconcileConnection<TTable extends AnyStoreTable> = Readonly<
|
|
23
29
|
Record<TTable['name'], StoreAccessor<TTable>>
|
|
@@ -39,9 +45,19 @@ export interface ReconcileOptions<
|
|
|
39
45
|
TTable extends AnyStoreTable,
|
|
40
46
|
TConnection extends ReconcileConnection<TTable>,
|
|
41
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>;
|
|
42
56
|
readonly description?: string;
|
|
43
57
|
readonly id?: string;
|
|
44
58
|
readonly on?: readonly (AnySignal | string)[];
|
|
59
|
+
/** Permit requirement declared on the reconcile trail. */
|
|
60
|
+
readonly permit?: PermitRequirement;
|
|
45
61
|
readonly resource: Resource<TConnection>;
|
|
46
62
|
readonly strategy?: ReconcileStrategy<TTable>;
|
|
47
63
|
readonly table: TTable;
|
|
@@ -177,8 +193,8 @@ const buildReconcileInputSchema = <TTable extends AnyStoreTable>(
|
|
|
177
193
|
[versionFieldName]: z.number().int(),
|
|
178
194
|
}) as unknown as z.ZodType<UpsertOf<TTable>>;
|
|
179
195
|
|
|
180
|
-
/** The
|
|
181
|
-
const
|
|
196
|
+
/** The implementation performs only the initial upsert; conflict recovery is handled by the detour. */
|
|
197
|
+
const createReconcileImplementation =
|
|
182
198
|
<
|
|
183
199
|
TTable extends AnyStoreTable,
|
|
184
200
|
TConnection extends ReconcileConnection<TTable>,
|
|
@@ -253,6 +269,7 @@ export const reconcile = <
|
|
|
253
269
|
>(
|
|
254
270
|
options: ReconcileOptions<TTable, TConnection>
|
|
255
271
|
): Trail<UpsertOf<TTable>, EntityOf<TTable>> => {
|
|
272
|
+
assertCurrentEntityOption(options, 'reconcile() options');
|
|
256
273
|
if (!options.table.versioned) {
|
|
257
274
|
throw new ValidationError(
|
|
258
275
|
`reconcile("${options.table.name}") requires a versioned store table.`
|
|
@@ -260,22 +277,23 @@ export const reconcile = <
|
|
|
260
277
|
}
|
|
261
278
|
|
|
262
279
|
const id = options.id ?? `${options.table.name}.reconcile`;
|
|
263
|
-
const
|
|
280
|
+
const tableEntity = options.entity ?? createTableEntity(options.table);
|
|
264
281
|
const strategy = options.strategy ?? 'last-write-wins';
|
|
265
282
|
|
|
266
283
|
return trail(id, {
|
|
267
|
-
blaze: createReconcileBlaze(options, id),
|
|
268
|
-
contours: [entityContour],
|
|
269
284
|
description:
|
|
270
285
|
options.description ??
|
|
271
286
|
`Reconcile version conflicts for "${options.table.name}" entities.`,
|
|
272
287
|
detours: [createReconcileDetour(options, id, strategy)],
|
|
288
|
+
entities: [tableEntity],
|
|
273
289
|
examples: deriveExamples(options.table),
|
|
290
|
+
implementation: createReconcileImplementation(options, id),
|
|
274
291
|
input: buildReconcileInputSchema(options.table),
|
|
275
292
|
intent: 'write',
|
|
276
293
|
on: options.on,
|
|
277
294
|
output: options.table.schema as unknown as z.ZodType<EntityOf<TTable>>,
|
|
278
295
|
pattern: 'reconcile',
|
|
296
|
+
...(options.permit === undefined ? {} : { permit: options.permit }),
|
|
279
297
|
resources: [options.resource],
|
|
280
298
|
});
|
|
281
299
|
};
|
package/src/trails/sync.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { InternalError, NotFoundError, Result, trail } from '@ontrails/core';
|
|
2
2
|
import type {
|
|
3
3
|
AnySignal,
|
|
4
|
+
PermitRequirement,
|
|
4
5
|
Resource,
|
|
5
6
|
Trail,
|
|
6
7
|
TrailContext,
|
|
@@ -16,7 +17,12 @@ import type {
|
|
|
16
17
|
StoreIdentifierOf,
|
|
17
18
|
UpsertOf,
|
|
18
19
|
} from '../types.js';
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
assertCurrentEntityOption,
|
|
22
|
+
createTableEntity,
|
|
23
|
+
mapStoreTrailError,
|
|
24
|
+
} from './utils.js';
|
|
25
|
+
import type { TableEntity } from './utils.js';
|
|
20
26
|
|
|
21
27
|
type IdentityInputOf<TTable extends AnyStoreTable> = Readonly<
|
|
22
28
|
Record<Extract<TTable['identity'], string>, StoreIdentifierOf<TTable>>
|
|
@@ -34,6 +40,14 @@ export interface SyncEndpoint<
|
|
|
34
40
|
TTable extends AnyStoreTable,
|
|
35
41
|
TConnection extends SourceConnection<TTable> | TargetConnection<TTable>,
|
|
36
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>;
|
|
37
51
|
readonly resource: Resource<TConnection>;
|
|
38
52
|
readonly table: TTable;
|
|
39
53
|
}
|
|
@@ -56,6 +70,11 @@ export interface SyncOptions<
|
|
|
56
70
|
readonly from: SyncEndpoint<TSourceTable, TSourceConnection>;
|
|
57
71
|
readonly id?: string;
|
|
58
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;
|
|
59
78
|
readonly to: SyncEndpoint<TTargetTable, TTargetConnection>;
|
|
60
79
|
readonly transform?: SyncTransform<TSourceTable, TTargetTable>;
|
|
61
80
|
}
|
|
@@ -174,22 +193,39 @@ export const sync = <
|
|
|
174
193
|
TTargetConnection
|
|
175
194
|
>
|
|
176
195
|
): Trail<IdentityInputOf<TSourceTable>, EntityOf<TTargetTable>> => {
|
|
196
|
+
assertCurrentEntityOption(options.from, 'sync() from options');
|
|
197
|
+
assertCurrentEntityOption(options.to, 'sync() to options');
|
|
177
198
|
const id = options.id ?? `${options.to.table.name}.sync`;
|
|
178
|
-
const
|
|
179
|
-
|
|
199
|
+
const sourceEntity =
|
|
200
|
+
options.from.entity ?? createTableEntity(options.from.table);
|
|
201
|
+
const targetEntity = options.to.entity ?? createTableEntity(options.to.table);
|
|
180
202
|
|
|
181
203
|
return trail(id, {
|
|
182
|
-
|
|
183
|
-
|
|
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) => {
|
|
184
220
|
try {
|
|
185
221
|
const identifier = input[
|
|
186
222
|
options.from.table.identity as keyof typeof input
|
|
187
223
|
] as StoreIdentifierOf<TSourceTable>;
|
|
188
|
-
const
|
|
224
|
+
const sourceRecord = await resolveSourceAccessor(options.from, ctx).get(
|
|
189
225
|
identifier
|
|
190
226
|
);
|
|
191
227
|
|
|
192
|
-
if (
|
|
228
|
+
if (sourceRecord === null) {
|
|
193
229
|
return Result.err(sourceMissingError(options.from.table, identifier));
|
|
194
230
|
}
|
|
195
231
|
|
|
@@ -201,7 +237,7 @@ export const sync = <
|
|
|
201
237
|
// a mismatched payload.
|
|
202
238
|
const next =
|
|
203
239
|
options.transform === undefined
|
|
204
|
-
? options.to.table.fixtureSchema.safeParse(
|
|
240
|
+
? options.to.table.fixtureSchema.safeParse(sourceRecord)
|
|
205
241
|
: undefined;
|
|
206
242
|
|
|
207
243
|
if (next !== undefined && !next.success) {
|
|
@@ -215,7 +251,7 @@ export const sync = <
|
|
|
215
251
|
const payload =
|
|
216
252
|
options.transform === undefined
|
|
217
253
|
? (next?.data as unknown as UpsertOf<TTargetTable>)
|
|
218
|
-
: await options.transform(
|
|
254
|
+
: await options.transform(sourceRecord, ctx);
|
|
219
255
|
|
|
220
256
|
const synced = await resolveTargetAccessor(options.to, ctx).upsert(
|
|
221
257
|
payload
|
|
@@ -225,20 +261,6 @@ export const sync = <
|
|
|
225
261
|
return Result.err(mapStoreTrailError(id, error));
|
|
226
262
|
}
|
|
227
263
|
},
|
|
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
264
|
input: identityInputSchema(options.from.table),
|
|
243
265
|
intent: 'write',
|
|
244
266
|
on: options.on,
|
|
@@ -246,6 +268,7 @@ export const sync = <
|
|
|
246
268
|
EntityOf<TTargetTable>
|
|
247
269
|
>,
|
|
248
270
|
pattern: 'sync',
|
|
271
|
+
...(options.permit === undefined ? {} : { permit: options.permit }),
|
|
249
272
|
resources: [options.from.resource, options.to.resource],
|
|
250
273
|
});
|
|
251
274
|
};
|
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
|