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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +144 -63
  3. package/package.json +14 -9
  4. package/src/adapter-support.ts +163 -0
  5. package/src/crud-doctrine.ts +43 -0
  6. package/src/index.ts +17 -8
  7. package/src/jsonfile/index.ts +6 -0
  8. package/src/jsonfile/runtime.ts +700 -0
  9. package/src/jsonfile/types.ts +50 -0
  10. package/src/store.ts +298 -137
  11. package/src/testing.ts +175 -0
  12. package/src/trails/crud.ts +356 -0
  13. package/src/trails/index.ts +15 -0
  14. package/src/trails/reconcile.ts +281 -0
  15. package/src/trails/sync.ts +251 -0
  16. package/src/trails/utils.ts +96 -0
  17. package/src/types.ts +361 -68
  18. package/.agents/notes/2026-04-04/handoff-202604032309-9e85a104.md +0 -38
  19. package/.turbo/turbo-build.log +0 -1
  20. package/.turbo/turbo-lint.log +0 -3
  21. package/.turbo/turbo-typecheck.log +0 -1
  22. package/dist/drizzle/index.d.ts +0 -3
  23. package/dist/drizzle/index.d.ts.map +0 -1
  24. package/dist/drizzle/index.js +0 -2
  25. package/dist/drizzle/index.js.map +0 -1
  26. package/dist/drizzle/runtime.d.ts +0 -21
  27. package/dist/drizzle/runtime.d.ts.map +0 -1
  28. package/dist/drizzle/runtime.js +0 -458
  29. package/dist/drizzle/runtime.js.map +0 -1
  30. package/dist/drizzle/schema.d.ts +0 -15
  31. package/dist/drizzle/schema.d.ts.map +0 -1
  32. package/dist/drizzle/schema.js +0 -322
  33. package/dist/drizzle/schema.js.map +0 -1
  34. package/dist/drizzle/types.d.ts +0 -40
  35. package/dist/drizzle/types.d.ts.map +0 -1
  36. package/dist/drizzle/types.js +0 -2
  37. package/dist/drizzle/types.js.map +0 -1
  38. package/dist/index.d.ts +0 -3
  39. package/dist/index.d.ts.map +0 -1
  40. package/dist/index.js +0 -2
  41. package/dist/index.js.map +0 -1
  42. package/dist/store.d.ts +0 -26
  43. package/dist/store.d.ts.map +0 -1
  44. package/dist/store.js +0 -192
  45. package/dist/store.js.map +0 -1
  46. package/dist/types.d.ts +0 -224
  47. package/dist/types.d.ts.map +0 -1
  48. package/dist/types.js +0 -2
  49. package/dist/types.js.map +0 -1
  50. package/src/__tests__/store.test.ts +0 -333
  51. package/src/drizzle/__tests__/drizzle.test.ts +0 -469
  52. package/src/drizzle/index.ts +0 -17
  53. package/src/drizzle/runtime.ts +0 -853
  54. package/src/drizzle/schema.ts +0 -577
  55. package/src/drizzle/types.ts +0 -70
  56. package/tsconfig.json +0 -9
  57. package/tsconfig.tsbuildinfo +0 -1
package/src/types.ts CHANGED
@@ -1,5 +1,19 @@
1
+ import type { Signal } from '@ontrails/core';
2
+ import type { StoreAccessorProtocol } from '@ontrails/core/store';
1
3
  import type { z } from 'zod';
2
4
 
5
+ /**
6
+ * Backend-agnostic persistence shapes that an adapter can interpret.
7
+ */
8
+ export type StoreKind = 'tabular' | 'document' | 'file' | 'kv' | 'cache';
9
+
10
+ /**
11
+ * Store-level options applied to the authored definition.
12
+ */
13
+ export interface StoreOptions {
14
+ readonly kind?: StoreKind;
15
+ }
16
+
3
17
  /**
4
18
  * Object schema accepted by the store definition layer.
5
19
  */
@@ -13,83 +27,176 @@ export type StoreFieldKey<TSchema extends StoreObjectSchema> = Extract<
13
27
  string
14
28
  >;
15
29
 
30
+ type VersionedSchema<
31
+ TSchema extends StoreObjectSchema,
32
+ TVersioned extends boolean | undefined,
33
+ > = TVersioned extends true
34
+ ? TSchema extends z.ZodObject<infer TShape>
35
+ ? z.ZodObject<TShape & { version: z.ZodNumber }>
36
+ : never
37
+ : TSchema;
38
+
16
39
  type GeneratedFieldNames<
17
40
  TSchema extends StoreObjectSchema,
18
- TGenerated extends readonly StoreFieldKey<TSchema>[] | undefined,
41
+ TGenerated extends readonly string[] | undefined,
19
42
  > = TGenerated extends readonly StoreFieldKey<TSchema>[]
20
43
  ? TGenerated[number]
21
44
  : never;
22
45
 
46
+ // We intentionally use Zod v4's shape-routed object infer helpers here because
47
+ // the public z.input/z.output path widens away the generic equality we need at
48
+ // store/core trail boundaries. These $-prefixed helpers are semi-internal Zod
49
+ // v4 API; that maintenance tradeoff is acceptable here because the public path
50
+ // does not preserve the boundary-proof shape we need.
51
+ type ObjectInputOf<TShape extends z.ZodRawShape> = z.core.$InferObjectInput<
52
+ TShape,
53
+ Record<never, never>
54
+ >;
55
+
56
+ type ObjectOutputOf<TShape extends z.ZodRawShape> = z.core.$InferObjectOutput<
57
+ TShape,
58
+ Record<never, never>
59
+ >;
60
+
23
61
  /**
24
62
  * Seed row accepted for one table fixture.
25
63
  *
26
64
  * Generated fields may be supplied explicitly, but they are optional so test
27
65
  * fixtures can omit timestamps and similar server-managed values when the mock
28
66
  * store can synthesize them.
67
+ *
68
+ * Routes input inference through the object's `shape` via a conditional
69
+ * `infer` so fixture inputs stay structurally aligned with the derived
70
+ * create/upsert inputs they meet at store/core trail boundaries.
71
+ *
72
+ * This is not the same type-level path core's `deriveTrail()` uses — core
73
+ * still goes through `z.input<TContour>` / `z.output<TContour>` — but at
74
+ * concrete instantiations it collapses to the same structural fixture shape
75
+ * while preserving generic equality across the store/core seam.
29
76
  */
30
77
  export type StoreFixtureInput<
31
78
  TSchema extends StoreObjectSchema,
32
- TGenerated extends readonly StoreFieldKey<TSchema>[] | undefined =
33
- | readonly StoreFieldKey<TSchema>[]
79
+ TGenerated extends readonly string[] | undefined =
80
+ | readonly string[]
34
81
  | undefined,
35
- > = Omit<
36
- z.input<TSchema>,
37
- Extract<GeneratedFieldNames<TSchema, TGenerated>, keyof z.input<TSchema>>
38
- > &
39
- Partial<
40
- Pick<
41
- z.input<TSchema>,
42
- Extract<GeneratedFieldNames<TSchema, TGenerated>, keyof z.input<TSchema>>
43
- >
44
- >;
82
+ > = [TSchema] extends [z.ZodObject<infer TShape>]
83
+ ? Omit<
84
+ ObjectInputOf<TShape>,
85
+ Extract<
86
+ GeneratedFieldNames<TSchema, TGenerated>,
87
+ keyof ObjectInputOf<TShape>
88
+ >
89
+ > &
90
+ Partial<
91
+ Pick<
92
+ ObjectInputOf<TShape>,
93
+ Extract<
94
+ GeneratedFieldNames<TSchema, TGenerated>,
95
+ keyof ObjectInputOf<TShape>
96
+ >
97
+ >
98
+ >
99
+ : never;
45
100
 
46
101
  /**
47
102
  * Normalized fixture row after schema validation and default application.
103
+ *
104
+ * Mirror of {@link StoreFixtureInput} on the output side — routed through
105
+ * `$InferObjectOutput<TShape, Record<never, never>>` so row types stay
106
+ * structurally aligned with the contour output shapes they compose against.
107
+ *
108
+ * As with {@link StoreFixtureInput}, this is a shape-routed equivalent rather
109
+ * than the identical `z.output<TSchema>` inference path core uses directly.
48
110
  */
49
111
  export type StoreFixtureRow<
50
112
  TSchema extends StoreObjectSchema,
51
- TGenerated extends readonly StoreFieldKey<TSchema>[] | undefined =
52
- | readonly StoreFieldKey<TSchema>[]
113
+ TGenerated extends readonly string[] | undefined =
114
+ | readonly string[]
53
115
  | undefined,
54
- > = Omit<
55
- z.output<TSchema>,
56
- Extract<GeneratedFieldNames<TSchema, TGenerated>, keyof z.output<TSchema>>
57
- > &
58
- Partial<
59
- Pick<
60
- z.output<TSchema>,
61
- Extract<GeneratedFieldNames<TSchema, TGenerated>, keyof z.output<TSchema>>
62
- >
63
- >;
116
+ > = [TSchema] extends [z.ZodObject<infer TShape>]
117
+ ? Omit<
118
+ ObjectOutputOf<TShape>,
119
+ Extract<
120
+ GeneratedFieldNames<TSchema, TGenerated>,
121
+ keyof ObjectOutputOf<TShape>
122
+ >
123
+ > &
124
+ Partial<
125
+ Pick<
126
+ ObjectOutputOf<TShape>,
127
+ Extract<
128
+ GeneratedFieldNames<TSchema, TGenerated>,
129
+ keyof ObjectOutputOf<TShape>
130
+ >
131
+ >
132
+ >
133
+ : never;
64
134
 
65
135
  /**
66
- * Connector-owned search metadata.
136
+ * Adapter-owned search metadata.
67
137
  *
68
138
  * The core package keeps this opaque on purpose. Search behavior is declared
69
- * here and interpreted by a concrete connector later.
139
+ * here and interpreted by a concrete adapter later.
70
140
  */
71
141
  export type StoreSearchDefinition = Readonly<Record<string, unknown>>;
72
142
 
73
143
  /**
74
- * Authored metadata for one store table.
144
+ * Change signals projected from one store entity definition.
75
145
  */
76
- export interface StoreTableInput<
146
+ export interface StoreTableSignals<TPayload> {
147
+ readonly created: Signal<TPayload>;
148
+ readonly updated: Signal<TPayload>;
149
+ readonly removed: Signal<TPayload>;
150
+ }
151
+
152
+ /**
153
+ * Shared fields for all store table input variants.
154
+ */
155
+ interface StoreTableInputBase<
77
156
  TSchema extends StoreObjectSchema = StoreObjectSchema,
78
157
  TGenerated extends readonly StoreFieldKey<TSchema>[] | undefined =
79
158
  | readonly StoreFieldKey<TSchema>[]
80
159
  | undefined,
160
+ TVersioned extends boolean | undefined = boolean | undefined,
81
161
  > {
82
- readonly fixtures?: readonly StoreFixtureInput<TSchema, TGenerated>[];
162
+ readonly fixtures?: readonly StoreFixtureInput<
163
+ VersionedSchema<TSchema, TVersioned>,
164
+ GeneratedFieldsOfShape<TSchema, TGenerated, TVersioned>
165
+ >[];
83
166
  readonly generated?: TGenerated;
167
+ readonly indexed?: readonly StoreFieldKey<TSchema>[];
84
168
  readonly indexes?: readonly StoreFieldKey<TSchema>[];
85
- readonly primaryKey: StoreFieldKey<TSchema>;
86
169
  readonly references?: Readonly<
87
170
  Partial<Record<StoreFieldKey<TSchema>, string>>
88
171
  >;
89
172
  readonly schema: TSchema;
90
173
  readonly search?: StoreSearchDefinition;
174
+ readonly versioned?: TVersioned;
91
175
  }
92
176
 
177
+ /**
178
+ * Authored metadata for one store entity.
179
+ *
180
+ * At least one of `identity` or `primaryKey` must be provided. Omitting both
181
+ * is a compile-time error — `resolveIdentity` would throw at runtime without
182
+ * this guard.
183
+ */
184
+ export type StoreTableInput<
185
+ TSchema extends StoreObjectSchema = StoreObjectSchema,
186
+ TGenerated extends readonly StoreFieldKey<TSchema>[] | undefined =
187
+ | readonly StoreFieldKey<TSchema>[]
188
+ | undefined,
189
+ TVersioned extends boolean | undefined = boolean | undefined,
190
+ > =
191
+ | (StoreTableInputBase<TSchema, TGenerated, TVersioned> & {
192
+ readonly identity: StoreFieldKey<TSchema>;
193
+ readonly primaryKey?: StoreFieldKey<TSchema>;
194
+ })
195
+ | (StoreTableInputBase<TSchema, TGenerated, TVersioned> & {
196
+ readonly identity?: StoreFieldKey<TSchema>;
197
+ readonly primaryKey: StoreFieldKey<TSchema>;
198
+ });
199
+
93
200
  /**
94
201
  * Record of authored tables passed to `store(...)`.
95
202
  */
@@ -97,25 +204,82 @@ export type StoreTablesInput = Record<
97
204
  string,
98
205
  StoreTableInput<
99
206
  StoreObjectSchema,
100
- readonly StoreFieldKey<StoreObjectSchema>[] | undefined
207
+ readonly StoreFieldKey<StoreObjectSchema>[] | undefined,
208
+ boolean | undefined
101
209
  >
102
210
  >;
103
211
 
212
+ type DeclaredGeneratedFieldsOfInput<TInput extends StoreTableInput> =
213
+ TInput['generated'] extends readonly StoreFieldKey<TInput['schema']>[]
214
+ ? TInput['generated']
215
+ : readonly [];
216
+
217
+ type GeneratedFieldsOfShape<
218
+ TSchema extends StoreObjectSchema,
219
+ TGenerated extends readonly StoreFieldKey<TSchema>[] | undefined,
220
+ TVersioned extends boolean | undefined,
221
+ > = TVersioned extends true
222
+ ? readonly [
223
+ ...(TGenerated extends readonly StoreFieldKey<TSchema>[]
224
+ ? TGenerated
225
+ : readonly []),
226
+ 'version',
227
+ ]
228
+ : TGenerated extends readonly StoreFieldKey<TSchema>[]
229
+ ? TGenerated
230
+ : readonly [];
231
+
232
+ type VersionedFieldsOfInput<TInput extends StoreTableInput> =
233
+ TInput['versioned'] extends true ? true : false;
234
+
235
+ type SchemaOfInput<TInput extends StoreTableInput> = VersionedSchema<
236
+ TInput['schema'],
237
+ VersionedFieldsOfInput<TInput>
238
+ >;
239
+
104
240
  /**
105
241
  * Preserve generated fields when present, otherwise normalize to an empty tuple.
106
242
  */
107
243
  export type GeneratedFieldsOfInput<TInput extends StoreTableInput> =
108
- TInput['generated'] extends readonly StoreFieldKey<TInput['schema']>[]
109
- ? TInput['generated']
110
- : readonly [];
244
+ GeneratedFieldsOfShape<
245
+ TInput['schema'],
246
+ DeclaredGeneratedFieldsOfInput<TInput>,
247
+ VersionedFieldsOfInput<TInput>
248
+ >;
249
+
250
+ /**
251
+ * Preserve the authored identity field.
252
+ */
253
+ export type IdentityFieldOfInput<TInput extends StoreTableInput> =
254
+ TInput['identity'] extends StoreFieldKey<TInput['schema']>
255
+ ? TInput['identity']
256
+ : TInput['primaryKey'] extends StoreFieldKey<TInput['schema']>
257
+ ? TInput['primaryKey']
258
+ : never;
111
259
 
112
260
  /**
113
- * Preserve index fields when present, otherwise normalize to an empty tuple.
261
+ * Preserve indexed fields when present, otherwise normalize to an empty tuple.
262
+ *
263
+ * At runtime `resolveIndexed` merges both `indexed` and `indexes` arrays, so
264
+ * this type mirrors that behavior: when both are present, the result is the
265
+ * union of both tuples. When only one is provided, it is used directly.
266
+ */
267
+ export type IndexedFieldsOfInput<TInput extends StoreTableInput> =
268
+ TInput['indexed'] extends readonly StoreFieldKey<TInput['schema']>[]
269
+ ? TInput['indexes'] extends readonly StoreFieldKey<TInput['schema']>[]
270
+ ? readonly [...TInput['indexed'], ...TInput['indexes']]
271
+ : TInput['indexed']
272
+ : TInput['indexes'] extends readonly StoreFieldKey<TInput['schema']>[]
273
+ ? TInput['indexes']
274
+ : readonly [];
275
+
276
+ /**
277
+ * Backward-compatible alias for code that still uses the SQL-shaped name.
114
278
  */
115
279
  export type IndexFieldsOfInput<TInput extends StoreTableInput> =
116
280
  TInput['indexes'] extends readonly StoreFieldKey<TInput['schema']>[]
117
281
  ? TInput['indexes']
118
- : readonly [];
282
+ : IndexedFieldsOfInput<TInput>;
119
283
 
120
284
  /**
121
285
  * Preserve references when present, otherwise normalize to an empty object.
@@ -132,11 +296,11 @@ export type ReferencesOfInput<TInput extends StoreTableInput> =
132
296
  */
133
297
  export type FixturesOfInput<TInput extends StoreTableInput> =
134
298
  TInput['fixtures'] extends readonly StoreFixtureInput<
135
- TInput['schema'],
299
+ SchemaOfInput<TInput>,
136
300
  GeneratedFieldsOfInput<TInput>
137
301
  >[]
138
302
  ? readonly StoreFixtureRow<
139
- TInput['schema'],
303
+ SchemaOfInput<TInput>,
140
304
  GeneratedFieldsOfInput<TInput>
141
305
  >[]
142
306
  : readonly [];
@@ -151,14 +315,18 @@ export interface StoreTable<
151
315
  readonly fixtureSchema: StoreObjectSchema;
152
316
  readonly fixtures: FixturesOfInput<TInput>;
153
317
  readonly generated: GeneratedFieldsOfInput<TInput>;
154
- readonly indexes: IndexFieldsOfInput<TInput>;
318
+ readonly identity: IdentityFieldOfInput<TInput>;
319
+ readonly indexed: IndexedFieldsOfInput<TInput>;
320
+ readonly indexes: IndexedFieldsOfInput<TInput>;
155
321
  readonly insertSchema: StoreObjectSchema;
156
322
  readonly name: TName;
157
- readonly primaryKey: TInput['primaryKey'];
323
+ readonly primaryKey: IdentityFieldOfInput<TInput>;
158
324
  readonly references: ReferencesOfInput<TInput>;
159
- readonly schema: TInput['schema'];
160
- readonly search?: TInput['search'];
325
+ readonly schema: SchemaOfInput<TInput>;
326
+ readonly search?: StoreSearchDefinition | undefined;
327
+ readonly signals: StoreTableSignals<z.output<SchemaOfInput<TInput>>>;
161
328
  readonly updateSchema: StoreObjectSchema;
329
+ readonly versioned: VersionedFieldsOfInput<TInput>;
162
330
  }
163
331
 
164
332
  /**
@@ -170,7 +338,8 @@ export interface StoreDefinition<
170
338
  readonly get: <TName extends Extract<keyof TTables, string>>(
171
339
  name: TName
172
340
  ) => StoreTable<TTables[TName], TName>;
173
- readonly kind: 'store';
341
+ readonly kind: StoreKind;
342
+ readonly signals: readonly Signal<unknown>[];
174
343
  readonly tableNames: readonly Extract<keyof TTables, string>[];
175
344
  readonly tables: {
176
345
  readonly [TName in keyof TTables]: StoreTable<
@@ -178,12 +347,13 @@ export interface StoreDefinition<
178
347
  Extract<TName, string>
179
348
  >;
180
349
  };
350
+ readonly type: 'store';
181
351
  }
182
352
 
183
353
  /**
184
354
  * Structural view of any normalized store table.
185
355
  *
186
- * This stays broad on purpose so connector packages can accept concrete store
356
+ * This stays broad on purpose so adapter packages can accept concrete store
187
357
  * definitions returned by `store(...)` without erasing their table-specific
188
358
  * types back to one canonical generic instantiation.
189
359
  */
@@ -191,6 +361,8 @@ export interface AnyStoreTable {
191
361
  readonly fixtureSchema: StoreObjectSchema;
192
362
  readonly fixtures: readonly Record<string, unknown>[];
193
363
  readonly generated: readonly string[];
364
+ readonly identity: string;
365
+ readonly indexed: readonly string[];
194
366
  readonly indexes: readonly string[];
195
367
  readonly insertSchema: StoreObjectSchema;
196
368
  readonly name: string;
@@ -198,16 +370,20 @@ export interface AnyStoreTable {
198
370
  readonly references: Readonly<Partial<Record<string, string>>>;
199
371
  readonly schema: StoreObjectSchema;
200
372
  readonly search?: StoreSearchDefinition | undefined;
373
+ readonly signals: StoreTableSignals<unknown>;
201
374
  readonly updateSchema: StoreObjectSchema;
375
+ readonly versioned: boolean;
202
376
  }
203
377
 
204
378
  /**
205
379
  * Structural view of any normalized store definition.
206
380
  */
207
381
  export interface AnyStoreDefinition {
208
- readonly kind: 'store';
382
+ readonly kind: StoreKind;
383
+ readonly signals: readonly Signal<unknown>[];
209
384
  readonly tableNames: readonly string[];
210
385
  readonly tables: Readonly<Record<string, AnyStoreTable>>;
386
+ readonly type: 'store';
211
387
  }
212
388
 
213
389
  type GeneratedFieldKeysOf<TTable extends AnyStoreTable> = readonly Extract<
@@ -237,9 +413,9 @@ export type FixtureOf<TTable extends AnyStoreTable> = StoreFixtureRow<
237
413
  >;
238
414
 
239
415
  /**
240
- * Primary-key field name for one store table.
416
+ * Identity field name for one store entity.
241
417
  */
242
- export type PrimaryKeyOf<TTable extends AnyStoreTable> = TTable['primaryKey'];
418
+ export type IdentityOf<TTable extends AnyStoreTable> = TTable['identity'];
243
419
 
244
420
  /**
245
421
  * Server-managed fields for one store table.
@@ -252,21 +428,39 @@ export type GeneratedKeysOf<TTable extends AnyStoreTable> = Extract<
252
428
  /**
253
429
  * Insert shape: entity minus generated fields, with defaulted fields optional.
254
430
  *
255
- * Uses `z.input` so that fields with `.default()` are correctly represented as
256
- * optional in the insert shape (matching the runtime insert schema behavior).
431
+ * Routes through `$InferObjectInput<TShape, Record<never, never>>` via a
432
+ * conditional `infer` on the object's `shape`. At concrete instantiations
433
+ * this collapses to the same structural shape as
434
+ * `Omit<z.input<TTable['schema']>, GeneratedKeysOf<TTable>>`, but the
435
+ * shape-routed form lets TypeScript prove structural equality with
436
+ * `CreateInputOf<Contour, ...>` at trail boundaries without widening
437
+ * generic call sites to `Record<string, unknown>`.
438
+ *
439
+ * Defaulted fields remain optional because `$InferObjectInput` honors
440
+ * `OptionalInSchema`, mirroring the runtime insert schema behavior.
257
441
  */
258
- export type InsertOf<TTable extends AnyStoreTable> = Omit<
259
- z.input<TTable['schema']>,
260
- GeneratedKeysOf<TTable>
261
- >;
442
+ export type InsertOf<TTable extends AnyStoreTable> = [
443
+ TTable['schema'],
444
+ ] extends [z.ZodObject<infer TShape>]
445
+ ? Omit<ObjectInputOf<TShape>, GeneratedKeysOf<TTable>>
446
+ : never;
262
447
 
263
448
  /**
264
449
  * Update shape: partial insert minus the primary key (immutable identifier).
265
450
  */
266
451
  export type UpdateOf<TTable extends AnyStoreTable> = Partial<
267
- Omit<InsertOf<TTable>, PrimaryKeyOf<TTable>>
452
+ Omit<InsertOf<TTable>, IdentityOf<TTable>>
268
453
  >;
269
454
 
455
+ /**
456
+ * Upsert shape: entity payload with generated fields remaining optional.
457
+ *
458
+ * This matches the backend-agnostic "create or replace" contract while
459
+ * still allowing adapters to synthesize generated values like IDs and
460
+ * timestamps when the caller omits them.
461
+ */
462
+ export type UpsertOf<TTable extends AnyStoreTable> = FixtureInputOf<TTable>;
463
+
270
464
  /**
271
465
  * Typed filter shape for list operations.
272
466
  */
@@ -284,10 +478,10 @@ export interface StoreListOptions {
284
478
  * Shared identifier type for read/write accessors.
285
479
  */
286
480
  export type StoreIdentifierOf<TTable extends AnyStoreTable> =
287
- EntityOf<TTable>[Extract<PrimaryKeyOf<TTable>, keyof EntityOf<TTable>>];
481
+ EntityOf<TTable>[Extract<IdentityOf<TTable>, keyof EntityOf<TTable>>];
288
482
 
289
483
  /**
290
- * Access mode for a bound store connection or provision.
484
+ * Access mode for a bound store connection or resource.
291
485
  */
292
486
  export type StoreAccessMode = 'readonly' | 'readwrite';
293
487
 
@@ -295,7 +489,7 @@ export type StoreAccessMode = 'readonly' | 'readwrite';
295
489
  * Read-only table operations that every bound store must expose.
296
490
  */
297
491
  export interface ReadOnlyStoreTableAccessor<TTable extends AnyStoreTable> {
298
- /** Retrieve a single entity by primary key. Returns `null` when not found. */
492
+ /** Retrieve a single entity by identity. Returns `null` when not found. */
299
493
  get(id: StoreIdentifierOf<TTable>): Promise<EntityOf<TTable> | null>;
300
494
  /**
301
495
  * List entities, optionally filtered. Returns all rows when no filters are
@@ -308,33 +502,91 @@ export interface ReadOnlyStoreTableAccessor<TTable extends AnyStoreTable> {
308
502
  }
309
503
 
310
504
  /**
311
- * Writable table operations layered on top of the read contract.
505
+ * Backend-agnostic writable operations layered on top of the read contract.
312
506
  */
313
- export interface StoreTableAccessor<
507
+ export interface StoreAccessor<
314
508
  TTable extends AnyStoreTable,
315
509
  > extends ReadOnlyStoreTableAccessor<TTable> {
316
510
  /**
317
- * Insert a new entity.
511
+ * Create or replace one entity using the store's identity field.
318
512
  *
319
513
  * @throws {AlreadyExistsError} On primary key or unique constraint violation.
320
514
  *
321
515
  * @remarks
322
- * This is an intentional throw-based boundary: store connectors throw typed
516
+ * This is an intentional throw-based boundary: store adapters throw typed
323
517
  * errors (`AlreadyExistsError`) rather than returning `Result`. Trail
324
518
  * implementations that call store accessors should catch and convert to
325
- * `Result.err()` at their level. A future `safeInsert` returning `Result`
326
- * is planned but deferred to avoid cascading changes across all connectors.
519
+ * `Result.err()` at their level. A future safe variant returning `Result`
520
+ * is planned but deferred to avoid cascading changes across all adapters.
327
521
  */
328
- insert(input: InsertOf<TTable>): Promise<EntityOf<TTable>>;
522
+ upsert(input: UpsertOf<TTable>): Promise<EntityOf<TTable>>;
329
523
  /**
330
- * Remove an entity by primary key. Returns `{ deleted: true }` when the
524
+ * Remove an entity by identity. Returns `{ deleted: true }` when the
331
525
  * row was found and removed, `{ deleted: false }` when no matching row
332
526
  * existed (not an error).
333
527
  */
334
528
  remove(id: StoreIdentifierOf<TTable>): Promise<{ readonly deleted: boolean }>;
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // Compile-time assertion: StoreAccessor satisfies the core accessor protocol.
533
+ //
534
+ // `@ontrails/core/store` declares a structural protocol that `deriveTrail()`
535
+ // uses to synthesize default blazes without importing `@ontrails/store`. We
536
+ // pin the relationship here rather than in core so that any drift between
537
+ // the two shapes fails the store build immediately. If this check fails, the
538
+ // protocol in core has diverged from the store accessor contract — fix the
539
+ // protocol shape, not this assertion.
540
+ // ---------------------------------------------------------------------------
541
+
542
+ type AssertExtends<TActual, TExpected> = TActual extends TExpected
543
+ ? true
544
+ : never;
545
+
546
+ /**
547
+ * Pins the structural relationship between `StoreAccessor` and the core
548
+ * `StoreAccessorProtocol`. If this check resolves to `never`, the protocol in
549
+ * core has drifted from the store accessor contract — fix the protocol shape,
550
+ * not this assertion.
551
+ */
552
+ const storeAccessorProtocolCheck: AssertExtends<
553
+ StoreAccessor<AnyStoreTable>,
554
+ StoreAccessorProtocol<
555
+ UpsertOf<AnyStoreTable>,
556
+ EntityOf<AnyStoreTable>,
557
+ StoreIdentifierOf<AnyStoreTable>,
558
+ FiltersOf<AnyStoreTable>
559
+ >
560
+ > = true;
561
+
562
+ void storeAccessorProtocolCheck;
563
+
564
+ /**
565
+ * Tabular writable operations layered on top of the backend-agnostic
566
+ * contract.
567
+ */
568
+ export interface StoreTableAccessor<
569
+ TTable extends AnyStoreTable,
570
+ > extends StoreAccessor<TTable> {
571
+ /**
572
+ * Insert a new entity.
573
+ *
574
+ * Tabular adapters can expose this convenience when the backend has a
575
+ * native distinction between create and update.
576
+ */
577
+ insert(input: InsertOf<TTable>): Promise<EntityOf<TTable>>;
335
578
  /**
336
- * Patch an entity by primary key with partial fields. Returns the updated
579
+ * Patch an entity by identity with partial fields. Returns the updated
337
580
  * entity, or `null` when no row with that ID exists.
581
+ *
582
+ * @remarks
583
+ * On versioned tables, `update` does **not** participate in optimistic
584
+ * concurrency control. The `UpdateOf<TTable>` shape is derived by omitting
585
+ * generated fields — including the framework-managed `version` column — so
586
+ * any `version` value is dropped before reaching the adapter and the
587
+ * adapter always auto-increments without comparing. Callers that need
588
+ * lost-update protection must use {@link StoreAccessor.upsert | `upsert`}
589
+ * instead and pass the expected `version` in the payload.
338
590
  */
339
591
  update(
340
592
  id: StoreIdentifierOf<TTable>,
@@ -352,10 +604,51 @@ export type ReadOnlyStoreConnection<TStore extends AnyStoreDefinition> = {
352
604
  };
353
605
 
354
606
  /**
355
- * Connection shape exposed by a writable bound store.
607
+ * Backend-agnostic connection shape exposed by a writable bound store.
356
608
  */
357
609
  export type StoreConnection<TStore extends AnyStoreDefinition> = {
610
+ readonly [TName in keyof TStore['tables']]: StoreAccessor<
611
+ TStore['tables'][TName]
612
+ >;
613
+ };
614
+
615
+ /**
616
+ * Tabular connection shape exposed by adapters that distinguish insert and
617
+ * patch operations from the generalized `upsert` contract.
618
+ */
619
+ export type StoreTableConnection<TStore extends AnyStoreDefinition> = {
358
620
  readonly [TName in keyof TStore['tables']]: StoreTableAccessor<
359
621
  TStore['tables'][TName]
360
622
  >;
361
623
  };
624
+
625
+ /**
626
+ * Optional fixture overrides used when building a mock store connection.
627
+ *
628
+ * The shape is a partial map keyed by table name; each entry is a list of
629
+ * fixture inputs validated against the table's fixture schema. Adapters
630
+ * share this type so every store backend seeds mocks the same way.
631
+ */
632
+ export type StoreMockSeed<TDef extends AnyStoreDefinition> = Partial<{
633
+ readonly [TName in keyof TDef['tables']]: readonly FixtureInputOf<
634
+ TDef['tables'][TName]
635
+ >[];
636
+ }>;
637
+
638
+ /**
639
+ * Shared adapter options every store backend accepts.
640
+ *
641
+ * Concrete adapters extend this shape with their backend-specific fields
642
+ * (e.g. `url`, `dir`). Aligning the authored surface here lets the framework
643
+ * reason about adapter options uniformly — one shape, many projections.
644
+ */
645
+ export interface StoreAdapterOptions<TDef extends AnyStoreDefinition> {
646
+ /** Optional resource id override. Defaults to `"store"`. */
647
+ readonly id?: string;
648
+ /** Human-readable description surfaced on the resource definition. */
649
+ readonly description?: string;
650
+ /** Free-form metadata for downstream tooling and governance. */
651
+ readonly meta?: Record<string, unknown>;
652
+ /** Optional per-table fixture overrides used by the mock resource factory. */
653
+ readonly mockSeed?: StoreMockSeed<TDef>;
654
+ }
@@ -1,38 +0,0 @@
1
- ---
2
- created: 2026-04-04T03:09:15.424Z
3
- type: handoff
4
- session: 9e85a104-864f-4273-b68d-a4a95eb2d5fc
5
- ---
6
-
7
- # Handoff 2026-04-04 23:09
8
-
9
- > Session `9e85a104` — PR feedback loop on 14-branch Graphite stack (#64-#77)
10
-
11
- ## Done
12
-
13
- - **Round 1**: Fixed 11 P1s across the stack — `persistEstablishedTopoSave` throw→Result, hash pipeline unification in drift.ts, store type safety (nullable defaults, PK leak, InsertOf/UpdateOf precision), drizzle error mapping, legacy tracker DB cleanup, transitive cache TSDoc, duplicate const before compile error
14
- - **Round 2**: Fixed absorb regressions (variable reference splits across branches), plus new P1s — Commander undefined opts, toTrackStore close() no-op, verifyCurrentTopo hash pipeline, duplicate verifyCurrentTopo removal
15
- - **Round 3**: Fixed remaining P1s — ADR depends_on slug→integer normalization, draft-promote preflight rename validation (duplicates + existing targets), NOT NULL on PK columns in generated SQL, InternalError passthrough, warden README method names (.trailhead→.blaze), vocabulary verb fix
16
- - **Thread resolution**: 128 threads resolved across 3 rounds, re-reviews requested each time
17
- - **Key learning**: `gt absorb` splits changes across branches unpredictably when a function definition and its callers span different branches — always walk up and typecheck each branch after absorb
18
-
19
- ## State
20
-
21
- - On branch `trl-129-topo-and-dev-surfaces` (#69) with an uncommitted fix (drift.ts resolveTrailsDir import + readTrailheadLock dir fix) — lint failed, needs investigation
22
- - 4 new P1s surfaced in Round 4 (from `-p 2` run which includes P0-P2):
23
- - **#69**: drift.ts passes workspace root instead of `.trails/` to readTrailheadLock (fix attempted, lint error pending)
24
- - **#71**: v2→v3 migration gap in topo-saves.ts — crash on existing schema version 2 databases
25
- - **#72**: createMockTopoStore returns data without seeded saves (diverges from real store)
26
- - **#77**: Three broken API examples in warden README (wrong signatures, invalid lefthook key)
27
- - P2s not yet addressed across #65, #68, #70, #72, #75
28
-
29
- ## Next
30
-
31
- - [ ] Debug lint failure on #69 drift.ts fix and commit
32
- - [ ] Fix #71: Add v2→v3 migration guard in `ensureTopoHistorySchema` to create `topo_schemas`/`topo_exports` tables
33
- - [ ] Fix #72: Add empty-save guard to `createMockTopoStore` `trails.list()`/`trails.get()`
34
- - [ ] Fix #77: Correct `runWarden` and `checkDrift` signatures + lefthook key in warden README
35
- - [ ] Walk up all branches verifying typecheck after each fix
36
- - [ ] Submit stack, resolve threads, write loop record
37
- - [ ] Then address P2s: gitignore duplicate-append (#68), stale TSDoc (#70), test harness fallback (#65), readonlyStore mock factory (#75)
38
- - [ ] Consider: the `isDraftMarkedFile` vs `stripDraftFileMarkers` inconsistency on #67 is still open (P2)
@@ -1 +0,0 @@
1
- $ tsc -b