@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
@@ -0,0 +1,50 @@
1
+ import type { Resource } from '@ontrails/core';
2
+ import type {
3
+ AnyStoreDefinition,
4
+ AnyStoreTable,
5
+ StoreAccessor,
6
+ StoreAdapterOptions,
7
+ } from '../types.js';
8
+
9
+ /** Connection shape: one StoreAccessor per table name. */
10
+ export type JsonFileConnection<TStore extends Record<string, AnyStoreTable>> =
11
+ Readonly<{ [K in keyof TStore]: StoreAccessor<TStore[K]> }>;
12
+
13
+ /** Options for creating a jsonfile store. */
14
+ export interface JsonFileStoreOptions<
15
+ TStore extends AnyStoreDefinition = AnyStoreDefinition,
16
+ > extends StoreAdapterOptions<TStore> {
17
+ /** Directory where JSON files are written. One `<tableName>.json` per table. */
18
+ readonly dir: string;
19
+ /**
20
+ * Optional identity generator. Defaults to `Bun.randomUUIDv7()`.
21
+ *
22
+ * @remarks
23
+ * When a jsonfile table is reused across multiple `connectJsonFile` call
24
+ * sites (same `dir` + table), the runtime enforces that every connection
25
+ * supplies a compatible `generateIdentity`. That compatibility check is
26
+ * intentional reference equality, not a structural or behavioral comparison:
27
+ * two distinct arrow functions that happen to be "logically identical"
28
+ * (e.g. `() => uuid()` written inline in two files) will still be treated
29
+ * as a conflict and surface as a `ConflictError`. Callers that want to
30
+ * share a table across call sites should hoist `generateIdentity` to a
31
+ * stable reference — a module-level `const` or shared utility — and pass
32
+ * that same reference in from every connection, rather than recreating
33
+ * the function inline at each site.
34
+ *
35
+ * The conflict check only fires for custom generators. When no custom
36
+ * generator is provided for an auto-generated-identity store,
37
+ * `deriveTableReuseConfig` normalizes `generateIdentity` to `undefined`
38
+ * regardless of what (if anything) was passed, so two connections that
39
+ * both rely on defaults never conflict on this field. The same
40
+ * normalization also applies when the identity field is not in
41
+ * `generatedFields` — the generator is irrelevant in that case, so the
42
+ * check is skipped.
43
+ */
44
+ readonly generateIdentity?: () => string;
45
+ }
46
+
47
+ /** Resource type for a jsonfile store. */
48
+ export type JsonFileStoreResource<
49
+ TStore extends Record<string, AnyStoreTable>,
50
+ > = Resource<JsonFileConnection<TStore>>;
package/src/store.ts CHANGED
@@ -1,8 +1,12 @@
1
- import { ValidationError } from '@ontrails/core';
2
- import type { z } from 'zod';
1
+ import { stripDefaultsFromShape, ValidationError } from '@ontrails/core';
2
+ import type { AnySignal } from '@ontrails/core';
3
+ import { z } from 'zod';
3
4
 
5
+ import { createStoreTableSignals } from './adapter-support.js';
4
6
  import type {
5
7
  StoreDefinition,
8
+ StoreKind,
9
+ StoreOptions,
6
10
  StoreObjectSchema,
7
11
  StoreTable,
8
12
  StoreTableInput,
@@ -12,14 +16,31 @@ import type {
12
16
  const isStoreObjectSchema = (schema: z.ZodType): schema is StoreObjectSchema =>
13
17
  schema.def.type === 'object' && 'shape' in schema.def;
14
18
 
19
+ /**
20
+ * Name of the framework-managed version column used by versioned tables for
21
+ * optimistic concurrency control. Exported so adapters can gate their own
22
+ * version-handling logic without duplicating the literal.
23
+ */
24
+ export const versionFieldName = 'version';
25
+ const versionFieldSchema = z.number().int().positive();
26
+
15
27
  const uniqueStrings = <T extends string>(
16
28
  values: readonly T[] | undefined
17
29
  ): readonly T[] =>
18
30
  Object.freeze([...(values === undefined ? [] : new Set(values))]);
19
31
 
32
+ const uniqueMergedStrings = <T extends string>(
33
+ ...groups: readonly (readonly T[] | undefined)[]
34
+ ): readonly T[] => uniqueStrings(groups.flatMap((group) => [...(group ?? [])]));
35
+
20
36
  const hasField = (schema: StoreObjectSchema, field: string): boolean =>
21
37
  Object.hasOwn(schema.shape, field);
22
38
 
39
+ const versionedSchema = (schema: StoreObjectSchema): StoreObjectSchema =>
40
+ schema.extend({
41
+ [versionFieldName]: versionFieldSchema,
42
+ }) as StoreObjectSchema;
43
+
23
44
  const validateFieldList = (
24
45
  tableName: string,
25
46
  schema: StoreObjectSchema,
@@ -98,56 +119,16 @@ const deriveFixtureSchema = <TSchema extends StoreObjectSchema>(
98
119
  generated: readonly string[]
99
120
  ): StoreObjectSchema => partialFields(schema, generated);
100
121
 
101
- /**
102
- * Strip `default` wrappers from a Zod type so that partial update schemas
103
- * do not silently re-materialize defaults. Walks through all wrapper layers
104
- * (default, optional, nullable), strips defaults, and re-applies non-default
105
- * wrappers to preserve the nullable constraint.
106
- */
107
- const stripDefaultWrappers = (schema: z.ZodType): z.ZodType => {
108
- const wrappers: ('optional' | 'nullable')[] = [];
109
- let current = schema;
110
-
111
- while (
112
- current.def.type === 'default' ||
113
- current.def.type === 'optional' ||
114
- current.def.type === 'nullable'
115
- ) {
116
- const type = current.def.type as 'default' | 'optional' | 'nullable';
117
- if (type !== 'default') {
118
- wrappers.push(type);
119
- }
120
- current = (current.def as unknown as Record<string, unknown>)[
121
- 'innerType'
122
- ] as z.ZodType;
123
- }
124
-
125
- // Re-apply nullable wrappers (optional is dropped — .partial() re-adds it)
126
- return wrappers.includes('nullable') ? current.nullable() : current;
127
- };
128
-
129
- const stripDefaultsFromShape = (
130
- schema: StoreObjectSchema
131
- ): Record<string, z.ZodType> => {
132
- const stripped: Record<string, z.ZodType> = {};
133
-
134
- for (const [field, value] of Object.entries(schema.shape)) {
135
- stripped[field] = stripDefaultWrappers(value);
136
- }
137
-
138
- return stripped;
139
- };
140
-
141
122
  const deriveUpdateSchema = (
142
123
  schema: StoreObjectSchema,
143
- primaryKey: string
124
+ identity: string
144
125
  ): StoreObjectSchema => {
145
126
  const partial = schema
146
127
  .extend(stripDefaultsFromShape(schema))
147
128
  .partial() as StoreObjectSchema;
148
- // Only omit the primaryKey if it's still present (it may already be in `generated`)
149
- return hasField(partial, primaryKey)
150
- ? omitFields(partial, [primaryKey])
129
+ // Only omit the identity field if it's still present (it may already be in `generated`)
130
+ return hasField(partial, identity)
131
+ ? omitFields(partial, [identity])
151
132
  : partial;
152
133
  };
153
134
 
@@ -156,13 +137,13 @@ const formatFixtureIssues = (issues: readonly { readonly message: string }[]) =>
156
137
 
157
138
  const validateFixturePrimaryKeys = (
158
139
  tableName: string,
159
- primaryKey: string,
140
+ identity: string,
160
141
  fixtures: readonly Record<string, unknown>[]
161
142
  ): void => {
162
143
  const seen = new Set<unknown>();
163
144
 
164
145
  for (const [index, fixture] of fixtures.entries()) {
165
- const identifier = fixture[primaryKey];
146
+ const identifier = fixture[identity];
166
147
  if (identifier === undefined) {
167
148
  continue;
168
149
  }
@@ -194,29 +175,81 @@ const normalizeReferences = (
194
175
  const validatePrimaryKey = (
195
176
  tableName: string,
196
177
  schema: StoreObjectSchema,
197
- primaryKey: string
178
+ identity: string
198
179
  ): void => {
199
- if (hasField(schema, primaryKey)) {
180
+ if (hasField(schema, identity)) {
200
181
  return;
201
182
  }
202
183
 
203
184
  throw new ValidationError(
204
- `Store table "${tableName}" declares primaryKey "${primaryKey}" that is not present on the schema`
185
+ `Store table "${tableName}" declares identity "${identity}" that is not present on the schema`
186
+ );
187
+ };
188
+
189
+ const validateVersioning = (
190
+ tableName: string,
191
+ schema: StoreObjectSchema,
192
+ versioned: boolean
193
+ ): void => {
194
+ if (!versioned || !hasField(schema, versionFieldName)) {
195
+ return;
196
+ }
197
+
198
+ throw new ValidationError(
199
+ `Store table "${tableName}" cannot declare a "${versionFieldName}" field when versioned storage is enabled because the framework manages that field.`
200
+ );
201
+ };
202
+
203
+ const resolveStoreObjectSchema = (
204
+ tableName: string,
205
+ schema: StoreTableInput['schema']
206
+ ): StoreObjectSchema => {
207
+ if (isStoreObjectSchema(schema)) {
208
+ return schema;
209
+ }
210
+
211
+ throw new ValidationError(
212
+ `Store table "${tableName}" must use a Zod object schema`
205
213
  );
206
214
  };
207
215
 
216
+ const resolveIdentity = (tableName: string, input: StoreTableInput): string => {
217
+ if (
218
+ input.identity !== undefined &&
219
+ input.primaryKey !== undefined &&
220
+ input.identity !== input.primaryKey
221
+ ) {
222
+ throw new ValidationError(
223
+ `Store table "${tableName}" declares conflicting identity "${input.identity}" and primaryKey "${input.primaryKey}"`
224
+ );
225
+ }
226
+
227
+ const identity = input.identity ?? input.primaryKey;
228
+ if (identity !== undefined) {
229
+ return identity;
230
+ }
231
+
232
+ throw new ValidationError(`Store table "${tableName}" must declare identity`);
233
+ };
234
+
235
+ const resolveIndexed = (input: StoreTableInput): readonly string[] =>
236
+ uniqueMergedStrings(input.indexed, input.indexes);
237
+
238
+ const resolveVersioned = (input: StoreTableInput): boolean =>
239
+ input.versioned === true;
240
+
208
241
  const validateTableInput = (
209
242
  tableName: string,
210
243
  schema: StoreObjectSchema,
211
- primaryKey: string,
244
+ identity: string,
212
245
  generated: readonly string[],
213
- indexes: readonly string[],
246
+ indexed: readonly string[],
214
247
  references: Readonly<Partial<Record<string, string>>>,
215
248
  tableNames: readonly string[]
216
249
  ): void => {
217
- validatePrimaryKey(tableName, schema, primaryKey);
250
+ validatePrimaryKey(tableName, schema, identity);
218
251
  validateFieldList(tableName, schema, generated, 'generated');
219
- validateFieldList(tableName, schema, indexes, 'index');
252
+ validateFieldList(tableName, schema, indexed, 'indexed');
220
253
  validateReferences(tableName, schema, references, tableNames);
221
254
  };
222
255
 
@@ -224,144 +257,272 @@ type MutableTables<TTables extends StoreTablesInput> = {
224
257
  -readonly [TName in keyof TTables]: StoreDefinition<TTables>['tables'][TName];
225
258
  };
226
259
 
260
+ const fixtureListFrom = (
261
+ fixtures: StoreTableInput['fixtures']
262
+ ): readonly unknown[] => (Array.isArray(fixtures) ? fixtures : []);
263
+
264
+ const parseFixture = (
265
+ tableName: string,
266
+ fixtureSchema: StoreObjectSchema,
267
+ fixture: unknown,
268
+ index: number
269
+ ): Readonly<Record<string, unknown>> => {
270
+ const parsed = fixtureSchema.safeParse(fixture);
271
+ if (!parsed.success) {
272
+ throw new ValidationError(
273
+ `Store table "${tableName}" fixture ${index + 1} is invalid: ${formatFixtureIssues(parsed.error.issues)}`
274
+ );
275
+ }
276
+
277
+ return Object.freeze(parsed.data);
278
+ };
279
+
227
280
  const normalizeFixtures = <TInput extends StoreTableInput>(
228
281
  tableName: string,
229
- primaryKey: string,
282
+ identity: string,
230
283
  fixtureSchema: StoreObjectSchema,
231
284
  fixtures: TInput['fixtures']
232
285
  ): StoreTable<TInput>['fixtures'] => {
233
- if (fixtures === undefined || fixtures.length === 0) {
286
+ const fixtureList = fixtureListFrom(fixtures);
287
+
288
+ if (fixtureList.length === 0) {
234
289
  return Object.freeze([]) as StoreTable<TInput>['fixtures'];
235
290
  }
236
291
 
237
- const normalized = [];
292
+ const normalized: Record<string, unknown>[] = [];
238
293
 
239
- for (const [index, fixture] of fixtures.entries()) {
240
- const parsed = fixtureSchema.safeParse(fixture);
241
- if (!parsed.success) {
242
- throw new ValidationError(
243
- `Store table "${tableName}" fixture ${index + 1} is invalid: ${formatFixtureIssues(parsed.error.issues)}`
244
- );
245
- }
246
-
247
- normalized.push(Object.freeze(parsed.data));
294
+ for (const [index, fixture] of fixtureList.entries()) {
295
+ normalized.push(parseFixture(tableName, fixtureSchema, fixture, index));
248
296
  }
249
297
 
250
- validateFixturePrimaryKeys(tableName, primaryKey, normalized);
298
+ validateFixturePrimaryKeys(tableName, identity, normalized);
251
299
  return Object.freeze(normalized) as StoreTable<TInput>['fixtures'];
252
300
  };
253
301
 
254
- const normalizeTable = <
302
+ const resolveTableSchema = <TInput extends StoreTableInput<StoreObjectSchema>>(
303
+ tableName: string,
304
+ input: TInput
305
+ ): {
306
+ readonly schema: StoreObjectSchema;
307
+ readonly versioned: boolean;
308
+ } => {
309
+ const authoredSchema = resolveStoreObjectSchema(tableName, input.schema);
310
+ const versioned = resolveVersioned(input);
311
+ validateVersioning(tableName, authoredSchema, versioned);
312
+
313
+ return {
314
+ schema: versioned ? versionedSchema(authoredSchema) : authoredSchema,
315
+ versioned,
316
+ };
317
+ };
318
+
319
+ const resolveGeneratedFields = <
320
+ TInput extends StoreTableInput<StoreObjectSchema>,
321
+ >(
322
+ input: TInput,
323
+ versioned: boolean
324
+ ): readonly string[] =>
325
+ versioned
326
+ ? uniqueMergedStrings(input.generated, [versionFieldName])
327
+ : uniqueStrings(input.generated);
328
+
329
+ const freezeNormalizedTable = <
255
330
  TName extends string,
256
331
  TInput extends StoreTableInput<StoreObjectSchema>,
257
332
  >(
258
333
  name: TName,
259
334
  input: TInput,
260
- tableNames: readonly string[]
261
- ): StoreTable<TInput, TName> => {
262
- if (!isStoreObjectSchema(input.schema)) {
263
- throw new ValidationError(
264
- `Store table "${name}" must use a Zod object schema`
265
- );
335
+ resolved: {
336
+ readonly fixtureSchema: StoreObjectSchema;
337
+ readonly fixtures: StoreTable<TInput>['fixtures'];
338
+ readonly generated: readonly string[];
339
+ readonly identity: string;
340
+ readonly indexed: readonly string[];
341
+ readonly insertSchema: StoreObjectSchema;
342
+ readonly references: Readonly<Partial<Record<string, string>>>;
343
+ readonly schema: StoreObjectSchema;
344
+ readonly signals: StoreTable<TInput, TName>['signals'];
345
+ readonly versioned: boolean;
266
346
  }
347
+ ): StoreTable<TInput, TName> =>
348
+ Object.freeze({
349
+ fixtureSchema: resolved.fixtureSchema,
350
+ fixtures: resolved.fixtures,
351
+ generated: resolved.generated,
352
+ identity: resolved.identity,
353
+ indexed: resolved.indexed,
354
+ indexes: resolved.indexed,
355
+ insertSchema: resolved.insertSchema,
356
+ name,
357
+ primaryKey: resolved.identity,
358
+ references: resolved.references,
359
+ schema: resolved.schema,
360
+ ...(input.search === undefined ? {} : { search: input.search }),
361
+ signals: resolved.signals,
362
+ updateSchema: deriveUpdateSchema(resolved.insertSchema, resolved.identity),
363
+ versioned: resolved.versioned,
364
+ }) as StoreTable<TInput, TName>;
267
365
 
268
- const generated = uniqueStrings(input.generated);
269
- const indexes = uniqueStrings(input.indexes);
366
+ interface NormalizedTableState {
367
+ readonly generated: readonly string[];
368
+ readonly identity: string;
369
+ readonly indexed: readonly string[];
370
+ readonly references: Readonly<Partial<Record<string, string>>>;
371
+ readonly schema: StoreObjectSchema;
372
+ readonly versioned: boolean;
373
+ }
374
+
375
+ const resolveNormalizedTableState = <
376
+ TInput extends StoreTableInput<StoreObjectSchema>,
377
+ >(
378
+ name: string,
379
+ input: TInput,
380
+ tableNames: readonly string[]
381
+ ): NormalizedTableState => {
382
+ const { schema, versioned } = resolveTableSchema(name, input);
383
+ const identity = resolveIdentity(name, input);
384
+ const generated = resolveGeneratedFields(input, versioned);
385
+ const indexed = resolveIndexed(input);
270
386
  const references = normalizeReferences(input.references);
387
+
271
388
  validateTableInput(
272
389
  name,
273
- input.schema,
274
- input.primaryKey,
390
+ schema,
391
+ identity,
275
392
  generated,
276
- indexes,
393
+ indexed,
277
394
  references,
278
395
  tableNames
279
396
  );
280
397
 
281
- const insertSchema = deriveInsertSchema(input.schema, generated);
282
- const fixtureSchema = deriveFixtureSchema(input.schema, generated);
398
+ return {
399
+ generated,
400
+ identity,
401
+ indexed,
402
+ references,
403
+ schema,
404
+ versioned,
405
+ };
406
+ };
407
+
408
+ const resolveNormalizedTableArtifacts = <
409
+ TName extends string,
410
+ TInput extends StoreTableInput<StoreObjectSchema>,
411
+ >(
412
+ name: TName,
413
+ input: TInput,
414
+ resolved: NormalizedTableState
415
+ ): {
416
+ readonly fixtureSchema: StoreObjectSchema;
417
+ readonly fixtures: StoreTable<TInput>['fixtures'];
418
+ readonly generated: readonly string[];
419
+ readonly identity: string;
420
+ readonly indexed: readonly string[];
421
+ readonly insertSchema: StoreObjectSchema;
422
+ readonly references: Readonly<Partial<Record<string, string>>>;
423
+ readonly schema: StoreObjectSchema;
424
+ readonly signals: StoreTable<TInput, TName>['signals'];
425
+ } => {
426
+ const insertSchema = deriveInsertSchema(resolved.schema, resolved.generated);
427
+ const fixtureSchema = deriveFixtureSchema(
428
+ resolved.schema,
429
+ resolved.generated
430
+ );
283
431
  const fixtures = normalizeFixtures(
284
432
  name,
285
- input.primaryKey,
433
+ resolved.identity,
286
434
  fixtureSchema,
287
435
  input.fixtures
288
436
  );
437
+ const signals = createStoreTableSignals(name, resolved.schema) as StoreTable<
438
+ TInput,
439
+ TName
440
+ >['signals'];
289
441
 
290
- return Object.freeze({
442
+ return {
291
443
  fixtureSchema,
292
444
  fixtures,
293
- generated,
294
- indexes,
445
+ generated: resolved.generated,
446
+ identity: resolved.identity,
447
+ indexed: resolved.indexed,
295
448
  insertSchema,
296
- name,
297
- primaryKey: input.primaryKey,
298
- references,
299
- schema: input.schema,
300
- ...(input.search === undefined ? {} : { search: input.search }),
301
- updateSchema: deriveUpdateSchema(insertSchema, input.primaryKey),
302
- }) as StoreTable<TInput, TName>;
449
+ references: resolved.references,
450
+ schema: resolved.schema,
451
+ signals,
452
+ };
453
+ };
454
+
455
+ const normalizeTable = <
456
+ TName extends string,
457
+ TInput extends StoreTableInput<StoreObjectSchema>,
458
+ >(
459
+ name: TName,
460
+ input: TInput,
461
+ tableNames: readonly string[]
462
+ ): StoreTable<TInput, TName> => {
463
+ const resolved = resolveNormalizedTableState(name, input, tableNames);
464
+
465
+ return freezeNormalizedTable(name, input, {
466
+ ...resolveNormalizedTableArtifacts(name, input, resolved),
467
+ versioned: resolved.versioned,
468
+ });
469
+ };
470
+
471
+ const normalizeTables = <const TTables extends StoreTablesInput>(
472
+ tables: TTables,
473
+ tableNames: readonly Extract<keyof TTables, string>[]
474
+ ): MutableTables<TTables> => {
475
+ const normalized = {} as MutableTables<TTables>;
476
+
477
+ for (const name of tableNames) {
478
+ const input = tables[name];
479
+ if (input !== undefined) {
480
+ normalized[name] = normalizeTable(name, input, tableNames);
481
+ }
482
+ }
483
+
484
+ return normalized;
303
485
  };
304
486
 
487
+ const collectStoreSignals = <const TTables extends StoreTablesInput>(
488
+ normalized: MutableTables<TTables>,
489
+ tableNames: readonly Extract<keyof TTables, string>[]
490
+ ): readonly AnySignal[] =>
491
+ Object.freeze(
492
+ tableNames.flatMap((name) => {
493
+ const table = normalized[name];
494
+ return table === undefined
495
+ ? []
496
+ : [table.signals.created, table.signals.updated, table.signals.removed];
497
+ })
498
+ );
499
+
305
500
  /**
306
- * Declare a connector-agnostic store definition from entity schemas and
501
+ * Declare a backend-agnostic store definition from entity schemas and
307
502
  * persistence metadata.
308
503
  *
309
- * The returned value is a normalized, read-only contract that connectors can
504
+ * The returned value is a normalized, read-only contract that adapters can
310
505
  * bind to a concrete runtime later.
311
506
  */
312
507
  export const store = <const TTables extends StoreTablesInput>(
313
- tables: TTables
508
+ tables: TTables,
509
+ options: StoreOptions = {}
314
510
  ): StoreDefinition<TTables> => {
511
+ const kind: StoreKind = options.kind ?? 'tabular';
315
512
  const tableNames = Object.freeze(
316
513
  Object.keys(tables).toSorted()
317
514
  ) as readonly Extract<keyof TTables, string>[];
318
-
319
- const normalized = {} as MutableTables<TTables>;
320
- for (const name of tableNames) {
321
- const input = tables[name];
322
-
323
- if (input === undefined) {
324
- continue;
325
- }
326
-
327
- normalized[name] = normalizeTable(name, input, tableNames);
328
- }
329
-
515
+ const normalized = normalizeTables(tables, tableNames);
330
516
  const get = <TName extends Extract<keyof TTables, string>>(name: TName) =>
331
517
  normalized[name];
518
+ const signals = collectStoreSignals(normalized, tableNames);
332
519
 
333
520
  return Object.freeze({
334
521
  get,
335
- kind: 'store' as const,
522
+ kind,
523
+ signals,
336
524
  tableNames,
337
525
  tables: Object.freeze(normalized),
526
+ type: 'store' as const,
338
527
  });
339
528
  };
340
-
341
- /**
342
- * Read the full entity schema from a normalized store table.
343
- */
344
- export const entitySchemaOf = <TTable extends StoreTable>(
345
- table: TTable
346
- ): TTable['schema'] => table.schema;
347
-
348
- /**
349
- * Read the fixture schema from a normalized store table.
350
- */
351
- export const fixtureSchemaOf = <TTable extends StoreTable>(
352
- table: TTable
353
- ): TTable['fixtureSchema'] => table.fixtureSchema;
354
-
355
- /**
356
- * Read the insert schema from a normalized store table.
357
- */
358
- export const insertSchemaOf = <TTable extends StoreTable>(
359
- table: TTable
360
- ): TTable['insertSchema'] => table.insertSchema;
361
-
362
- /**
363
- * Read the update schema from a normalized store table.
364
- */
365
- export const updateSchemaOf = <TTable extends StoreTable>(
366
- table: TTable
367
- ): TTable['updateSchema'] => table.updateSchema;