@ontrails/store 1.0.0-beta.14
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/.agents/notes/2026-04-04/handoff-202604032309-9e85a104.md +38 -0
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +12 -0
- package/README.md +213 -0
- package/dist/drizzle/index.d.ts +3 -0
- package/dist/drizzle/index.d.ts.map +1 -0
- package/dist/drizzle/index.js +2 -0
- package/dist/drizzle/index.js.map +1 -0
- package/dist/drizzle/runtime.d.ts +21 -0
- package/dist/drizzle/runtime.d.ts.map +1 -0
- package/dist/drizzle/runtime.js +458 -0
- package/dist/drizzle/runtime.js.map +1 -0
- package/dist/drizzle/schema.d.ts +15 -0
- package/dist/drizzle/schema.d.ts.map +1 -0
- package/dist/drizzle/schema.js +322 -0
- package/dist/drizzle/schema.js.map +1 -0
- package/dist/drizzle/types.d.ts +40 -0
- package/dist/drizzle/types.d.ts.map +1 -0
- package/dist/drizzle/types.js +2 -0
- package/dist/drizzle/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/store.d.ts +26 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +192 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +224 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/store.test.ts +333 -0
- package/src/drizzle/__tests__/drizzle.test.ts +469 -0
- package/src/drizzle/index.ts +17 -0
- package/src/drizzle/runtime.ts +853 -0
- package/src/drizzle/schema.ts +577 -0
- package/src/drizzle/types.ts +70 -0
- package/src/index.ts +39 -0
- package/src/store.ts +367 -0
- package/src/types.ts +361 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { ValidationError } from '@ontrails/core';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
StoreDefinition,
|
|
6
|
+
StoreObjectSchema,
|
|
7
|
+
StoreTable,
|
|
8
|
+
StoreTableInput,
|
|
9
|
+
StoreTablesInput,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
|
|
12
|
+
const isStoreObjectSchema = (schema: z.ZodType): schema is StoreObjectSchema =>
|
|
13
|
+
schema.def.type === 'object' && 'shape' in schema.def;
|
|
14
|
+
|
|
15
|
+
const uniqueStrings = <T extends string>(
|
|
16
|
+
values: readonly T[] | undefined
|
|
17
|
+
): readonly T[] =>
|
|
18
|
+
Object.freeze([...(values === undefined ? [] : new Set(values))]);
|
|
19
|
+
|
|
20
|
+
const hasField = (schema: StoreObjectSchema, field: string): boolean =>
|
|
21
|
+
Object.hasOwn(schema.shape, field);
|
|
22
|
+
|
|
23
|
+
const validateFieldList = (
|
|
24
|
+
tableName: string,
|
|
25
|
+
schema: StoreObjectSchema,
|
|
26
|
+
fields: readonly string[],
|
|
27
|
+
label: string
|
|
28
|
+
): void => {
|
|
29
|
+
for (const field of fields) {
|
|
30
|
+
if (hasField(schema, field)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
throw new ValidationError(
|
|
35
|
+
`Store table "${tableName}" declares ${label} field "${field}" that is not present on the schema`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const validateReferences = (
|
|
41
|
+
tableName: string,
|
|
42
|
+
schema: StoreObjectSchema,
|
|
43
|
+
references: Readonly<Partial<Record<string, string>>>,
|
|
44
|
+
tableNames: readonly string[]
|
|
45
|
+
): void => {
|
|
46
|
+
for (const [field, target] of Object.entries(references)) {
|
|
47
|
+
if (!hasField(schema, field)) {
|
|
48
|
+
throw new ValidationError(
|
|
49
|
+
`Store table "${tableName}" declares reference field "${field}" that is not present on the schema`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (target !== undefined && tableNames.includes(target)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new ValidationError(
|
|
58
|
+
`Store table "${tableName}" references unknown table "${target}"`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const buildFieldMask = (fields: readonly string[]): Record<string, true> =>
|
|
64
|
+
Object.fromEntries(fields.map((field) => [field, true] as const)) as Record<
|
|
65
|
+
string,
|
|
66
|
+
true
|
|
67
|
+
>;
|
|
68
|
+
|
|
69
|
+
const omitFields = <TSchema extends StoreObjectSchema>(
|
|
70
|
+
schema: TSchema,
|
|
71
|
+
fields: readonly string[]
|
|
72
|
+
): StoreObjectSchema => {
|
|
73
|
+
if (fields.length === 0) {
|
|
74
|
+
return schema;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return schema.omit(buildFieldMask(fields)) as StoreObjectSchema;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const partialFields = <TSchema extends StoreObjectSchema>(
|
|
81
|
+
schema: TSchema,
|
|
82
|
+
fields: readonly string[]
|
|
83
|
+
): StoreObjectSchema => {
|
|
84
|
+
if (fields.length === 0) {
|
|
85
|
+
return schema;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return schema.partial(buildFieldMask(fields)) as StoreObjectSchema;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const deriveInsertSchema = <TSchema extends StoreObjectSchema>(
|
|
92
|
+
schema: TSchema,
|
|
93
|
+
generated: readonly string[]
|
|
94
|
+
): StoreObjectSchema => omitFields(schema, generated);
|
|
95
|
+
|
|
96
|
+
const deriveFixtureSchema = <TSchema extends StoreObjectSchema>(
|
|
97
|
+
schema: TSchema,
|
|
98
|
+
generated: readonly string[]
|
|
99
|
+
): StoreObjectSchema => partialFields(schema, generated);
|
|
100
|
+
|
|
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
|
+
const deriveUpdateSchema = (
|
|
142
|
+
schema: StoreObjectSchema,
|
|
143
|
+
primaryKey: string
|
|
144
|
+
): StoreObjectSchema => {
|
|
145
|
+
const partial = schema
|
|
146
|
+
.extend(stripDefaultsFromShape(schema))
|
|
147
|
+
.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])
|
|
151
|
+
: partial;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const formatFixtureIssues = (issues: readonly { readonly message: string }[]) =>
|
|
155
|
+
issues.map((issue) => issue.message).join('; ');
|
|
156
|
+
|
|
157
|
+
const validateFixturePrimaryKeys = (
|
|
158
|
+
tableName: string,
|
|
159
|
+
primaryKey: string,
|
|
160
|
+
fixtures: readonly Record<string, unknown>[]
|
|
161
|
+
): void => {
|
|
162
|
+
const seen = new Set<unknown>();
|
|
163
|
+
|
|
164
|
+
for (const [index, fixture] of fixtures.entries()) {
|
|
165
|
+
const identifier = fixture[primaryKey];
|
|
166
|
+
if (identifier === undefined) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (seen.has(identifier)) {
|
|
171
|
+
throw new ValidationError(
|
|
172
|
+
`Store table "${tableName}" fixture ${index + 1} duplicates primary key "${String(identifier)}"`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
seen.add(identifier);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const normalizeReferences = (
|
|
181
|
+
references: Readonly<Partial<Record<string, string>>> | undefined
|
|
182
|
+
): Readonly<Record<string, string>> => {
|
|
183
|
+
const normalized: Record<string, string> = {};
|
|
184
|
+
|
|
185
|
+
for (const [field, target] of Object.entries(references ?? {})) {
|
|
186
|
+
if (target !== undefined) {
|
|
187
|
+
normalized[field] = target;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Object.freeze(normalized);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const validatePrimaryKey = (
|
|
195
|
+
tableName: string,
|
|
196
|
+
schema: StoreObjectSchema,
|
|
197
|
+
primaryKey: string
|
|
198
|
+
): void => {
|
|
199
|
+
if (hasField(schema, primaryKey)) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
throw new ValidationError(
|
|
204
|
+
`Store table "${tableName}" declares primaryKey "${primaryKey}" that is not present on the schema`
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const validateTableInput = (
|
|
209
|
+
tableName: string,
|
|
210
|
+
schema: StoreObjectSchema,
|
|
211
|
+
primaryKey: string,
|
|
212
|
+
generated: readonly string[],
|
|
213
|
+
indexes: readonly string[],
|
|
214
|
+
references: Readonly<Partial<Record<string, string>>>,
|
|
215
|
+
tableNames: readonly string[]
|
|
216
|
+
): void => {
|
|
217
|
+
validatePrimaryKey(tableName, schema, primaryKey);
|
|
218
|
+
validateFieldList(tableName, schema, generated, 'generated');
|
|
219
|
+
validateFieldList(tableName, schema, indexes, 'index');
|
|
220
|
+
validateReferences(tableName, schema, references, tableNames);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
type MutableTables<TTables extends StoreTablesInput> = {
|
|
224
|
+
-readonly [TName in keyof TTables]: StoreDefinition<TTables>['tables'][TName];
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const normalizeFixtures = <TInput extends StoreTableInput>(
|
|
228
|
+
tableName: string,
|
|
229
|
+
primaryKey: string,
|
|
230
|
+
fixtureSchema: StoreObjectSchema,
|
|
231
|
+
fixtures: TInput['fixtures']
|
|
232
|
+
): StoreTable<TInput>['fixtures'] => {
|
|
233
|
+
if (fixtures === undefined || fixtures.length === 0) {
|
|
234
|
+
return Object.freeze([]) as StoreTable<TInput>['fixtures'];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const normalized = [];
|
|
238
|
+
|
|
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));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
validateFixturePrimaryKeys(tableName, primaryKey, normalized);
|
|
251
|
+
return Object.freeze(normalized) as StoreTable<TInput>['fixtures'];
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const normalizeTable = <
|
|
255
|
+
TName extends string,
|
|
256
|
+
TInput extends StoreTableInput<StoreObjectSchema>,
|
|
257
|
+
>(
|
|
258
|
+
name: TName,
|
|
259
|
+
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
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const generated = uniqueStrings(input.generated);
|
|
269
|
+
const indexes = uniqueStrings(input.indexes);
|
|
270
|
+
const references = normalizeReferences(input.references);
|
|
271
|
+
validateTableInput(
|
|
272
|
+
name,
|
|
273
|
+
input.schema,
|
|
274
|
+
input.primaryKey,
|
|
275
|
+
generated,
|
|
276
|
+
indexes,
|
|
277
|
+
references,
|
|
278
|
+
tableNames
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const insertSchema = deriveInsertSchema(input.schema, generated);
|
|
282
|
+
const fixtureSchema = deriveFixtureSchema(input.schema, generated);
|
|
283
|
+
const fixtures = normalizeFixtures(
|
|
284
|
+
name,
|
|
285
|
+
input.primaryKey,
|
|
286
|
+
fixtureSchema,
|
|
287
|
+
input.fixtures
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return Object.freeze({
|
|
291
|
+
fixtureSchema,
|
|
292
|
+
fixtures,
|
|
293
|
+
generated,
|
|
294
|
+
indexes,
|
|
295
|
+
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>;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Declare a connector-agnostic store definition from entity schemas and
|
|
307
|
+
* persistence metadata.
|
|
308
|
+
*
|
|
309
|
+
* The returned value is a normalized, read-only contract that connectors can
|
|
310
|
+
* bind to a concrete runtime later.
|
|
311
|
+
*/
|
|
312
|
+
export const store = <const TTables extends StoreTablesInput>(
|
|
313
|
+
tables: TTables
|
|
314
|
+
): StoreDefinition<TTables> => {
|
|
315
|
+
const tableNames = Object.freeze(
|
|
316
|
+
Object.keys(tables).toSorted()
|
|
317
|
+
) 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
|
+
|
|
330
|
+
const get = <TName extends Extract<keyof TTables, string>>(name: TName) =>
|
|
331
|
+
normalized[name];
|
|
332
|
+
|
|
333
|
+
return Object.freeze({
|
|
334
|
+
get,
|
|
335
|
+
kind: 'store' as const,
|
|
336
|
+
tableNames,
|
|
337
|
+
tables: Object.freeze(normalized),
|
|
338
|
+
});
|
|
339
|
+
};
|
|
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;
|