@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.
- package/CHANGELOG.md +329 -0
- package/README.md +290 -0
- package/package.json +56 -0
- package/src/adapter-support.ts +178 -0
- package/src/crud-doctrine.ts +43 -0
- package/src/index.ts +48 -0
- package/src/jsonfile/index.ts +6 -0
- package/src/jsonfile/runtime.ts +700 -0
- package/src/jsonfile/types.ts +50 -0
- package/src/store.ts +528 -0
- package/src/testing.ts +175 -0
- package/src/trails/crud.ts +423 -0
- package/src/trails/index.ts +20 -0
- package/src/trails/reconcile.ts +299 -0
- package/src/trails/sync.ts +274 -0
- package/src/trails/utils.ts +117 -0
- package/src/types.ts +654 -0
|
@@ -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
|
+
};
|