@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.
Files changed (46) hide show
  1. package/.agents/notes/2026-04-04/handoff-202604032309-9e85a104.md +38 -0
  2. package/.turbo/turbo-build.log +1 -0
  3. package/.turbo/turbo-lint.log +3 -0
  4. package/.turbo/turbo-typecheck.log +1 -0
  5. package/CHANGELOG.md +12 -0
  6. package/README.md +213 -0
  7. package/dist/drizzle/index.d.ts +3 -0
  8. package/dist/drizzle/index.d.ts.map +1 -0
  9. package/dist/drizzle/index.js +2 -0
  10. package/dist/drizzle/index.js.map +1 -0
  11. package/dist/drizzle/runtime.d.ts +21 -0
  12. package/dist/drizzle/runtime.d.ts.map +1 -0
  13. package/dist/drizzle/runtime.js +458 -0
  14. package/dist/drizzle/runtime.js.map +1 -0
  15. package/dist/drizzle/schema.d.ts +15 -0
  16. package/dist/drizzle/schema.d.ts.map +1 -0
  17. package/dist/drizzle/schema.js +322 -0
  18. package/dist/drizzle/schema.js.map +1 -0
  19. package/dist/drizzle/types.d.ts +40 -0
  20. package/dist/drizzle/types.d.ts.map +1 -0
  21. package/dist/drizzle/types.js +2 -0
  22. package/dist/drizzle/types.js.map +1 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +2 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/store.d.ts +26 -0
  28. package/dist/store.d.ts.map +1 -0
  29. package/dist/store.js +192 -0
  30. package/dist/store.js.map +1 -0
  31. package/dist/types.d.ts +224 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +2 -0
  34. package/dist/types.js.map +1 -0
  35. package/package.json +29 -0
  36. package/src/__tests__/store.test.ts +333 -0
  37. package/src/drizzle/__tests__/drizzle.test.ts +469 -0
  38. package/src/drizzle/index.ts +17 -0
  39. package/src/drizzle/runtime.ts +853 -0
  40. package/src/drizzle/schema.ts +577 -0
  41. package/src/drizzle/types.ts +70 -0
  42. package/src/index.ts +39 -0
  43. package/src/store.ts +367 -0
  44. package/src/types.ts +361 -0
  45. package/tsconfig.json +9 -0
  46. 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;