@ontrails/store 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 ADDED
@@ -0,0 +1,528 @@
1
+ import { stripDefaultsFromShape, ValidationError } from '@ontrails/core';
2
+ import type { AnySignal } from '@ontrails/core';
3
+ import { z } from 'zod';
4
+
5
+ import { createStoreTableSignals } from './adapter-support.js';
6
+ import type {
7
+ StoreDefinition,
8
+ StoreKind,
9
+ StoreOptions,
10
+ StoreObjectSchema,
11
+ StoreTable,
12
+ StoreTableInput,
13
+ StoreTablesInput,
14
+ } from './types.js';
15
+
16
+ const isStoreObjectSchema = (schema: z.ZodType): schema is StoreObjectSchema =>
17
+ schema.def.type === 'object' && 'shape' in schema.def;
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
+
27
+ const uniqueStrings = <T extends string>(
28
+ values: readonly T[] | undefined
29
+ ): readonly T[] =>
30
+ Object.freeze([...(values === undefined ? [] : new Set(values))]);
31
+
32
+ const uniqueMergedStrings = <T extends string>(
33
+ ...groups: readonly (readonly T[] | undefined)[]
34
+ ): readonly T[] => uniqueStrings(groups.flatMap((group) => [...(group ?? [])]));
35
+
36
+ const hasField = (schema: StoreObjectSchema, field: string): boolean =>
37
+ Object.hasOwn(schema.shape, field);
38
+
39
+ const versionedSchema = (schema: StoreObjectSchema): StoreObjectSchema =>
40
+ schema.extend({
41
+ [versionFieldName]: versionFieldSchema,
42
+ }) as StoreObjectSchema;
43
+
44
+ const validateFieldList = (
45
+ tableName: string,
46
+ schema: StoreObjectSchema,
47
+ fields: readonly string[],
48
+ label: string
49
+ ): void => {
50
+ for (const field of fields) {
51
+ if (hasField(schema, field)) {
52
+ continue;
53
+ }
54
+
55
+ throw new ValidationError(
56
+ `Store table "${tableName}" declares ${label} field "${field}" that is not present on the schema`
57
+ );
58
+ }
59
+ };
60
+
61
+ const validateReferences = (
62
+ tableName: string,
63
+ schema: StoreObjectSchema,
64
+ references: Readonly<Partial<Record<string, string>>>,
65
+ tableNames: readonly string[]
66
+ ): void => {
67
+ for (const [field, target] of Object.entries(references)) {
68
+ if (!hasField(schema, field)) {
69
+ throw new ValidationError(
70
+ `Store table "${tableName}" declares reference field "${field}" that is not present on the schema`
71
+ );
72
+ }
73
+
74
+ if (target !== undefined && tableNames.includes(target)) {
75
+ continue;
76
+ }
77
+
78
+ throw new ValidationError(
79
+ `Store table "${tableName}" references unknown table "${target}"`
80
+ );
81
+ }
82
+ };
83
+
84
+ const buildFieldMask = (fields: readonly string[]): Record<string, true> =>
85
+ Object.fromEntries(fields.map((field) => [field, true] as const)) as Record<
86
+ string,
87
+ true
88
+ >;
89
+
90
+ const omitFields = <TSchema extends StoreObjectSchema>(
91
+ schema: TSchema,
92
+ fields: readonly string[]
93
+ ): StoreObjectSchema => {
94
+ if (fields.length === 0) {
95
+ return schema;
96
+ }
97
+
98
+ return schema.omit(buildFieldMask(fields)) as StoreObjectSchema;
99
+ };
100
+
101
+ const partialFields = <TSchema extends StoreObjectSchema>(
102
+ schema: TSchema,
103
+ fields: readonly string[]
104
+ ): StoreObjectSchema => {
105
+ if (fields.length === 0) {
106
+ return schema;
107
+ }
108
+
109
+ return schema.partial(buildFieldMask(fields)) as StoreObjectSchema;
110
+ };
111
+
112
+ const deriveInsertSchema = <TSchema extends StoreObjectSchema>(
113
+ schema: TSchema,
114
+ generated: readonly string[]
115
+ ): StoreObjectSchema => omitFields(schema, generated);
116
+
117
+ const deriveFixtureSchema = <TSchema extends StoreObjectSchema>(
118
+ schema: TSchema,
119
+ generated: readonly string[]
120
+ ): StoreObjectSchema => partialFields(schema, generated);
121
+
122
+ const deriveUpdateSchema = (
123
+ schema: StoreObjectSchema,
124
+ identity: string
125
+ ): StoreObjectSchema => {
126
+ const partial = schema
127
+ .extend(stripDefaultsFromShape(schema))
128
+ .partial() as StoreObjectSchema;
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])
132
+ : partial;
133
+ };
134
+
135
+ const formatFixtureIssues = (issues: readonly { readonly message: string }[]) =>
136
+ issues.map((issue) => issue.message).join('; ');
137
+
138
+ const validateFixturePrimaryKeys = (
139
+ tableName: string,
140
+ identity: string,
141
+ fixtures: readonly Record<string, unknown>[]
142
+ ): void => {
143
+ const seen = new Set<unknown>();
144
+
145
+ for (const [index, fixture] of fixtures.entries()) {
146
+ const identifier = fixture[identity];
147
+ if (identifier === undefined) {
148
+ continue;
149
+ }
150
+
151
+ if (seen.has(identifier)) {
152
+ throw new ValidationError(
153
+ `Store table "${tableName}" fixture ${index + 1} duplicates primary key "${String(identifier)}"`
154
+ );
155
+ }
156
+
157
+ seen.add(identifier);
158
+ }
159
+ };
160
+
161
+ const normalizeReferences = (
162
+ references: Readonly<Partial<Record<string, string>>> | undefined
163
+ ): Readonly<Record<string, string>> => {
164
+ const normalized: Record<string, string> = {};
165
+
166
+ for (const [field, target] of Object.entries(references ?? {})) {
167
+ if (target !== undefined) {
168
+ normalized[field] = target;
169
+ }
170
+ }
171
+
172
+ return Object.freeze(normalized);
173
+ };
174
+
175
+ const validatePrimaryKey = (
176
+ tableName: string,
177
+ schema: StoreObjectSchema,
178
+ identity: string
179
+ ): void => {
180
+ if (hasField(schema, identity)) {
181
+ return;
182
+ }
183
+
184
+ throw new ValidationError(
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`
213
+ );
214
+ };
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
+
241
+ const validateTableInput = (
242
+ tableName: string,
243
+ schema: StoreObjectSchema,
244
+ identity: string,
245
+ generated: readonly string[],
246
+ indexed: readonly string[],
247
+ references: Readonly<Partial<Record<string, string>>>,
248
+ tableNames: readonly string[]
249
+ ): void => {
250
+ validatePrimaryKey(tableName, schema, identity);
251
+ validateFieldList(tableName, schema, generated, 'generated');
252
+ validateFieldList(tableName, schema, indexed, 'indexed');
253
+ validateReferences(tableName, schema, references, tableNames);
254
+ };
255
+
256
+ type MutableTables<TTables extends StoreTablesInput> = {
257
+ -readonly [TName in keyof TTables]: StoreDefinition<TTables>['tables'][TName];
258
+ };
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
+
280
+ const normalizeFixtures = <TInput extends StoreTableInput>(
281
+ tableName: string,
282
+ identity: string,
283
+ fixtureSchema: StoreObjectSchema,
284
+ fixtures: TInput['fixtures']
285
+ ): StoreTable<TInput>['fixtures'] => {
286
+ const fixtureList = fixtureListFrom(fixtures);
287
+
288
+ if (fixtureList.length === 0) {
289
+ return Object.freeze([]) as StoreTable<TInput>['fixtures'];
290
+ }
291
+
292
+ const normalized: Record<string, unknown>[] = [];
293
+
294
+ for (const [index, fixture] of fixtureList.entries()) {
295
+ normalized.push(parseFixture(tableName, fixtureSchema, fixture, index));
296
+ }
297
+
298
+ validateFixturePrimaryKeys(tableName, identity, normalized);
299
+ return Object.freeze(normalized) as StoreTable<TInput>['fixtures'];
300
+ };
301
+
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 = <
330
+ TName extends string,
331
+ TInput extends StoreTableInput<StoreObjectSchema>,
332
+ >(
333
+ name: TName,
334
+ input: TInput,
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;
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>;
365
+
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);
386
+ const references = normalizeReferences(input.references);
387
+
388
+ validateTableInput(
389
+ name,
390
+ schema,
391
+ identity,
392
+ generated,
393
+ indexed,
394
+ references,
395
+ tableNames
396
+ );
397
+
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
+ );
431
+ const fixtures = normalizeFixtures(
432
+ name,
433
+ resolved.identity,
434
+ fixtureSchema,
435
+ input.fixtures
436
+ );
437
+ const signals = createStoreTableSignals(name, resolved.schema) as StoreTable<
438
+ TInput,
439
+ TName
440
+ >['signals'];
441
+
442
+ return {
443
+ fixtureSchema,
444
+ fixtures,
445
+ generated: resolved.generated,
446
+ identity: resolved.identity,
447
+ indexed: resolved.indexed,
448
+ insertSchema,
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;
485
+ };
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
+
500
+ /**
501
+ * Declare a backend-agnostic store definition from entity schemas and
502
+ * persistence metadata.
503
+ *
504
+ * The returned value is a normalized, read-only contract that adapters can
505
+ * bind to a concrete runtime later.
506
+ */
507
+ export const store = <const TTables extends StoreTablesInput>(
508
+ tables: TTables,
509
+ options: StoreOptions = {}
510
+ ): StoreDefinition<TTables> => {
511
+ const kind: StoreKind = options.kind ?? 'tabular';
512
+ const tableNames = Object.freeze(
513
+ Object.keys(tables).toSorted()
514
+ ) as readonly Extract<keyof TTables, string>[];
515
+ const normalized = normalizeTables(tables, tableNames);
516
+ const get = <TName extends Extract<keyof TTables, string>>(name: TName) =>
517
+ normalized[name];
518
+ const signals = collectStoreSignals(normalized, tableNames);
519
+
520
+ return Object.freeze({
521
+ get,
522
+ kind,
523
+ signals,
524
+ tableNames,
525
+ tables: Object.freeze(normalized),
526
+ type: 'store' as const,
527
+ });
528
+ };