@zmdb/orm 1.0.0-beta.1

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 (87) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +30 -0
  3. package/dist/cache/index.d.ts +34 -0
  4. package/dist/cache/index.d.ts.map +1 -0
  5. package/dist/cache/index.js +149 -0
  6. package/dist/cache/index.js.map +1 -0
  7. package/dist/drivers/transactional.d.ts +6 -0
  8. package/dist/drivers/transactional.d.ts.map +1 -0
  9. package/dist/drivers/transactional.js +2 -0
  10. package/dist/drivers/transactional.js.map +1 -0
  11. package/dist/dto/index.d.ts +41 -0
  12. package/dist/dto/index.d.ts.map +1 -0
  13. package/dist/dto/index.js +334 -0
  14. package/dist/dto/index.js.map +1 -0
  15. package/dist/entity-modeling/index.d.ts +18 -0
  16. package/dist/entity-modeling/index.d.ts.map +1 -0
  17. package/dist/entity-modeling/index.js +26 -0
  18. package/dist/entity-modeling/index.js.map +1 -0
  19. package/dist/filters/index.d.ts +62 -0
  20. package/dist/filters/index.d.ts.map +1 -0
  21. package/dist/filters/index.js +163 -0
  22. package/dist/filters/index.js.map +1 -0
  23. package/dist/index.d.ts +413 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +2003 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/loaders/index.d.ts +38 -0
  28. package/dist/loaders/index.d.ts.map +1 -0
  29. package/dist/loaders/index.js +164 -0
  30. package/dist/loaders/index.js.map +1 -0
  31. package/dist/outbox/index.d.ts +59 -0
  32. package/dist/outbox/index.d.ts.map +1 -0
  33. package/dist/outbox/index.js +323 -0
  34. package/dist/outbox/index.js.map +1 -0
  35. package/dist/outbox/sql.d.ts +52 -0
  36. package/dist/outbox/sql.d.ts.map +1 -0
  37. package/dist/outbox/sql.js +115 -0
  38. package/dist/outbox/sql.js.map +1 -0
  39. package/dist/relations/index.d.ts +38 -0
  40. package/dist/relations/index.d.ts.map +1 -0
  41. package/dist/relations/index.js +164 -0
  42. package/dist/relations/index.js.map +1 -0
  43. package/dist/replicas/index.d.ts +10 -0
  44. package/dist/replicas/index.d.ts.map +1 -0
  45. package/dist/replicas/index.js +42 -0
  46. package/dist/replicas/index.js.map +1 -0
  47. package/dist/seeding/index.d.ts +18 -0
  48. package/dist/seeding/index.d.ts.map +1 -0
  49. package/dist/seeding/index.js +66 -0
  50. package/dist/seeding/index.js.map +1 -0
  51. package/dist/streaming/index.d.ts +7 -0
  52. package/dist/streaming/index.d.ts.map +1 -0
  53. package/dist/streaming/index.js +67 -0
  54. package/dist/streaming/index.js.map +1 -0
  55. package/dist/testing/official-dialects.fixture.d.ts +16 -0
  56. package/dist/testing/official-dialects.fixture.d.ts.map +1 -0
  57. package/dist/testing/official-dialects.fixture.js +21 -0
  58. package/dist/testing/official-dialects.fixture.js.map +1 -0
  59. package/dist/testing/repository.fixture.d.ts +11 -0
  60. package/dist/testing/repository.fixture.d.ts.map +1 -0
  61. package/dist/testing/repository.fixture.js +33 -0
  62. package/dist/testing/repository.fixture.js.map +1 -0
  63. package/dist/transactions/index.d.ts +40 -0
  64. package/dist/transactions/index.d.ts.map +1 -0
  65. package/dist/transactions/index.js +249 -0
  66. package/dist/transactions/index.js.map +1 -0
  67. package/dist/typed-populate/nested.fixtures.d.ts +34 -0
  68. package/dist/typed-populate/nested.fixtures.d.ts.map +1 -0
  69. package/dist/typed-populate/nested.fixtures.js +4 -0
  70. package/dist/typed-populate/nested.fixtures.js.map +1 -0
  71. package/package.json +78 -0
  72. package/src/cache/index.ts +188 -0
  73. package/src/drivers/transactional.ts +6 -0
  74. package/src/dto/index.ts +379 -0
  75. package/src/entity-modeling/index.ts +38 -0
  76. package/src/filters/index.ts +267 -0
  77. package/src/index.ts +2757 -0
  78. package/src/loaders/index.ts +256 -0
  79. package/src/outbox/index.ts +409 -0
  80. package/src/outbox/sql.ts +179 -0
  81. package/src/relations/index.ts +221 -0
  82. package/src/replicas/index.ts +51 -0
  83. package/src/seeding/index.ts +72 -0
  84. package/src/streaming/index.ts +67 -0
  85. package/src/testing/repository.fixture.ts +44 -0
  86. package/src/transactions/index.ts +315 -0
  87. package/src/typed-populate/nested.fixtures.ts +59 -0
package/dist/index.js ADDED
@@ -0,0 +1,2003 @@
1
+ import { isRecord, resolveRelation, } from '@zmdb/schema';
2
+ import {} from '@zmdb/schema/derive';
3
+ import { buildListResult, decodeCursor, } from '@zmdb/schema/dto';
4
+ import { appTypeOf, dbDecodedColumns, decodeDbValue, objectTypeFromShape, SQL_TYPES, shapeOfVariant, } from '@zmdb/schema/ir';
5
+ import {} from '@zmdb/schema/tags';
6
+ import {} from '@zmdb/sql';
7
+ import { chunkArray, createQueryCompiler, dialectCapabilities, dialectTraits, EXPR, inc, proposed, sanitizeKeys, } from '@zmdb/sql';
8
+ import { aggregateSelectFrom } from '@zmdb/sql/aggregations';
9
+ import { ftsSelectFrom } from '@zmdb/sql/fts';
10
+ import { joinableSelectFrom } from '@zmdb/sql/joins';
11
+ import {} from '@zmdb/sql/schema-objects';
12
+ // @zmdb/orm — the repository layer: reads (#26), writes (#27), delete +
13
+ // lifecycle hooks (#28), transactions (#37), typed populate (#217) and the
14
+ // no-subclass wiring helper (#223). Every SQL statement comes from
15
+ // @zmdb/sql and every type from the schema; there is no runtime
16
+ // reflection, no proxies and no identity map.
17
+ import { issuesFor, ValidationError } from '@zmdb/validator';
18
+ import { cacheTags, copyCachedRows, memoryStore, resultCacheKey, } from './cache/index.js';
19
+ import { compileWhere, applyOrderBy, applyPagination, applyKeysetFilter } from './dto/index.js';
20
+ import { applyResolvedFilters, filtersAsPredicates, resolveFilters, } from './filters/index.js';
21
+ import { createEntityLoader, createRelationLoader, LOADER_ENTITY_BATCH, LOADER_ENTITY_KEY, LOADER_FOR_SCOPE, LOADER_RELATION_BATCH, LOADER_RELATION_KEY, RELATION_LOADER_FOR_SCOPE, } from './loaders/index.js';
22
+ import {} from './relations/index.js';
23
+ import { createRepositoryStream } from './streaming/index.js';
24
+ const BUFFERED_STREAM_REPORTED = new WeakSet();
25
+ function executeOptions(signal, batchSize) {
26
+ if (signal === undefined)
27
+ return batchSize === undefined ? undefined : { batchSize };
28
+ return batchSize === undefined ? { signal } : { signal, batchSize };
29
+ }
30
+ function parseTableSpec(spec) {
31
+ const match = /^(\S+)(?:\s+(?:as\s+)?(\S+))?$/i.exec(spec.trim());
32
+ const table = match?.[1] ?? spec.trim();
33
+ return { table, reference: match?.[2] ?? table };
34
+ }
35
+ function isFilterDef(value) {
36
+ return (isRecord(value) &&
37
+ typeof value.name === 'string' &&
38
+ typeof value.where === 'function' &&
39
+ (value.table === undefined || typeof value.table === 'string') &&
40
+ (value.schema === undefined ||
41
+ (isRecord(value.schema) && typeof value.schema.table === 'string' && isRecord(value.schema.ir))) &&
42
+ (value.enabled === undefined || typeof value.enabled === 'boolean') &&
43
+ (value.appliesToWrites === undefined || typeof value.appliesToWrites === 'boolean'));
44
+ }
45
+ function staticFiltersFor(constructor) {
46
+ const value = Reflect.get(constructor, 'filters');
47
+ if (value === undefined)
48
+ return [];
49
+ if (!Array.isArray(value) || !value.every(isFilterDef)) {
50
+ throw new ValidationError('repository static filters must be an array of filter definitions');
51
+ }
52
+ return value;
53
+ }
54
+ function schemaSqlNames(schema) {
55
+ const ir = schema.ir;
56
+ const columns = new Map(ir.columns.map(column => [column.name, column.physicalName]));
57
+ const renamed = ir.columns.some(column => column.name !== column.physicalName);
58
+ const entityProjection = renamed
59
+ ? Object.freeze(ir.columns.map(column => column.name === column.physicalName
60
+ ? column.physicalName
61
+ : { column: column.physicalName, alias: column.name }))
62
+ : undefined;
63
+ const keyColumns = Object.freeze([...ir.primaryKey]);
64
+ return {
65
+ schema,
66
+ ir,
67
+ columns,
68
+ keyColumns,
69
+ physicalKeyColumns: Object.freeze(keyColumns.map(column => columns.get(column) ?? column)),
70
+ entityProjection,
71
+ };
72
+ }
73
+ export { ValidationError };
74
+ /** A composite key omitted one or more required own properties. */
75
+ export class IncompleteKeyError extends ValidationError {
76
+ table;
77
+ missing;
78
+ constructor(table, method, missing) {
79
+ const orderedMissing = Object.freeze([...missing]);
80
+ super(`${table}.${method} requires every key column; missing: ${orderedMissing.join(', ')}`);
81
+ this.name = 'IncompleteKeyError';
82
+ this.table = table;
83
+ this.missing = orderedMissing;
84
+ }
85
+ }
86
+ function isUpdateFieldList(value) {
87
+ return Array.isArray(value);
88
+ }
89
+ const ROUTINE_SQL_TYPES = new Set(SQL_TYPES);
90
+ function isRoutineDefinition(value) {
91
+ if (!isRecord(value))
92
+ return false;
93
+ if (value.kind !== 'function' && value.kind !== 'procedure')
94
+ return false;
95
+ if (typeof value.name !== 'string' || value.name.trim().length === 0)
96
+ return false;
97
+ if (typeof value.body !== 'string' || !Array.isArray(value.params))
98
+ return false;
99
+ if (value.language !== undefined && typeof value.language !== 'string')
100
+ return false;
101
+ if (value.deterministic !== undefined && typeof value.deterministic !== 'boolean')
102
+ return false;
103
+ for (const param of value.params) {
104
+ if (!isRecord(param))
105
+ return false;
106
+ if (typeof param.name !== 'string' || param.name.trim().length === 0)
107
+ return false;
108
+ if (typeof param.type !== 'string' || !ROUTINE_SQL_TYPES.has(param.type))
109
+ return false;
110
+ if (param.mode !== undefined && param.mode !== 'in' && param.mode !== 'out' && param.mode !== 'inout') {
111
+ return false;
112
+ }
113
+ }
114
+ if (value.returns !== undefined) {
115
+ if (!isRecord(value.returns))
116
+ return false;
117
+ if (typeof value.returns.type !== 'string' ||
118
+ (value.returns.type !== 'void' && !ROUTINE_SQL_TYPES.has(value.returns.type))) {
119
+ return false;
120
+ }
121
+ if (value.returns.setof !== undefined && typeof value.returns.setof !== 'boolean')
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+ function routineColumn(name, sql) {
127
+ return {
128
+ name,
129
+ physicalName: name,
130
+ sql,
131
+ nullable: false,
132
+ primaryKey: false,
133
+ serial: false,
134
+ unique: false,
135
+ hasDefault: false,
136
+ sensitive: false,
137
+ constraints: {},
138
+ rules: [],
139
+ // A routine declaration carries only the SQL type, so bare JSON has no
140
+ // narrower payload witness and is correctly validated as unknown.
141
+ ...(sql === 'json' ? { payload: { kind: 'unknown' } } : {}),
142
+ };
143
+ }
144
+ function validatedRoutineValue(value, type) {
145
+ const issues = issuesFor(value, type, 'result');
146
+ if (issues.length > 0) {
147
+ throw new ValidationError(`validation failed: ${issues.map(issue => issue.path).join(', ')}`, issues);
148
+ }
149
+ return value;
150
+ }
151
+ /**
152
+ * Whether a value can be a single-column primary key.
153
+ *
154
+ * The composite branch of `keyWhere` has always refused a key that is missing a
155
+ * column; the single-column branch accepted anything and wrapped it, so an object
156
+ * arriving from untyped input became `{ id: { … } }` — a where-spec that compiles to
157
+ * no predicate at all and therefore an `UPDATE` or `DELETE` over the whole table
158
+ * (#608). `PrimaryKeyOf<T>` rules this out at compile time for typed callers; this is
159
+ * for the value that did not come through the types.
160
+ *
161
+ * `Date` counts as a scalar: it is one column's value for a `timestamp` key, and the
162
+ * composite branch excludes it by name for the same reason.
163
+ */
164
+ function isScalarKey(value) {
165
+ return (typeof value === 'string' ||
166
+ typeof value === 'number' ||
167
+ typeof value === 'bigint' ||
168
+ typeof value === 'boolean' ||
169
+ value instanceof Date);
170
+ }
171
+ /** What a rejected key was, for the error message. Never the value itself. */
172
+ function describeKey(value) {
173
+ if (value === null)
174
+ return 'null';
175
+ if (value === undefined)
176
+ return 'undefined';
177
+ if (value instanceof Date)
178
+ return 'a Date';
179
+ if (Array.isArray(value))
180
+ return 'an array';
181
+ if (typeof value === 'object')
182
+ return 'an object';
183
+ return typeof value === 'function' ? 'a function' : `a ${typeof value}`;
184
+ }
185
+ /** Type-tag one SQL key value so values such as `1`, `'1'` and `1n` cannot collide. */
186
+ function loaderKeyPart(value) {
187
+ if (value instanceof Date)
188
+ return `d:${value.getTime()}`;
189
+ if (value === null)
190
+ return 'z:';
191
+ if (value === undefined)
192
+ return 'u:';
193
+ if (typeof value === 'string')
194
+ return `s:${value.length}:${value}`;
195
+ if (typeof value === 'number')
196
+ return `n:${String(value)}`;
197
+ if (typeof value === 'bigint')
198
+ return `i:${String(value)}`;
199
+ if (typeof value === 'boolean')
200
+ return `b:${String(value)}`;
201
+ throw new ValidationError(`dataloader keys must be SQL scalar values; got ${describeKey(value)}`);
202
+ }
203
+ /** Length-prefix each tagged part so composite-key boundaries are unambiguous. */
204
+ function scalarLoaderKey(value) {
205
+ const part = typeof value === 'number' ? `n:${String(value)}` : loaderKeyPart(value);
206
+ return `${part.length}:${part}`;
207
+ }
208
+ function loaderKey(parts) {
209
+ if (parts.length === 1)
210
+ return scalarLoaderKey(parts[0]);
211
+ let key = '';
212
+ for (const value of parts)
213
+ key += scalarLoaderKey(value);
214
+ return key;
215
+ }
216
+ function relationKeyValues(row, columns) {
217
+ return columns.map(column => Reflect.get(row, column));
218
+ }
219
+ function relationRowKey(row, columns) {
220
+ let key = '';
221
+ for (const column of columns) {
222
+ const value = Reflect.get(row, column);
223
+ if (value === null || value === undefined)
224
+ return undefined;
225
+ key += scalarLoaderKey(value);
226
+ }
227
+ return key;
228
+ }
229
+ function hasNullishKeyPart(values) {
230
+ return values.some(value => value === null || value === undefined);
231
+ }
232
+ function copyPopulatedRow(row, nodes) {
233
+ const copy = { ...row };
234
+ for (const node of nodes) {
235
+ const value = Reflect.get(row, node.relation.name);
236
+ copy[node.relation.name] = Array.isArray(value)
237
+ ? value.map(child => copyPopulatedRow(child, node.children))
238
+ : value === null
239
+ ? null
240
+ : copyPopulatedRow(value, node.children);
241
+ }
242
+ return copy;
243
+ }
244
+ const NO_EXPRESSION_OPERAND = Symbol('zmdb.no-expression-operand');
245
+ function isColumnExpression(value) {
246
+ return typeof value === 'object' && value !== null && EXPR in value;
247
+ }
248
+ function expressionOperand(expression) {
249
+ switch (expression.op) {
250
+ case 'add':
251
+ case 'sub':
252
+ case 'mul':
253
+ return expression.by;
254
+ case 'concat':
255
+ return expression.with;
256
+ case 'coalesce':
257
+ return expression.fallback;
258
+ case 'not':
259
+ case 'proposed':
260
+ return NO_EXPRESSION_OPERAND;
261
+ }
262
+ }
263
+ /**
264
+ * The base repository.
265
+ *
266
+ * `T` is the **declared type** — the interface the table was written as — and every DTO on
267
+ * this class derives from it. It is not the schema value: a repository is handed one of
268
+ * those at construction, and `defineRepository` recovers `T` from its phantom, but nothing
269
+ * here reads a column map to work out what a row looks like.
270
+ *
271
+ * Relations come from `T` as well. There used to be a second type parameter for them,
272
+ * paired with a static `relations` map, because `Entity<T>` was derived from a schema value
273
+ * and a schema value carries no relations — so the map was the only place the runtime could
274
+ * learn that `orders` means `orders.userId`. The declaration says it:
275
+ *
276
+ * ```ts
277
+ * interface User extends Table<'users'> {
278
+ * id: number & Sql<'integer'> & Serial & PrimaryKey;
279
+ * orders?: Order[] & OneToMany<'orders', 'userId'>;
280
+ * }
281
+ *
282
+ * class Users extends BaseRepository<User> {
283
+ * static override readonly schema = UserSchema;
284
+ * }
285
+ * ```
286
+ *
287
+ * `populate: ['orders']` is checked against `RelationKeys<User>` and the batched select it
288
+ * runs comes from the same tag. `defineRepository` needs no subclass at all.
289
+ */
290
+ export class BaseRepository {
291
+ static schema;
292
+ driver;
293
+ qb;
294
+ dialect;
295
+ dialectCapabilities;
296
+ dialectTraits;
297
+ /** Ordered primary-key columns, resolved once because a repository's schema cannot change. */
298
+ keyColumns;
299
+ physicalKeyColumns;
300
+ #rootSqlNames;
301
+ #sqlNamesByDeclaredTable = new Map();
302
+ #sqlNamesByPhysicalTable = new Map();
303
+ #additionalSchemas;
304
+ /** variant → its columns and their object type. See `payloadShape`. */
305
+ #shapes = new Map();
306
+ /** The columns a driver may hand back in their storage form. See `decodeRows`. */
307
+ #decoded;
308
+ /** Undefined until a custom store is supplied or an opted-in read needs the bounded default. */
309
+ #cacheStore;
310
+ #cacheFailureReported = false;
311
+ #optionFilters;
312
+ #filterDefinitions;
313
+ #onQuery;
314
+ #queryFilters = new WeakMap();
315
+ /** Loader state is keyed by the explicit request-scope token, never globally. */
316
+ #entityLoaders = new WeakMap();
317
+ #relationLoaders = new WeakMap();
318
+ constructor(driver, dialect = driver.dialect, options) {
319
+ if (dialect === undefined) {
320
+ throw new TypeError('repository construction requires an explicit database dialect object');
321
+ }
322
+ this.driver = driver;
323
+ this.dialect = dialect;
324
+ this.dialectCapabilities = dialectCapabilities(dialect);
325
+ this.dialectTraits = dialectTraits(dialect);
326
+ this.#cacheStore = options?.cacheStore;
327
+ this.#optionFilters = Object.freeze([...(options?.filters ?? [])]);
328
+ const staticFilters = staticFiltersFor(this.constructor);
329
+ this.#additionalSchemas = Object.freeze([...(options?.schemas ?? [])]);
330
+ const rootSchema = this.schema;
331
+ const configuredSchemas = [
332
+ rootSchema,
333
+ ...this.#additionalSchemas,
334
+ ...staticFilters.flatMap(filter => (filter.schema === undefined ? [] : [filter.schema])),
335
+ ...this.#optionFilters.flatMap(filter => (filter.schema === undefined ? [] : [filter.schema])),
336
+ ];
337
+ for (const schema of configuredSchemas) {
338
+ const names = schemaSqlNames(schema);
339
+ const existing = this.#sqlNamesByDeclaredTable.get(names.ir.table);
340
+ if (existing !== undefined) {
341
+ if (existing.schema.table !== schema.table) {
342
+ throw new ValidationError(`repository schemas disagree on physical table for \`${names.ir.table}\`: ` +
343
+ `\`${existing.schema.table}\` and \`${schema.table}\``);
344
+ }
345
+ continue;
346
+ }
347
+ this.#sqlNamesByDeclaredTable.set(names.ir.table, names);
348
+ this.#sqlNamesByPhysicalTable.set(schema.table, names);
349
+ }
350
+ const rootNames = this.#sqlNamesByPhysicalTable.get(rootSchema.table);
351
+ if (rootNames === undefined)
352
+ throw new Error(`repository schema map omitted ${rootSchema.table}`);
353
+ this.#rootSqlNames = rootNames;
354
+ this.#filterDefinitions = Object.freeze([...staticFilters, ...this.#optionFilters].map(filter => {
355
+ if (filter.table !== undefined &&
356
+ filter.schema !== undefined &&
357
+ filter.table !== filter.schema.ir.table &&
358
+ filter.table !== filter.schema.table) {
359
+ throw new ValidationError(`filter \`${filter.name}\` targets \`${filter.table}\` but its schema declares ` +
360
+ `\`${filter.schema.ir.table}\` (physical \`${filter.schema.table}\`)`);
361
+ }
362
+ const mapped = filter.schema === undefined
363
+ ? filter.table === undefined
364
+ ? this.#rootSqlNames
365
+ : (this.#sqlNamesByDeclaredTable.get(filter.table) ?? this.#sqlNamesByPhysicalTable.get(filter.table))
366
+ : this.#sqlNamesByDeclaredTable.get(filter.schema.ir.table);
367
+ return { ...filter, table: mapped?.schema.table ?? filter.table ?? this.schema.table };
368
+ }));
369
+ this.#onQuery = options?.onQuery;
370
+ this.qb = createQueryCompiler(dialect, driver.queryTelemetry === true ? { telemetry: true } : undefined);
371
+ this.keyColumns = this.#rootSqlNames.keyColumns;
372
+ this.physicalKeyColumns = this.#rootSqlNames.physicalKeyColumns;
373
+ const seen = new Set();
374
+ for (const filter of this.#filterDefinitions) {
375
+ if (filter.name.trim().length === 0)
376
+ throw new ValidationError('filter names must not be empty');
377
+ const identity = `${filter.table ?? this.schema.table}\u0000${filter.name}`;
378
+ if (seen.has(identity)) {
379
+ throw new ValidationError(`filter \`${filter.name}\` is declared more than once for \`${filter.table}\``);
380
+ }
381
+ seen.add(identity);
382
+ }
383
+ }
384
+ [LOADER_FOR_SCOPE](scope) {
385
+ const existing = this.#entityLoaders.get(scope);
386
+ if (existing)
387
+ return existing;
388
+ const created = createEntityLoader(this);
389
+ this.#entityLoaders.set(scope, created);
390
+ return created;
391
+ }
392
+ [RELATION_LOADER_FOR_SCOPE](scope, relation) {
393
+ let loaders = this.#relationLoaders.get(scope);
394
+ if (!loaders) {
395
+ loaders = {};
396
+ this.#relationLoaders.set(scope, loaders);
397
+ }
398
+ const existing = Object.hasOwn(loaders, relation) ? loaders[relation] : undefined;
399
+ if (existing)
400
+ return existing;
401
+ const created = createRelationLoader(this, relation);
402
+ Object.defineProperty(loaders, relation, {
403
+ configurable: false,
404
+ enumerable: true,
405
+ value: created,
406
+ writable: false,
407
+ });
408
+ return created;
409
+ }
410
+ // #37 — bind this repository to a transaction context so all its SQL runs
411
+ // on the transaction's connection. Re-instantiates via standard constructor
412
+ // invocation to allocate private instance state and avoid method binding leaks.
413
+ //
414
+ // boundary: the assertion names the constructor this class declares, on the subclass that
415
+ // inherited it, and `this` as its return type. A subclass that widens the signature —
416
+ // taking a required third argument — would be constructed here without it; that is a
417
+ // subclass contract, like the static `schema`, and there is no way to state it in the type
418
+ // system from inside the base class. `new (this.constructor as …)` is the only way to
419
+ // re-run field initialisers, which is the point: `Object.create` would share `#shapes`.
420
+ withTransaction(tx) {
421
+ const txStream = typeof tx.stream === 'function' ? tx.stream : undefined;
422
+ const txDriver = {
423
+ dialect: this.dialect,
424
+ ...(this.driver.queryTelemetry === true ? { queryTelemetry: true } : {}),
425
+ execute: (query, opts) => tx.execute(query, opts),
426
+ ...(txStream === undefined
427
+ ? {}
428
+ : {
429
+ stream: (query, opts) => txStream.call(tx, query, opts),
430
+ }),
431
+ };
432
+ const ctor = this.constructor;
433
+ const options = {
434
+ ...(this.#cacheStore === undefined ? {} : { cacheStore: this.#cacheStore }),
435
+ ...(this.#optionFilters.length === 0 ? {} : { filters: this.#optionFilters }),
436
+ ...(this.#additionalSchemas.length === 0 ? {} : { schemas: this.#additionalSchemas }),
437
+ ...(this.#onQuery === undefined ? {} : { onQuery: this.#onQuery }),
438
+ };
439
+ return new ctor(txDriver, this.dialect, Object.keys(options).length === 0 ? undefined : options);
440
+ }
441
+ async call(definition, args) {
442
+ if (!isRoutineDefinition(definition)) {
443
+ throw new ValidationError('routine calls require a declared RoutineDef', [
444
+ {
445
+ path: 'routine',
446
+ message: 'expected a declared routine definition, not a caller-supplied name',
447
+ expected: 'RoutineDef',
448
+ value: definition,
449
+ },
450
+ ]);
451
+ }
452
+ if (!Array.isArray(args)) {
453
+ throw new ValidationError(`routine "${definition.name}" arguments must be a tuple`, [
454
+ { path: 'input', message: 'expected array', expected: 'array', value: args },
455
+ ]);
456
+ }
457
+ const unsupported = definition.params.find(param => param.mode === 'out' || param.mode === 'inout');
458
+ if (unsupported) {
459
+ throw new ValidationError(`routine "${definition.name}" parameter "${unsupported.name}" uses unsupported mode "${unsupported.mode}"`);
460
+ }
461
+ if (args.length !== definition.params.length) {
462
+ throw new ValidationError(`routine "${definition.name}" expects ${definition.params.length} argument(s), received ${args.length}`, [
463
+ {
464
+ path: 'input',
465
+ message: `expected ${definition.params.length} argument(s), received ${args.length}`,
466
+ expected: `tuple of length ${definition.params.length}`,
467
+ value: args,
468
+ },
469
+ ]);
470
+ }
471
+ const seenNames = new Set();
472
+ const input = {};
473
+ const shape = definition.params.map((param, index) => {
474
+ if (seenNames.has(param.name)) {
475
+ throw new ValidationError(`routine "${definition.name}" declares parameter "${param.name}" more than once`);
476
+ }
477
+ seenNames.add(param.name);
478
+ input[param.name] = args[index];
479
+ return { column: routineColumn(param.name, param.type), optional: false };
480
+ });
481
+ // Binding protects the outer SQL call, but not dynamic SQL inside an opaque
482
+ // routine body. A routine may also run with definer rights, so every
483
+ // argument is validated before even compiling the privileged call.
484
+ const argumentIssues = issuesFor(input, objectTypeFromShape(shape));
485
+ if (argumentIssues.length > 0) {
486
+ throw new ValidationError(`validation failed: ${argumentIssues.map(issue => issue.path).join(', ')}`, argumentIssues);
487
+ }
488
+ const query = definition.kind === 'procedure'
489
+ ? this.qb.callProcedure(definition.name, args)
490
+ : definition.returns?.setof === true
491
+ ? this.qb.callTableFunction(definition.name, args)
492
+ : this.qb.callFunction(definition.name, args);
493
+ const rows = await this.driver.execute(query);
494
+ if (definition.kind === 'procedure' || definition.returns === undefined || definition.returns.type === 'void') {
495
+ return validatedRoutineValue(undefined, { kind: 'undefined' });
496
+ }
497
+ const column = routineColumn(definition.name, definition.returns.type);
498
+ const resultType = appTypeOf(column);
499
+ if (definition.returns.setof === true) {
500
+ const values = rows.map(row => decodeDbValue(column, row[definition.name]));
501
+ return validatedRoutineValue(values, { kind: 'array', element: resultType });
502
+ }
503
+ const value = decodeDbValue(column, rows[0]?.result);
504
+ return validatedRoutineValue(value, resultType);
505
+ }
506
+ get schema() {
507
+ // boundary: `this.constructor` is typed `Function`; there is no way to say
508
+ // "the static side of my own class". The subclass contract is
509
+ // `static readonly schema = …`, declared abstractly above.
510
+ return this.constructor.schema;
511
+ }
512
+ /**
513
+ * One relation, by name, resolved from the declaration.
514
+ *
515
+ * No assertion, which is the difference from the static `relations` map this replaced:
516
+ * `schema.ir.relations` is a field of the schema value every repository is handed, so
517
+ * there is nothing to claim about `this.constructor`. The resolution itself lives in
518
+ * `@zmdb/schema` because `compilePopulate` needs the same answer, and two resolvers
519
+ * over one declaration is how the join and the batched select came to disagree before.
520
+ */
521
+ relation(name) {
522
+ return resolveRelation(this.schema.ir, name);
523
+ }
524
+ get tableName() {
525
+ return this.schema.table;
526
+ }
527
+ sqlNamesForTable(table) {
528
+ return this.#sqlNamesByDeclaredTable.get(table) ?? this.#sqlNamesByPhysicalTable.get(table);
529
+ }
530
+ relationSqlNames(relation) {
531
+ return this.#sqlNamesByDeclaredTable.get(relation.targetTable);
532
+ }
533
+ physicalColumn(column, names = this.#rootSqlNames) {
534
+ const separator = column.lastIndexOf('.');
535
+ const property = separator === -1 ? column : column.slice(separator + 1);
536
+ const physical = names.columns.get(property);
537
+ if (physical === undefined)
538
+ return column;
539
+ if (separator === -1)
540
+ return physical;
541
+ const qualifier = column.slice(0, separator);
542
+ const physicalQualifier = qualifier === names.schema.ir.table || qualifier === names.schema.table ? names.schema.table : qualifier;
543
+ return `${physicalQualifier}.${physical}`;
544
+ }
545
+ aggregateColumn(column) {
546
+ const separator = column.indexOf('.');
547
+ if (separator === -1)
548
+ return this.physicalColumn(column);
549
+ const relationName = column.slice(0, separator);
550
+ const relation = this.schema.ir.relations.find(candidate => candidate.name === relationName);
551
+ if (relation === undefined)
552
+ return this.physicalColumn(column);
553
+ const target = this.#sqlNamesByDeclaredTable.get(relation.target);
554
+ return target === undefined ? column : this.physicalColumn(column, target);
555
+ }
556
+ aggregateSelection(column) {
557
+ const physical = this.aggregateColumn(column);
558
+ return physical === column ? column : `${physical} as ${column}`;
559
+ }
560
+ selectEntity(names = this.#rootSqlNames) {
561
+ const builder = this.qb.selectFrom(names.schema.table);
562
+ return names.entityProjection === undefined ? builder : builder.select(names.entityProjection);
563
+ }
564
+ selectProperties(properties, names = this.#rootSqlNames) {
565
+ const selected = [];
566
+ const seen = new Set();
567
+ for (const property of properties) {
568
+ if (seen.has(property))
569
+ continue;
570
+ seen.add(property);
571
+ const physical = this.physicalColumn(property, names);
572
+ selected.push(physical === property ? physical : { column: physical, alias: property });
573
+ }
574
+ return selected.length === 0 ? this.selectEntity(names) : this.qb.selectFrom(names.schema.table).select(selected);
575
+ }
576
+ entityReturning(names = this.#rootSqlNames) {
577
+ return names.entityProjection ?? ['*'];
578
+ }
579
+ physicalRecord(record, names = this.#rootSqlNames) {
580
+ const physical = {};
581
+ for (const [property, value] of Object.entries(record)) {
582
+ physical[this.physicalColumn(property, names)] = value;
583
+ }
584
+ return physical;
585
+ }
586
+ physicalFields(fields) {
587
+ if (fields === undefined)
588
+ return undefined;
589
+ return isUpdateFieldList(fields) ? fields.map(column => this.physicalColumn(column)) : this.physicalRecord(fields);
590
+ }
591
+ filterDefinitionsFor(table, schema) {
592
+ const definitions = this.#filterDefinitions.filter(filter => filter.table === table);
593
+ const softDelete = (schema === undefined ? this.sqlNamesForTable(table) : this.sqlNamesForTable(schema.table))?.ir.softDelete ??
594
+ schema?.ir.softDelete;
595
+ if (softDelete === undefined || definitions.some(filter => filter.name === 'softDelete')) {
596
+ return definitions;
597
+ }
598
+ return [
599
+ ...definitions,
600
+ {
601
+ name: 'softDelete',
602
+ table,
603
+ where: (_params) => [
604
+ {
605
+ col: softDelete.column,
606
+ op: 'is null',
607
+ value: undefined,
608
+ },
609
+ ],
610
+ },
611
+ ];
612
+ }
613
+ knownFilterNames(definitions, additional = []) {
614
+ return [...new Set([...definitions.map(filter => filter.name), ...additional])];
615
+ }
616
+ rootFilterNames() {
617
+ return this.filterDefinitionsFor(this.tableName, this.schema).map(filter => filter.name);
618
+ }
619
+ allDeclaredFilterNames() {
620
+ return [...new Set([...this.rootFilterNames(), ...this.#filterDefinitions.map(filter => filter.name)])];
621
+ }
622
+ populatePlan(paths) {
623
+ const roots = [];
624
+ const nodes = new Map();
625
+ for (const path of paths ?? []) {
626
+ const parts = path.split('.');
627
+ if (parts.some(part => part.length === 0))
628
+ throw new ValidationError(`invalid populate path "${path}"`);
629
+ let source = this.schema.ir;
630
+ let parent;
631
+ for (const name of parts) {
632
+ const prefix = parent === undefined ? name : `${parent.path}.${name}`;
633
+ let node = nodes.get(prefix);
634
+ if (node === undefined) {
635
+ const relation = resolveRelation(source, name);
636
+ node = { path: prefix, relation, target: this.relationSqlNames(relation), children: [] };
637
+ nodes.set(prefix, node);
638
+ (parent?.children ?? roots).push(node);
639
+ }
640
+ if (parts.length > 1 && node.target === undefined) {
641
+ throw new ValidationError(`populate path "${path}" requires the schema for "${node.relation.targetTable}" in RepositoryOptions.schemas`);
642
+ }
643
+ source = node.target?.ir ?? source;
644
+ parent = node;
645
+ }
646
+ }
647
+ return { roots, nodes: [...nodes.values()] };
648
+ }
649
+ populateFilterNames(names, plan = this.populatePlan(names)) {
650
+ const known = new Set(this.rootFilterNames());
651
+ for (const { relation, target } of plan.nodes) {
652
+ for (const filter of this.filterDefinitionsFor(target?.schema.table ?? relation.targetTable, target?.schema)) {
653
+ known.add(filter.name);
654
+ }
655
+ }
656
+ return [...known];
657
+ }
658
+ resolveReadFilters(method, options, table, schema, qualifyColumns, additionalKnownNames = [], columnPrefix = table) {
659
+ const definitions = this.filterDefinitionsFor(table, schema);
660
+ return resolveFilters(definitions, options?.filters, {
661
+ method,
662
+ table,
663
+ columnPrefix,
664
+ ...(schema === undefined ? {} : { schema }),
665
+ ...(qualifyColumns ? { qualifyColumns: true } : {}),
666
+ knownNames: this.knownFilterNames(definitions, additionalKnownNames),
667
+ });
668
+ }
669
+ /**
670
+ * The only place a repository read becomes a CompiledQuery.
671
+ *
672
+ * Filter parameters are resolved before `build` runs, the resolved predicates
673
+ * are conjoined unless a structurally richer builder already placed them, and
674
+ * the same point reports the final SQL plus the applied names.
675
+ */
676
+ compileRead(method, options, build, settings = {}) {
677
+ options?.signal?.throwIfAborted();
678
+ const table = settings.table ?? this.tableName;
679
+ const schema = settings.schema ?? (table === this.tableName ? this.schema : undefined);
680
+ const resolved = settings.resolvedFilters ??
681
+ this.resolveReadFilters(method, options, table, schema, settings.qualifyColumns === true, settings.additionalKnownNames, settings.columnPrefix);
682
+ const built = build(resolved);
683
+ const filtered = settings.filtersApplied === true ? built : applyResolvedFilters(built, resolved);
684
+ const query = filtered.compile();
685
+ const names = Object.freeze([...new Set([...resolved.names, ...(settings.additionalFilterNames ?? [])])]);
686
+ this.#queryFilters.set(query, names);
687
+ this.#onQuery?.(query, settings.buffered === true ? { filters: names, buffered: true } : { filters: names });
688
+ return query;
689
+ }
690
+ resolveWriteFilters(method, options, excludedFilterNames = []) {
691
+ const allDefinitions = this.filterDefinitionsFor(this.tableName, this.schema);
692
+ const excluded = new Set(excludedFilterNames);
693
+ const definitions = allDefinitions.filter(filter => filter.appliesToWrites !== false && !excluded.has(filter.name));
694
+ return resolveFilters(definitions, options?.filters, {
695
+ method,
696
+ table: this.tableName,
697
+ schema: this.schema,
698
+ knownNames: this.knownFilterNames(allDefinitions),
699
+ });
700
+ }
701
+ /**
702
+ * The only place a repository UPDATE or DELETE becomes a CompiledQuery.
703
+ *
704
+ * Write filters default on, are resolved before compilation, and are reported
705
+ * through the same query-observation hook as reads.
706
+ */
707
+ compileWrite(method, options, build, settings = {}) {
708
+ const resolved = this.resolveWriteFilters(method, options, settings.excludedFilterNames);
709
+ const query = applyResolvedFilters(build(resolved), resolved).compile();
710
+ this.#queryFilters.set(query, resolved.names);
711
+ this.#onQuery?.(query, { filters: resolved.names });
712
+ return query;
713
+ }
714
+ resolvePopulateFilters(names, options) {
715
+ const byRelation = new Map();
716
+ const byTable = new Map();
717
+ const plan = this.populatePlan(names);
718
+ const knownNames = this.populateFilterNames(names, plan);
719
+ for (const { path, relation, target } of plan.nodes) {
720
+ const targetTable = target?.schema.table ?? relation.targetTable;
721
+ let resolved = byTable.get(targetTable);
722
+ if (resolved === undefined) {
723
+ resolved = this.resolveReadFilters('populate', options, targetTable, target?.schema, true, knownNames);
724
+ byTable.set(targetTable, resolved);
725
+ }
726
+ byRelation.set(path, resolved);
727
+ }
728
+ return byRelation;
729
+ }
730
+ requiredKeyColumns() {
731
+ if (this.keyColumns.length === 0)
732
+ throw new Error(`schema ${this.tableName} has no primary key`);
733
+ return this.keyColumns;
734
+ }
735
+ /**
736
+ * The one row-shape trust boundary in this package (ARCHITECTURE §2.1).
737
+ *
738
+ * A driver or configured cache store hands back structurally opaque values;
739
+ * the compiled query and schema fingerprint decide their shape, so exactly one
740
+ * assertion re-types them for the caller. Every read method funnels through
741
+ * here instead of asserting at its own return statement.
742
+ */
743
+ async executeRead(query, signal) {
744
+ signal?.throwIfAborted();
745
+ const rows = await this.driver.execute(query, executeOptions(signal));
746
+ signal?.throwIfAborted();
747
+ return rows;
748
+ }
749
+ async rows(query, options) {
750
+ let value;
751
+ if (options?.cache === undefined || options.cache === false) {
752
+ value = this.decodeRows(await this.executeRead(query, options?.signal));
753
+ }
754
+ else {
755
+ value = await this.cachedRows(query, options.cache, options.signal);
756
+ }
757
+ // boundary: driver rows are proved by the compiled query; cache entries are stored
758
+ // only under the same query plus dialect and schema fingerprint. Both establish
759
+ // `Row` at this one boundary, without re-validating cache hits (§3d).
760
+ return this.trusted(value);
761
+ }
762
+ /**
763
+ * The repository's one row-shape assertion, shared by buffered and streamed
764
+ * reads. The compiler that produced the query establishes the public shape;
765
+ * drivers deliberately return opaque records.
766
+ */
767
+ trusted(value) {
768
+ // boundary: a compiled repository query establishes the row shape; Driver
769
+ // deliberately returns opaque records so third-party adapters stay structural.
770
+ return value;
771
+ }
772
+ async cachedRows(query, cache, signal) {
773
+ if (!Number.isFinite(cache.ttlMs) || cache.ttlMs <= 0) {
774
+ throw new RangeError('cache ttlMs must be a positive finite number');
775
+ }
776
+ signal?.throwIfAborted();
777
+ const store = (this.#cacheStore ??= memoryStore());
778
+ const filters = this.#queryFilters.get(query);
779
+ const key = resultCacheKey({
780
+ dialect: this.dialect,
781
+ schema: this.schema.ir,
782
+ table: this.tableName,
783
+ ...(filters === undefined ? {} : { filters }),
784
+ query,
785
+ });
786
+ let cached;
787
+ try {
788
+ cached = await store.get(key);
789
+ }
790
+ catch (error) {
791
+ this.reportCacheFailure(error);
792
+ return this.decodeRows(await this.executeRead(query, signal));
793
+ }
794
+ signal?.throwIfAborted();
795
+ if (cached !== undefined)
796
+ return copyCachedRows(cached);
797
+ const rows = this.decodeRows(await this.executeRead(query, signal));
798
+ try {
799
+ await store.set(key, copyCachedRows(rows), cache.ttlMs, cacheTags(this.tableName, cache.tags));
800
+ }
801
+ catch (error) {
802
+ this.reportCacheFailure(error);
803
+ }
804
+ signal?.throwIfAborted();
805
+ return rows;
806
+ }
807
+ async invalidateCache(options) {
808
+ const store = this.#cacheStore;
809
+ if (store === undefined)
810
+ return;
811
+ try {
812
+ await store.invalidateTags(cacheTags(this.tableName, options?.invalidateTags));
813
+ }
814
+ catch (error) {
815
+ this.reportCacheFailure(error);
816
+ }
817
+ }
818
+ reportCacheFailure(error) {
819
+ if (this.#cacheFailureReported)
820
+ return;
821
+ this.#cacheFailureReported = true;
822
+ console.warn('@zmdb/orm cache store failed; continuing on the database path', error);
823
+ }
824
+ /**
825
+ * The db→app crossing on the way out (plan D3).
826
+ *
827
+ * `Entity<T>` says a `timestamp` column is a `Date`, a `bigint` column is a `bigint`, and
828
+ * an extension vector is a number array. A driver may or may not agree: `pg` returns a
829
+ * `Date` for a `timestamptz`, a string for an `int8`, and pgvector's text form when its
830
+ * parser is absent; SQLite returns the `TEXT` it stored. So a raw row can disagree with
831
+ * the declared app type silently unless this boundary converts it.
832
+ *
833
+ * A schema without a `timestamp`, `bigint`, or extension vector skips the walk entirely
834
+ * rather than copying every row to no effect. Only `timestamp` and `bigint` need distinct
835
+ * JSON wire forms; vector decoding is specific to the db→app crossing.
836
+ */
837
+ decodeRows(rows) {
838
+ const columns = this.decodedColumns;
839
+ if (columns.length === 0 || rows.length === 0)
840
+ return rows;
841
+ return rows.map(row => this.decodeRow(row, columns));
842
+ }
843
+ decodeRow(row, columns) {
844
+ if (columns.length === 0)
845
+ return row;
846
+ const out = { ...row };
847
+ for (const column of columns) {
848
+ if (column.name in out)
849
+ out[column.name] = decodeDbValue(column, out[column.name]);
850
+ }
851
+ return out;
852
+ }
853
+ get decodedColumns() {
854
+ this.#decoded ??= dbDecodedColumns(this.#rootSqlNames.ir);
855
+ return this.#decoded;
856
+ }
857
+ keyValues(id, method) {
858
+ const keyColumns = this.requiredKeyColumns();
859
+ if (keyColumns.length === 1) {
860
+ const [keyColumn] = keyColumns;
861
+ if (!keyColumn) {
862
+ throw new Error(`schema ${this.tableName} has empty primary key column`);
863
+ }
864
+ if (!isScalarKey(id)) {
865
+ throw new ValidationError(`${this.tableName}.${method} requires the value of "${keyColumn}", not ${describeKey(id)}`);
866
+ }
867
+ return [id];
868
+ }
869
+ if (!isRecord(id) || id instanceof Date) {
870
+ throw new ValidationError(`${this.tableName}.${method} requires every key column; got ${describeKey(id)}, expected an object with (${keyColumns.join(', ')})`);
871
+ }
872
+ const missing = [];
873
+ for (const column of keyColumns) {
874
+ if (!Object.hasOwn(id, column) || id[column] === undefined)
875
+ missing.push(column);
876
+ }
877
+ if (missing.length > 0)
878
+ throw new IncompleteKeyError(this.tableName, method, missing);
879
+ return keyColumns.map(column => id[column]);
880
+ }
881
+ keyWhere(builder, id, method) {
882
+ const values = this.keyValues(id, method);
883
+ let keyed = builder;
884
+ for (let index = 0; index < this.physicalKeyColumns.length; index++) {
885
+ const column = this.physicalKeyColumns[index];
886
+ if (column !== undefined)
887
+ keyed = keyed.where(column, '=', values[index]);
888
+ }
889
+ return keyed;
890
+ }
891
+ /** Add the deterministic order required by dialects whose pagination grammar needs one. */
892
+ limitOne(builder) {
893
+ if (!this.dialectTraits.paginationRequiresOrder)
894
+ return builder.limit(1);
895
+ const fallback = this.schema.ir.columns[0]?.physicalName;
896
+ const order = this.physicalKeyColumns.length > 0 ? this.physicalKeyColumns : fallback === undefined ? [] : [fallback];
897
+ if (order.length === 0) {
898
+ throw new Error(`schema ${this.tableName} has no column available to order a first-row read`);
899
+ }
900
+ let ordered = builder;
901
+ for (const column of order)
902
+ ordered = ordered.orderBy(column, 'asc');
903
+ return ordered.limit(1);
904
+ }
905
+ /** Validate a typed key and return its values in schema declaration order. */
906
+ loaderKeyValues(id) {
907
+ return this.keyValues(id, 'findById');
908
+ }
909
+ [LOADER_ENTITY_KEY](id) {
910
+ return loaderKey(this.loaderKeyValues(id));
911
+ }
912
+ /**
913
+ * Find a primary-key batch through the repository's compiler, decoder and
914
+ * dialect parameter ceiling. Composite tuples use OR-of-AND groups; SQL's
915
+ * AND-before-OR precedence keeps every tuple boundary intact.
916
+ */
917
+ async [LOADER_ENTITY_BATCH](ids) {
918
+ const unique = sanitizeKeys(ids);
919
+ if (unique.length === 0)
920
+ return [];
921
+ const columns = this.requiredKeyColumns();
922
+ const parameterLimit = this.dialectTraits.paramLimit;
923
+ const chunkSize = Math.max(1, Math.floor(parameterLimit / columns.length));
924
+ const found = new Map();
925
+ const filters = this.resolveReadFilters('loader.load', undefined, this.tableName, this.schema, false);
926
+ for (const chunk of chunkArray(unique, chunkSize)) {
927
+ let builder = this.selectEntity();
928
+ if (columns.length === 1) {
929
+ const [column] = columns;
930
+ if (!column)
931
+ throw new Error(`schema ${this.tableName} has empty primary key column`);
932
+ builder = builder.whereIn(this.physicalColumn(column), chunk);
933
+ }
934
+ else {
935
+ for (let tupleIndex = 0; tupleIndex < chunk.length; tupleIndex++) {
936
+ const id = chunk[tupleIndex];
937
+ if (id === undefined)
938
+ continue;
939
+ const values = this.loaderKeyValues(id);
940
+ for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
941
+ const column = columns[columnIndex];
942
+ if (!column)
943
+ continue;
944
+ const value = values[columnIndex];
945
+ const physicalColumn = this.physicalColumn(column);
946
+ if (tupleIndex === 0 && columnIndex === 0)
947
+ builder = builder.where(physicalColumn, '=', value);
948
+ else if (columnIndex === 0)
949
+ builder = builder.orWhere(physicalColumn, '=', value);
950
+ else
951
+ builder = builder.andWhere(physicalColumn, '=', value);
952
+ }
953
+ }
954
+ }
955
+ const query = this.compileRead('loader.load', undefined, () => builder, { resolvedFilters: filters });
956
+ const rows = await this.rows(query);
957
+ for (const row of rows) {
958
+ found.set(loaderKey(columns.map(column => row[column])), row);
959
+ }
960
+ }
961
+ return ids.map(id => found.get(this[LOADER_ENTITY_KEY](id)));
962
+ }
963
+ async findById(id, opts) {
964
+ const populateFilters = this.resolvePopulateFilters(opts?.populate, opts);
965
+ const query = this.compileRead('findById', opts, () => this.limitOne(this.keyWhere(this.selectEntity(), id, 'findById')), { additionalKnownNames: this.populateFilterNames(opts?.populate) });
966
+ return this.firstResult(query, opts, populateFilters);
967
+ }
968
+ /** The shared body of `findById` and `findOne`: first row for a where clause, relations attached if asked for. */
969
+ async firstMatching(where, options) {
970
+ const populateFilters = this.resolvePopulateFilters(options?.populate, options);
971
+ const query = this.compileRead('findOne', options, () => this.limitOne(compileWhere(this.selectEntity(), where, column => this.physicalColumn(column))), { additionalKnownNames: this.populateFilterNames(options?.populate) });
972
+ return this.firstResult(query, options, populateFilters);
973
+ }
974
+ async firstResult(query, options, populateFilters = new Map()) {
975
+ const rows = await this.rows(query, options);
976
+ const row = rows[0];
977
+ if (!row || !options?.populate?.length)
978
+ return row;
979
+ const [populated] = await this.attachRelations([row], options.populate, options, populateFilters);
980
+ return populated;
981
+ }
982
+ /** The children of every parent in parameter-bounded queries, grouped by the ordered target key. */
983
+ async childrenByParent(childTable, childKeys, parentKeys, options, filters, names = this.sqlNamesForTable(childTable)) {
984
+ options?.signal?.throwIfAborted();
985
+ if (childKeys.length === 0) {
986
+ throw new Error(`relation to ${childTable} resolved no target key columns`);
987
+ }
988
+ const unique = new Map();
989
+ for (const values of parentKeys) {
990
+ if (values.length !== childKeys.length) {
991
+ throw new Error(`relation to ${childTable} resolved ${String(childKeys.length)} target columns for ` +
992
+ `${String(values.length)} parent values`);
993
+ }
994
+ if (!hasNullishKeyPart(values))
995
+ unique.set(loaderKey(values), values);
996
+ }
997
+ const ids = [...unique.values()];
998
+ if (ids.length === 0)
999
+ return new Map();
1000
+ const limit = this.dialectTraits.paramLimit;
1001
+ const chunks = chunkArray(ids, Math.max(1, Math.floor(limit / childKeys.length)));
1002
+ const byParent = new Map();
1003
+ const physicalTable = names?.schema.table ?? childTable;
1004
+ const physicalKeys = names === undefined ? childKeys : childKeys.map(childKey => this.physicalColumn(childKey, names));
1005
+ for (const chunk of chunks) {
1006
+ let builder = names === undefined ? this.qb.selectFrom(physicalTable) : this.selectEntity(names);
1007
+ if (physicalKeys.length === 1) {
1008
+ const [physicalKey] = physicalKeys;
1009
+ if (physicalKey === undefined)
1010
+ throw new Error(`relation to ${childTable} resolved an empty target key`);
1011
+ builder = builder.whereIn(physicalKey, chunk.map(values => values[0]));
1012
+ }
1013
+ else {
1014
+ const predicates = [];
1015
+ for (let tupleIndex = 0; tupleIndex < chunk.length; tupleIndex++) {
1016
+ const values = chunk[tupleIndex];
1017
+ if (values === undefined)
1018
+ continue;
1019
+ for (let columnIndex = 0; columnIndex < physicalKeys.length; columnIndex++) {
1020
+ const physicalKey = physicalKeys[columnIndex];
1021
+ if (physicalKey === undefined)
1022
+ continue;
1023
+ predicates.push({
1024
+ col: physicalKey,
1025
+ op: '=',
1026
+ value: values[columnIndex],
1027
+ connector: tupleIndex > 0 && columnIndex === 0 ? 'OR' : 'AND',
1028
+ });
1029
+ }
1030
+ }
1031
+ builder = builder.whereGroup(predicates);
1032
+ }
1033
+ const query = this.compileRead('populate', options, () => builder, {
1034
+ table: physicalTable,
1035
+ ...(names === undefined ? {} : { schema: names.schema }),
1036
+ qualifyColumns: true,
1037
+ ...(filters === undefined ? {} : { resolvedFilters: filters }),
1038
+ });
1039
+ const res = await this.executeRead(query, options?.signal);
1040
+ for (const child of res) {
1041
+ const key = relationRowKey(child, childKeys);
1042
+ if (key === undefined)
1043
+ continue;
1044
+ const list = byParent.get(key);
1045
+ if (list === undefined)
1046
+ byParent.set(key, [child]);
1047
+ else
1048
+ list.push(child);
1049
+ }
1050
+ }
1051
+ return byParent;
1052
+ }
1053
+ async attachRelations(parents, names, options, populateFilters = this.resolvePopulateFilters(names, options)) {
1054
+ return this.populateRows(parents, this.populatePlan(names).roots, options, populateFilters);
1055
+ }
1056
+ async populateRows(parents, nodes, options, populateFilters) {
1057
+ options?.signal?.throwIfAborted();
1058
+ if (parents.length === 0)
1059
+ return [];
1060
+ const current = parents.map(parent => ({ ...parent }));
1061
+ for (const node of nodes) {
1062
+ const { relation: rel, target } = node;
1063
+ const name = rel.name;
1064
+ const byParent = await this.childrenByParent(target?.schema.table ?? rel.targetTable, rel.targetKey, current.map(parent => relationKeyValues(parent, rel.parentKey)), options, populateFilters.get(node.path), target);
1065
+ if (node.children.length > 0) {
1066
+ const children = [...byParent.values()].flat();
1067
+ const populated = await this.populateRows(children, node.children, options, populateFilters);
1068
+ let offset = 0;
1069
+ for (const [key, group] of byParent) {
1070
+ byParent.set(key, populated.slice(offset, offset + group.length));
1071
+ offset += group.length;
1072
+ }
1073
+ }
1074
+ const property = { configurable: true, enumerable: true, writable: true };
1075
+ current.forEach(parent => {
1076
+ const key = relationRowKey(parent, rel.parentKey);
1077
+ if (key === undefined) {
1078
+ property.value = rel.toMany ? [] : null;
1079
+ }
1080
+ else {
1081
+ const list = byParent.get(key) ?? [];
1082
+ if (rel.toMany) {
1083
+ property.value = list.map(child => copyPopulatedRow(child, node.children));
1084
+ }
1085
+ else {
1086
+ const first = list[0];
1087
+ property.value = first ? copyPopulatedRow(first, node.children) : null;
1088
+ }
1089
+ }
1090
+ Object.defineProperty(parent, name, property);
1091
+ });
1092
+ }
1093
+ return current;
1094
+ }
1095
+ async populate(rowOrRows, paths, options) {
1096
+ options?.signal?.throwIfAborted();
1097
+ const rows = Array.isArray(rowOrRows) ? rowOrRows : [rowOrRows];
1098
+ const populated = await this.attachRelations(rows, paths, options);
1099
+ return Array.isArray(rowOrRows) ? populated : (populated[0] ?? { ...rowOrRows });
1100
+ }
1101
+ [LOADER_RELATION_KEY](parent, relation) {
1102
+ const resolved = this.relation(relation);
1103
+ return loaderKey(relationKeyValues(parent, resolved.parentKey));
1104
+ }
1105
+ async [LOADER_RELATION_BATCH](parents, relation) {
1106
+ const filters = this.resolvePopulateFilters([relation], undefined);
1107
+ const populated = await this.attachRelations(parents, [relation], undefined, filters);
1108
+ return populated.map(parent => parent[relation]);
1109
+ }
1110
+ async findOne(where, opts) {
1111
+ return this.firstMatching(where, opts);
1112
+ }
1113
+ async find(where, opts) {
1114
+ const populateFilters = this.resolvePopulateFilters(opts?.populate, opts);
1115
+ const query = this.compileRead('find', opts, () => compileWhere(this.selectEntity(), where, column => this.physicalColumn(column)), {
1116
+ additionalKnownNames: this.populateFilterNames(opts?.populate),
1117
+ });
1118
+ const rows = await this.rows(query, opts);
1119
+ if (!opts?.populate?.length)
1120
+ return rows;
1121
+ return this.attachRelations(rows, opts.populate, opts, populateFilters);
1122
+ }
1123
+ async findAll(opts) {
1124
+ const populateFilters = this.resolvePopulateFilters(opts?.populate, opts);
1125
+ const query = this.compileRead('findAll', opts, () => this.selectEntity(), {
1126
+ additionalKnownNames: this.populateFilterNames(opts?.populate),
1127
+ });
1128
+ const rows = await this.rows(query, opts);
1129
+ if (!opts?.populate?.length)
1130
+ return rows;
1131
+ return this.attachRelations(rows, opts.populate, opts, populateFilters);
1132
+ }
1133
+ async count(where, options) {
1134
+ const query = this.compileRead('count', options, () => {
1135
+ let builder = aggregateSelectFrom(this.tableName, this.dialect, this.driver.queryTelemetry === true ? { telemetry: true } : undefined).count('*', 'count');
1136
+ if (where !== undefined)
1137
+ builder = compileWhere(builder, where, column => this.physicalColumn(column));
1138
+ return builder;
1139
+ });
1140
+ const rows = await this.executeRead(query, options?.signal);
1141
+ const value = rows[0]?.['count'];
1142
+ if (value === undefined)
1143
+ return 0;
1144
+ if (typeof value === 'number')
1145
+ return value;
1146
+ if (typeof value === 'bigint' || typeof value === 'string') {
1147
+ const count = Number(value);
1148
+ if (Number.isSafeInteger(count))
1149
+ return count;
1150
+ }
1151
+ throw new ValidationError(`count for \`${this.tableName}\` was not a safe integer`);
1152
+ }
1153
+ async exists(where, options) {
1154
+ const firstColumn = this.physicalKeyColumns[0] ?? this.schema.ir.columns[0]?.physicalName;
1155
+ if (firstColumn === undefined)
1156
+ throw new Error(`schema ${this.tableName} has no column to test for existence`);
1157
+ const query = this.compileRead('exists', options, () => {
1158
+ let builder = this.qb.selectFrom(this.tableName).select([firstColumn]);
1159
+ if (where !== undefined)
1160
+ builder = compileWhere(builder, where, column => this.physicalColumn(column));
1161
+ return this.limitOne(builder);
1162
+ });
1163
+ return (await this.executeRead(query, options?.signal)).length > 0;
1164
+ }
1165
+ async list(query, opts) {
1166
+ const populateFilters = this.resolvePopulateFilters(opts?.populate, opts);
1167
+ const keyColumns = this.requiredKeyColumns();
1168
+ const userOrderBy = query?.orderBy;
1169
+ const effectiveOrderBy = userOrderBy ? [...userOrderBy] : [];
1170
+ for (const column of keyColumns) {
1171
+ if (!effectiveOrderBy.some(item => String(item.column) === column)) {
1172
+ effectiveOrderBy.push({ column, dir: 'asc' });
1173
+ }
1174
+ }
1175
+ const page = query?.page;
1176
+ const limit = page && 'limit' in page ? page.limit : undefined;
1177
+ const keyset = page && 'after' in page && page.after !== undefined && page.after !== null;
1178
+ let cursorValues;
1179
+ if (keyset) {
1180
+ if (typeof page.after === 'string') {
1181
+ cursorValues = decodeCursor(page.after);
1182
+ }
1183
+ else if (typeof page.after === 'object' && !Array.isArray(page.after)) {
1184
+ // boundary: page.after is an untrusted client DTO parameter; runtime check above proves it is a non-null, non-array object.
1185
+ cursorValues = page.after;
1186
+ }
1187
+ else {
1188
+ throw new Error('Invalid cursor parameter: expected string or object');
1189
+ }
1190
+ }
1191
+ const compiled = this.compileRead('list', opts, filters => {
1192
+ const selectedProperties = query?.select === undefined
1193
+ ? undefined
1194
+ : [...query.select.map(String), ...effectiveOrderBy.map(item => String(item.column))];
1195
+ let builder = applyOrderBy(selectedProperties === undefined ? this.selectEntity() : this.selectProperties(selectedProperties), effectiveOrderBy, undefined, column => this.physicalColumn(column));
1196
+ if (keyset && cursorValues !== undefined) {
1197
+ builder = applyKeysetFilter(builder, cursorValues, effectiveOrderBy, query?.where, branch => {
1198
+ applyResolvedFilters(branch, filters);
1199
+ }, column => this.physicalColumn(column));
1200
+ if (limit !== undefined)
1201
+ builder = builder.limit(limit + 1);
1202
+ return builder;
1203
+ }
1204
+ if (query?.where)
1205
+ builder = compileWhere(builder, query.where, column => this.physicalColumn(column));
1206
+ if (page) {
1207
+ builder = applyPagination(builder, {
1208
+ limit: limit !== undefined ? limit + 1 : page.limit,
1209
+ offset: 'offset' in page ? page.offset : undefined,
1210
+ });
1211
+ }
1212
+ return builder;
1213
+ }, {
1214
+ filtersApplied: keyset === true,
1215
+ additionalKnownNames: this.populateFilterNames(opts?.populate),
1216
+ });
1217
+ const rows = await this.rows(compiled, opts);
1218
+ const listOpts = {
1219
+ ...(limit !== undefined ? { limit } : {}),
1220
+ ...(query?.select ? { select: query.select } : {}),
1221
+ orderBy: effectiveOrderBy,
1222
+ };
1223
+ const res = buildListResult(rows, listOpts);
1224
+ if (opts?.populate?.length) {
1225
+ const populatedItems = await this.attachRelations(res.items, opts.populate, opts, populateFilters);
1226
+ return { ...res, items: populatedItems };
1227
+ }
1228
+ return res;
1229
+ }
1230
+ stream(where, options) {
1231
+ options?.signal?.throwIfAborted();
1232
+ if (options?.batchSize !== undefined && (!Number.isInteger(options.batchSize) || options.batchSize <= 0)) {
1233
+ throw new RangeError('batchSize must be a positive integer');
1234
+ }
1235
+ const driverStream = typeof this.driver.stream === 'function' ? this.driver.stream : undefined;
1236
+ const reportBuffered = driverStream === undefined &&
1237
+ options?.requireCursor !== true &&
1238
+ this.#onQuery !== undefined &&
1239
+ !BUFFERED_STREAM_REPORTED.has(this.driver);
1240
+ if (reportBuffered)
1241
+ BUFFERED_STREAM_REPORTED.add(this.driver);
1242
+ const query = this.compileRead('stream', options, () => where === undefined
1243
+ ? this.selectEntity()
1244
+ : compileWhere(this.selectEntity(), where, column => this.physicalColumn(column)), reportBuffered ? { buffered: true } : {});
1245
+ // Resolve the schema-derived decoder once before iteration; the mapper then
1246
+ // performs only the per-row conversions that ordinary reads use.
1247
+ const columns = this.decodedColumns;
1248
+ const signal = options?.signal;
1249
+ const open = driverStream === undefined
1250
+ ? () => {
1251
+ if (options?.requireCursor === true) {
1252
+ const ctor = Reflect.get(this.driver, 'constructor');
1253
+ const name = typeof ctor === 'function' && ctor.name.length > 0 ? ctor.name : 'Driver';
1254
+ throw new Error(`${name} does not implement stream; pass requireCursor: false to allow buffering`);
1255
+ }
1256
+ const execute = this.executeRead.bind(this);
1257
+ return {
1258
+ async *[Symbol.asyncIterator]() {
1259
+ for (const row of await execute(query, signal))
1260
+ yield row;
1261
+ },
1262
+ };
1263
+ }
1264
+ : () => driverStream.call(this.driver, query, executeOptions(signal, options?.batchSize ?? 100));
1265
+ return createRepositoryStream(open, row => this.trusted(this.decodeRow(row, columns)), signal);
1266
+ }
1267
+ // #96 — full-text search integration. Uses the query-compiler FTS builder.
1268
+ // SQLite compiles FTS5 virtual table JOINs when ftsTable is declared on the
1269
+ // schema; querying plain SQLite columns without a declared virtual table
1270
+ // throws UnsupportedFeatureError (never a silently-wrong query).
1271
+ async findByFullText(column, term, options) {
1272
+ const ftsTable = this.schema.ftsTable;
1273
+ const query = this.compileRead('findByFullText', options, () => ftsSelectFrom(this.tableName, this.dialect, this.driver.queryTelemetry === true ? { ftsTable, telemetry: true } : { ftsTable }).whereMatch(this.physicalColumn(column), term));
1274
+ return this.executeRead(query, options?.signal);
1275
+ }
1276
+ async findJoined(join, where, options) {
1277
+ const targetTable = typeof join.target === 'string' ? join.target : join.target.table;
1278
+ const targetSpec = parseTableSpec(targetTable);
1279
+ const targetSchema = typeof join.target === 'string' ? undefined : join.target;
1280
+ const targetDefinitions = this.filterDefinitionsFor(targetSpec.table, targetSchema);
1281
+ const targetFilters = this.resolveReadFilters('findJoined', options, targetSpec.table, targetSchema, true, this.rootFilterNames(), targetSpec.reference);
1282
+ const query = this.compileRead('findJoined', options, () => {
1283
+ let builder = joinableSelectFrom(this.tableName, this.dialect, this.driver.queryTelemetry === true ? { telemetry: true } : undefined);
1284
+ const predicates = filtersAsPredicates(targetFilters);
1285
+ const leftCol = this.physicalColumn(join.leftCol);
1286
+ const rightCol = targetSchema === undefined ? join.rightCol : this.physicalColumn(join.rightCol, schemaSqlNames(targetSchema));
1287
+ builder =
1288
+ join.kind === 'inner'
1289
+ ? builder.innerJoin(targetTable, leftCol, rightCol, predicates)
1290
+ : builder.leftJoin(targetTable, leftCol, rightCol, predicates);
1291
+ if (where)
1292
+ builder = builder.where(this.aggregateColumn(where.col), where.op, where.value);
1293
+ return builder;
1294
+ }, {
1295
+ additionalFilterNames: targetFilters.names,
1296
+ additionalKnownNames: targetDefinitions.map(filter => filter.name),
1297
+ });
1298
+ return this.executeRead(query, options?.signal);
1299
+ }
1300
+ /**
1301
+ * A relation as a join: the target table, aliased to the relation name, and its ordered column pairs.
1302
+ *
1303
+ * One expression for both directions, where the map-driven version needed a branch per
1304
+ * cardinality — the two spellings put the joining column under a different key depending
1305
+ * on which side owned it (`fk` on the owning side, `mappedBy` on the inverse), and each
1306
+ * arm then had a fallback that guessed a column name from the table name. `parentKey` and
1307
+ * `targetKey` are already the answer to "which column on which side", so there is nothing
1308
+ * left to guess and no default to be wrong about.
1309
+ */
1310
+ resolveRelationJoin(relationName) {
1311
+ const rel = this.relation(relationName);
1312
+ const target = this.relationSqlNames(rel);
1313
+ const alias = relationName.trim();
1314
+ const physicalTargetTable = target?.schema.table ?? rel.targetTable;
1315
+ const targetTable = physicalTargetTable.toLowerCase() === alias.toLowerCase()
1316
+ ? physicalTargetTable
1317
+ : `${physicalTargetTable} as ${alias}`;
1318
+ return {
1319
+ targetTable,
1320
+ filterTable: physicalTargetTable,
1321
+ targetReference: alias,
1322
+ conditions: rel.parentKey.map((parentKey, index) => {
1323
+ const targetKey = rel.targetKey[index];
1324
+ if (targetKey === undefined) {
1325
+ throw new Error(`${this.tableName}.${relationName}: resolved relation keys have different lengths`);
1326
+ }
1327
+ return {
1328
+ leftCol: `${this.tableName}.${this.physicalColumn(parentKey)}`,
1329
+ rightCol: `${alias}.${target === undefined ? targetKey : this.physicalColumn(targetKey, target)}`,
1330
+ };
1331
+ }),
1332
+ };
1333
+ }
1334
+ filteredRelationJoin(relationName, options) {
1335
+ const join = this.resolveRelationJoin(relationName);
1336
+ const definitions = this.filterDefinitionsFor(join.filterTable);
1337
+ const targetSchema = this.sqlNamesForTable(join.filterTable)?.schema;
1338
+ const filters = this.resolveReadFilters('aggregate', options, join.filterTable, targetSchema, true, this.allDeclaredFilterNames(), join.targetReference);
1339
+ return { ...join, filters, knownNames: definitions.map(filter => filter.name) };
1340
+ }
1341
+ createRepositoryAggregateBuilder(options, targetFilterNames, targetKnownNames) {
1342
+ let builder = aggregateSelectFrom(this.tableName, this.dialect, this.driver.queryTelemetry === true ? { telemetry: true } : undefined);
1343
+ const resolveRelationJoin = (relationName) => this.filteredRelationJoin(relationName, options);
1344
+ const resolveTableJoin = (targetTable) => {
1345
+ const target = parseTableSpec(targetTable);
1346
+ const definitions = this.filterDefinitionsFor(target.table);
1347
+ const filters = this.resolveReadFilters('aggregate', options, target.table, undefined, true, this.allDeclaredFilterNames(), target.reference);
1348
+ return { filters, knownNames: definitions.map(filter => filter.name) };
1349
+ };
1350
+ const wrap = (b) => {
1351
+ builder = b;
1352
+ const target = Object.assign(builder, {
1353
+ joinRelation(relationName, kind = 'inner') {
1354
+ const { targetTable, conditions, filters, knownNames } = resolveRelationJoin(relationName);
1355
+ for (const name of filters.names)
1356
+ targetFilterNames.add(name);
1357
+ for (const name of knownNames)
1358
+ targetKnownNames.add(name);
1359
+ let nextB = builder;
1360
+ const predicates = filtersAsPredicates(filters);
1361
+ if (kind === 'left')
1362
+ nextB = builder.leftJoin(targetTable, conditions, predicates);
1363
+ else if (kind === 'right')
1364
+ nextB = builder.rightJoin(targetTable, conditions, predicates);
1365
+ else
1366
+ nextB = builder.innerJoin(targetTable, conditions, predicates);
1367
+ return wrap(nextB);
1368
+ },
1369
+ });
1370
+ return new Proxy(target, {
1371
+ get: (t, prop, receiver) => {
1372
+ if (prop === 'joinRelation') {
1373
+ return t.joinRelation;
1374
+ }
1375
+ if (prop === 'innerJoin' || prop === 'leftJoin' || prop === 'rightJoin') {
1376
+ return (targetTable, leftColOrConditions, rightColOrOn, scalarOn) => {
1377
+ const { filters, knownNames } = resolveTableJoin(targetTable);
1378
+ for (const name of filters.names)
1379
+ targetFilterNames.add(name);
1380
+ for (const name of knownNames)
1381
+ targetKnownNames.add(name);
1382
+ const on = typeof leftColOrConditions === 'string' ? scalarOn : rightColOrOn;
1383
+ if (on !== undefined && typeof on === 'string') {
1384
+ throw new TypeError(`join "${targetTable}" received an invalid ON predicate list`);
1385
+ }
1386
+ const predicates = [...(on ?? []), ...filtersAsPredicates(filters)];
1387
+ let joined;
1388
+ if (typeof leftColOrConditions === 'string') {
1389
+ if (typeof rightColOrOn !== 'string') {
1390
+ throw new TypeError(`join "${targetTable}" needs a right-hand column`);
1391
+ }
1392
+ if (prop === 'leftJoin') {
1393
+ joined = builder.leftJoin(targetTable, leftColOrConditions, rightColOrOn, predicates);
1394
+ }
1395
+ else if (prop === 'rightJoin') {
1396
+ joined = builder.rightJoin(targetTable, leftColOrConditions, rightColOrOn, predicates);
1397
+ }
1398
+ else {
1399
+ joined = builder.innerJoin(targetTable, leftColOrConditions, rightColOrOn, predicates);
1400
+ }
1401
+ }
1402
+ else if (prop === 'leftJoin') {
1403
+ joined = builder.leftJoin(targetTable, leftColOrConditions, predicates);
1404
+ }
1405
+ else if (prop === 'rightJoin') {
1406
+ joined = builder.rightJoin(targetTable, leftColOrConditions, predicates);
1407
+ }
1408
+ else {
1409
+ joined = builder.innerJoin(targetTable, leftColOrConditions, predicates);
1410
+ }
1411
+ return wrap(joined);
1412
+ };
1413
+ }
1414
+ const val = Reflect.get(t, prop, receiver);
1415
+ if (typeof val === 'function') {
1416
+ return (...args) => {
1417
+ let resolvedArgs = args;
1418
+ if (prop === 'select' && Array.isArray(args[0])) {
1419
+ resolvedArgs = [
1420
+ args[0].map(column => (typeof column === 'string' ? this.aggregateSelection(column) : column)),
1421
+ ];
1422
+ }
1423
+ else if ((prop === 'count' || prop === 'sum' || prop === 'avg' || prop === 'min' || prop === 'max') &&
1424
+ typeof args[0] === 'string' &&
1425
+ args[0] !== '*') {
1426
+ resolvedArgs = [this.aggregateColumn(args[0]), ...args.slice(1)];
1427
+ }
1428
+ else if (prop === 'groupBy') {
1429
+ resolvedArgs = args.map(arg => (typeof arg === 'string' ? this.aggregateColumn(arg) : arg));
1430
+ }
1431
+ else if ((prop === 'where' || prop === 'having' || prop === 'orderBy') && typeof args[0] === 'string') {
1432
+ resolvedArgs = [this.aggregateColumn(args[0]), ...args.slice(1)];
1433
+ }
1434
+ const res = val.apply(t, resolvedArgs);
1435
+ if (res &&
1436
+ typeof res === 'object' &&
1437
+ 'compile' in res &&
1438
+ typeof res.compile === 'function' &&
1439
+ 'select' in res &&
1440
+ typeof res.select === 'function') {
1441
+ return wrap(res);
1442
+ }
1443
+ return res;
1444
+ };
1445
+ }
1446
+ return val;
1447
+ },
1448
+ });
1449
+ };
1450
+ return wrap(builder);
1451
+ }
1452
+ // #92 & relation-aware aggregations. Runs a grouped aggregate (count/sum/…)
1453
+ // returning typed computed columns or relation-aware flat output fields.
1454
+ async aggregate(specOrBuild, options) {
1455
+ let q;
1456
+ const targetFilterNames = new Set();
1457
+ const targetKnownNames = new Set();
1458
+ if (typeof specOrBuild === 'function') {
1459
+ const builder = this.createRepositoryAggregateBuilder(options, targetFilterNames, targetKnownNames);
1460
+ const res = specOrBuild(builder);
1461
+ q = this.compileRead('aggregate', options, () => res ?? builder, {
1462
+ additionalFilterNames: [...targetFilterNames],
1463
+ additionalKnownNames: [...targetKnownNames],
1464
+ });
1465
+ }
1466
+ else if (typeof specOrBuild === 'object' && specOrBuild !== null) {
1467
+ const spec = specOrBuild;
1468
+ let builder = aggregateSelectFrom(this.tableName, this.dialect, this.driver.queryTelemetry === true ? { telemetry: true } : undefined);
1469
+ const joinedRelations = new Set();
1470
+ const applyJoin = (relName, kind = 'inner') => {
1471
+ if (joinedRelations.has(relName))
1472
+ return;
1473
+ joinedRelations.add(relName);
1474
+ const { targetTable, conditions, filters, knownNames } = this.filteredRelationJoin(relName, options);
1475
+ for (const name of filters.names)
1476
+ targetFilterNames.add(name);
1477
+ for (const name of knownNames)
1478
+ targetKnownNames.add(name);
1479
+ const predicates = filtersAsPredicates(filters);
1480
+ if (kind === 'left')
1481
+ builder = builder.leftJoin(targetTable, conditions, predicates);
1482
+ else if (kind === 'right')
1483
+ builder = builder.rightJoin(targetTable, conditions, predicates);
1484
+ else
1485
+ builder = builder.innerJoin(targetTable, conditions, predicates);
1486
+ };
1487
+ if (spec.joins) {
1488
+ for (const item of spec.joins) {
1489
+ if (typeof item === 'string') {
1490
+ applyJoin(item);
1491
+ }
1492
+ else if (item && typeof item === 'object') {
1493
+ applyJoin(item.relation, item.kind);
1494
+ }
1495
+ }
1496
+ }
1497
+ const relationNames = new Set(this.schema.ir.relations.map(rel => rel.name));
1498
+ const candidateCols = [];
1499
+ if (spec.groupBy)
1500
+ candidateCols.push(...spec.groupBy.map(String));
1501
+ if (spec.computed) {
1502
+ for (const comp of Object.values(spec.computed)) {
1503
+ if (comp.column)
1504
+ candidateCols.push(String(comp.column));
1505
+ }
1506
+ }
1507
+ if (spec.where) {
1508
+ candidateCols.push(...Object.keys(spec.where));
1509
+ }
1510
+ for (const col of candidateCols) {
1511
+ if (col.includes('.')) {
1512
+ const parts = col.split('.');
1513
+ const relCandidate = parts[0];
1514
+ if (relCandidate && relationNames.has(relCandidate)) {
1515
+ applyJoin(relCandidate);
1516
+ }
1517
+ }
1518
+ }
1519
+ if (spec.groupBy && spec.groupBy.length > 0) {
1520
+ const groupBy = spec.groupBy.map(column => this.aggregateColumn(String(column)));
1521
+ builder = builder
1522
+ .select(spec.groupBy.map(column => this.aggregateSelection(String(column))))
1523
+ .groupBy(...groupBy);
1524
+ }
1525
+ if (spec.computed) {
1526
+ for (const [alias, comp] of Object.entries(spec.computed)) {
1527
+ if (comp.raw) {
1528
+ builder = builder.expr(comp.raw, alias);
1529
+ }
1530
+ else {
1531
+ const fnLower = comp.fn.toLowerCase();
1532
+ const col = comp.column ? this.aggregateColumn(String(comp.column)) : '*';
1533
+ if (fnLower === 'count')
1534
+ builder = builder.count(col, alias);
1535
+ else if (fnLower === 'sum')
1536
+ builder = builder.sum(col, alias);
1537
+ else if (fnLower === 'avg')
1538
+ builder = builder.avg(col, alias);
1539
+ else if (fnLower === 'min')
1540
+ builder = builder.min(col, alias);
1541
+ else if (fnLower === 'max')
1542
+ builder = builder.max(col, alias);
1543
+ }
1544
+ }
1545
+ }
1546
+ if (spec.where) {
1547
+ for (const [col, val] of Object.entries(spec.where)) {
1548
+ const physicalColumn = this.aggregateColumn(col);
1549
+ if (val !== undefined && val !== null && typeof val === 'object' && !Array.isArray(val)) {
1550
+ for (const [op, opVal] of Object.entries(val)) {
1551
+ builder = builder.where(physicalColumn, op === 'eq' ? '=' : op, opVal);
1552
+ }
1553
+ }
1554
+ else {
1555
+ builder = builder.where(physicalColumn, '=', val);
1556
+ }
1557
+ }
1558
+ }
1559
+ if (spec.having) {
1560
+ builder = builder.having(this.aggregateColumn(String(spec.having.column)), spec.having.op, spec.having.value);
1561
+ }
1562
+ if (spec.orderBy) {
1563
+ for (const item of spec.orderBy) {
1564
+ builder = builder.orderBy(this.aggregateColumn(String(item.column)), item.dir ?? 'asc');
1565
+ }
1566
+ }
1567
+ if (spec.limit !== undefined)
1568
+ builder = builder.limit(spec.limit);
1569
+ if (spec.offset !== undefined)
1570
+ builder = builder.offset(spec.offset);
1571
+ q = this.compileRead('aggregate', options, () => builder, {
1572
+ additionalFilterNames: [...targetFilterNames],
1573
+ additionalKnownNames: [...targetKnownNames],
1574
+ });
1575
+ }
1576
+ else {
1577
+ throw new Error('aggregate requires a builder callback or AggregateSpec object');
1578
+ }
1579
+ const rawRows = await this.executeRead(q, options?.signal);
1580
+ const mappedRows = rawRows.map(row => {
1581
+ const out = { ...row };
1582
+ for (const [k, v] of Object.entries(row)) {
1583
+ if (k.includes('.')) {
1584
+ const flatKey = k.replace('.', '_');
1585
+ if (!(flatKey in out)) {
1586
+ out[flatKey] = v;
1587
+ }
1588
+ }
1589
+ else if (k.includes('_')) {
1590
+ const dotKey = k.replace('_', '.');
1591
+ if (!(dotKey in out)) {
1592
+ out[dotKey] = v;
1593
+ }
1594
+ }
1595
+ }
1596
+ return out;
1597
+ });
1598
+ // boundary: the same claim `rows<Row>` makes, and for the same reason — the aggregate
1599
+ // query that just ran is what decides the shape, and a driver row is opaque. It does not
1600
+ // go through `rows` because an aggregate row is not an entity row: `decodeRows` would
1601
+ // walk it looking for this table's `timestamp`, `bigint`, and extension vector columns,
1602
+ // and `COUNT(*)` is not one of them.
1603
+ return mappedRows;
1604
+ }
1605
+ async findAllWithMany(relationName, childTableOrOptions, childFk, parentKey = 'id', explicitOptions) {
1606
+ const childTable = typeof childTableOrOptions === 'string' ? childTableOrOptions : undefined;
1607
+ const options = typeof childTableOrOptions === 'string' ? explicitOptions : childTableOrOptions;
1608
+ const knownNames = childTable === undefined
1609
+ ? this.populateFilterNames([relationName])
1610
+ : [
1611
+ ...new Set([
1612
+ ...this.rootFilterNames(),
1613
+ ...this.filterDefinitionsFor(childTable).map(filter => filter.name),
1614
+ ]),
1615
+ ];
1616
+ const relationFilters = !childTable || !childFk
1617
+ ? this.resolvePopulateFilters([relationName], options)
1618
+ : new Map([
1619
+ [relationName, this.resolveReadFilters('populate', options, childTable, undefined, true, knownNames)],
1620
+ ]);
1621
+ const parentQuery = this.compileRead('findAllWithMany', options, () => this.selectEntity(), {
1622
+ additionalKnownNames: knownNames,
1623
+ });
1624
+ const fetched = await this.rows(parentQuery, options);
1625
+ if (fetched.length === 0)
1626
+ return fetched;
1627
+ // Without an explicit child table/FK the relation has to be looked up, which
1628
+ // is what attachRelations does; with one, the caller has already told us
1629
+ // everything the batched fetch needs.
1630
+ if (!childTable || !childFk)
1631
+ return this.attachRelations(fetched, [relationName], options, relationFilters);
1632
+ const byParent = await this.childrenByParent(childTable, [childFk], fetched.map(p => [p[parentKey]]), options, relationFilters.get(relationName));
1633
+ return fetched.map(p => ({
1634
+ ...p,
1635
+ [relationName]: byParent.get(loaderKey([p[parentKey]])) ?? [],
1636
+ }));
1637
+ }
1638
+ // #207 — typed writes. Create keeps the derived CreateDTO; update accepts the
1639
+ // expression-aware UpdatePatch and validates its plain values and operands
1640
+ // separately before SQL.
1641
+ async create(dto, options) {
1642
+ const clean = this.validatePayload(dto, 'create');
1643
+ this.preInsert(clean);
1644
+ const physical = this.physicalRecord(clean);
1645
+ const rows = await this.rows(this.qb.insertInto(this.tableName).values(physical).returning(this.entityReturning()).compile());
1646
+ await this.invalidateCache(options);
1647
+ const row = rows[0];
1648
+ if (!row)
1649
+ throw new Error(`insert into ${this.tableName} returned no row`);
1650
+ this.postInsert(row);
1651
+ return row;
1652
+ }
1653
+ async upsert(dto, opts) {
1654
+ const clean = this.validatePayload(dto, 'create');
1655
+ this.preInsert(clean);
1656
+ const target = opts?.target ?? this.requiredKeyColumns();
1657
+ const requestedUpdateFields = opts?.updateFields;
1658
+ const updateFields = requestedUpdateFields !== undefined && !isUpdateFieldList(requestedUpdateFields)
1659
+ ? this.validateUpdatePatch(requestedUpdateFields)
1660
+ : requestedUpdateFields;
1661
+ const resolvedUpdateFields = this.softDeleteUpsertFields(clean, target, updateFields);
1662
+ const physicalTarget = typeof target === 'string' ? this.physicalColumn(target) : target.map(column => this.physicalColumn(column));
1663
+ const physicalUpdateFields = this.physicalFields(resolvedUpdateFields);
1664
+ const ib = this.qb
1665
+ .insertInto(this.tableName)
1666
+ .values(this.physicalRecord(clean))
1667
+ .onConflict(physicalTarget)
1668
+ .doUpdate(physicalUpdateFields);
1669
+ if (!this.dialectCapabilities.returning.upsert &&
1670
+ physicalUpdateFields !== undefined &&
1671
+ !isUpdateFieldList(physicalUpdateFields) &&
1672
+ this.hasColumnExpression(physicalUpdateFields)) {
1673
+ await this.driver.execute(ib.compile());
1674
+ await this.invalidateCache(opts);
1675
+ return undefined;
1676
+ }
1677
+ const rows = await this.rows(ib.returning(this.entityReturning()).compile());
1678
+ await this.invalidateCache(opts);
1679
+ const row = rows[0];
1680
+ if (!row) {
1681
+ return undefined;
1682
+ }
1683
+ this.postInsert(row);
1684
+ return row;
1685
+ }
1686
+ async update(id, patch, options) {
1687
+ return this.updateOne(id, patch, options);
1688
+ }
1689
+ async updateMany(where, patch, options) {
1690
+ const clean = this.validateUpdatePatch(patch);
1691
+ this.preUpdate(clean);
1692
+ if (Object.keys(clean).length === 0) {
1693
+ this.resolveWriteFilters('updateMany', options);
1694
+ return 0;
1695
+ }
1696
+ const physical = this.physicalRecord(clean);
1697
+ const build = () => compileWhere(this.qb.updateTable(this.tableName).set(physical), where, column => this.physicalColumn(column));
1698
+ if (!this.dialectCapabilities.returning.update) {
1699
+ await this.driver.execute(this.compileWrite('updateMany', options, build));
1700
+ await this.invalidateCache(options);
1701
+ return undefined;
1702
+ }
1703
+ const returning = this.physicalKeyColumns.length > 0 ? this.physicalKeyColumns : ['*'];
1704
+ const updated = (await this.driver.execute(this.compileWrite('updateMany', options, () => build().returning(returning)))).length;
1705
+ await this.invalidateCache(options);
1706
+ return updated;
1707
+ }
1708
+ async increment(id, column, by, options) {
1709
+ const irColumn = this.payloadShape('update').columns.get(column);
1710
+ if (irColumn === undefined ||
1711
+ irColumn.payload !== undefined ||
1712
+ (irColumn.sql !== 'integer' && irColumn.sql !== 'bigint' && irColumn.sql !== 'numeric')) {
1713
+ throw new ValidationError(`"${column}" is not an updatable numeric column of "${this.tableName}"`, [
1714
+ {
1715
+ path: `input.${column}`,
1716
+ message: 'expected an updatable numeric column',
1717
+ expected: 'integer, bigint, or numeric column',
1718
+ value: column,
1719
+ },
1720
+ ]);
1721
+ }
1722
+ const operand = by ?? (irColumn.sql === 'bigint' ? 1n : 1);
1723
+ return this.updateOne(id, { [column]: inc(operand) }, options);
1724
+ }
1725
+ async updateOne(id, patch, options) {
1726
+ const clean = this.validateUpdatePatch(patch);
1727
+ this.preUpdate(clean);
1728
+ // Built before the empty-patch shortcut so that a bad key is reported as `update` rather
1729
+ // than as the `findById` it would otherwise delegate to (§2.1: "the method in the message
1730
+ // is the method the caller actually called").
1731
+ if (Object.keys(clean).length === 0) {
1732
+ const query = this.compileWrite('update', options, () => this.limitOne(this.keyWhere(this.selectEntity(), id, 'update')));
1733
+ return this.firstResult(query);
1734
+ }
1735
+ const physical = this.physicalRecord(clean);
1736
+ const build = () => this.keyWhere(this.qb.updateTable(this.tableName).set(physical), id, 'update');
1737
+ if (!this.dialectCapabilities.returning.update && this.hasColumnExpression(physical)) {
1738
+ await this.driver.execute(this.assertKeyed(this.compileWrite('update', options, build), 'update'));
1739
+ await this.invalidateCache(options);
1740
+ return undefined;
1741
+ }
1742
+ const rows = await this.rows(this.assertKeyed(this.compileWrite('update', options, () => build().returning(this.entityReturning())), 'update'));
1743
+ await this.invalidateCache(options);
1744
+ return rows[0];
1745
+ }
1746
+ /**
1747
+ * Refuse a keyed write that lost its `WHERE`.
1748
+ *
1749
+ * `update` and `delete` build the where clause themselves out of a primary key, so
1750
+ * neither has a legitimate unkeyed form and an empty clause is always a bug. The
1751
+ * check is here rather than only in the layers that can produce one because the cost
1752
+ * of being wrong is the whole table, and it survives a new operator, a new caller, or
1753
+ * a fourth way of folding a spec down to nothing (#608).
1754
+ */
1755
+ assertKeyed(query, operation) {
1756
+ if (!/\sWHERE\s/i.test(query.text)) {
1757
+ throw new ValidationError(`refusing to ${operation} every row of ${this.tableName}: the compiled statement has no WHERE clause`);
1758
+ }
1759
+ return query;
1760
+ }
1761
+ // #28 — delete + lifecycle hooks.
1762
+ async delete(id, options) {
1763
+ this.preDelete(id);
1764
+ const softDelete = this.schema.ir.softDelete;
1765
+ const build = softDelete === undefined
1766
+ ? () => this.keyWhere(this.qb.deleteFrom(this.tableName), id, 'delete')
1767
+ : () => this.keyWhere(this.qb.updateTable(this.tableName).set({ [this.physicalColumn(softDelete.column)]: new Date() }), id, 'delete');
1768
+ const statement = softDelete === undefined ? 'delete' : 'update';
1769
+ const query = !this.dialectCapabilities.returning[statement]
1770
+ ? this.compileWrite('delete', options, build)
1771
+ : this.compileWrite('delete', options, () => build().returning(this.physicalKeyColumns));
1772
+ const rows = await this.driver.execute(this.assertKeyed(query, 'delete'));
1773
+ await this.invalidateCache(options);
1774
+ return this.writeMatched(rows, statement);
1775
+ }
1776
+ async deleteMany(where, options) {
1777
+ const softDelete = this.schema.ir.softDelete;
1778
+ const build = softDelete === undefined
1779
+ ? () => compileWhere(this.qb.deleteFrom(this.tableName), where, column => this.physicalColumn(column))
1780
+ : () => compileWhere(this.qb.updateTable(this.tableName).set({ [this.physicalColumn(softDelete.column)]: new Date() }), where, column => this.physicalColumn(column));
1781
+ const statement = softDelete === undefined ? 'delete' : 'update';
1782
+ if (!this.dialectCapabilities.returning[statement]) {
1783
+ await this.driver.execute(this.compileWrite('deleteMany', options, build));
1784
+ await this.invalidateCache(options);
1785
+ return undefined;
1786
+ }
1787
+ const returning = this.physicalKeyColumns.length > 0 ? this.physicalKeyColumns : ['*'];
1788
+ const affected = (await this.driver.execute(this.compileWrite('deleteMany', options, () => build().returning(returning)))).length;
1789
+ await this.invalidateCache(options);
1790
+ return affected;
1791
+ }
1792
+ async hardDelete(id, options) {
1793
+ this.preDelete(id);
1794
+ const build = () => this.keyWhere(this.qb.deleteFrom(this.tableName), id, 'hardDelete');
1795
+ const query = !this.dialectCapabilities.returning.delete
1796
+ ? this.compileWrite('hardDelete', options, build)
1797
+ : this.compileWrite('hardDelete', options, () => build().returning(this.physicalKeyColumns));
1798
+ const rows = await this.driver.execute(this.assertKeyed(query, 'hardDelete'));
1799
+ await this.invalidateCache(options);
1800
+ return this.writeMatched(rows, 'delete');
1801
+ }
1802
+ async restore(id, options) {
1803
+ const softDelete = this.schema.ir.softDelete;
1804
+ if (softDelete === undefined) {
1805
+ throw new ValidationError(`${this.tableName}.restore requires a SoftDelete<'column'> declaration`);
1806
+ }
1807
+ const build = () => this.keyWhere(this.qb.updateTable(this.tableName).set({ [this.physicalColumn(softDelete.column)]: null }), id, 'restore');
1808
+ const query = !this.dialectCapabilities.returning.update
1809
+ ? this.compileWrite('restore', options, build, { excludedFilterNames: ['softDelete'] })
1810
+ : this.compileWrite('restore', options, () => build().returning(this.physicalKeyColumns), {
1811
+ excludedFilterNames: ['softDelete'],
1812
+ });
1813
+ const rows = await this.driver.execute(this.assertKeyed(query, 'restore'));
1814
+ await this.invalidateCache(options);
1815
+ return this.writeMatched(rows, 'update');
1816
+ }
1817
+ writeMatched(rows, statement) {
1818
+ if (!this.dialectCapabilities.returning[statement]) {
1819
+ const affectedRows = rows[0]?.['affectedRows'];
1820
+ if (typeof affectedRows === 'number')
1821
+ return affectedRows > 0;
1822
+ }
1823
+ return rows.length > 0;
1824
+ }
1825
+ softDeleteUpsertFields(clean, target, updateFields) {
1826
+ const softDelete = this.schema.ir.softDelete;
1827
+ if (softDelete === undefined)
1828
+ return updateFields;
1829
+ const restored = {};
1830
+ if (isUpdateFieldList(updateFields)) {
1831
+ for (const column of updateFields) {
1832
+ if (column !== softDelete.column)
1833
+ restored[column] = proposed();
1834
+ }
1835
+ }
1836
+ else if (updateFields !== undefined) {
1837
+ Object.assign(restored, updateFields);
1838
+ }
1839
+ else {
1840
+ const targetColumns = new Set(typeof target === 'string' ? [target] : target);
1841
+ const nonTarget = Object.keys(clean).filter(column => !targetColumns.has(column));
1842
+ const columns = nonTarget.length > 0 ? nonTarget : Object.keys(clean);
1843
+ for (const column of columns)
1844
+ restored[column] = proposed();
1845
+ }
1846
+ restored[softDelete.column] = null;
1847
+ return restored;
1848
+ }
1849
+ // Explicit, synchronous lifecycle hooks. No hidden change tracking — these
1850
+ // fire only around the corresponding operation, in documented order.
1851
+ preInsert(_row) { }
1852
+ postInsert(_row) { }
1853
+ preUpdate(_patch) { }
1854
+ preDelete(_id) { }
1855
+ postSelect(rows) {
1856
+ return rows;
1857
+ }
1858
+ sanitizePayload(payload) {
1859
+ if (!isRecord(payload)) {
1860
+ throw new ValidationError('payload must be an object', [
1861
+ { path: 'input', message: 'expected object', expected: 'object', value: payload },
1862
+ ]);
1863
+ }
1864
+ const clean = {};
1865
+ for (const key of Object.keys(payload)) {
1866
+ if (payload[key] !== undefined) {
1867
+ clean[key] = payload[key];
1868
+ }
1869
+ }
1870
+ return clean;
1871
+ }
1872
+ /**
1873
+ * One write variant's columns, and the object type they add up to.
1874
+ *
1875
+ * Built once per repository instance per variant. `this.schema` is a static and never
1876
+ * changes, so neither can the IR; without the cache every `create` would walk the
1877
+ * columns again to rebuild an identical object graph.
1878
+ */
1879
+ payloadShape(variant) {
1880
+ const cached = this.#shapes.get(variant);
1881
+ if (cached)
1882
+ return cached;
1883
+ const shape = shapeOfVariant(this.schema.ir, variant);
1884
+ const built = {
1885
+ shape,
1886
+ type: objectTypeFromShape(shape),
1887
+ accepted: new Set(shape.map(({ column }) => column.name)),
1888
+ columns: new Map(shape.map(({ column }) => [column.name, column])),
1889
+ };
1890
+ this.#shapes.set(variant, built);
1891
+ return built;
1892
+ }
1893
+ /**
1894
+ * One issue per key the payload has and this variant does not accept (REQ-RP-3).
1895
+ *
1896
+ * These used to be dropped in silence, which made two mistakes invisible. A misspelled
1897
+ * column — `{ emial: 'a@b.co' }` — reported only that `email` was missing, and a
1898
+ * supplied `id` on a serial key reported nothing at all: the insert went through with a
1899
+ * key the database was about to generate, so the payload the caller wrote and the row
1900
+ * that came back disagreed and nothing said so.
1901
+ *
1902
+ * Reported alongside the structural issues rather than only when there are none, which
1903
+ * is where this deliberately differs from `assertEquals`'s excess check. Its rule is
1904
+ * that "you also passed `extra`" is noise next to "`email` is not a string", and at a
1905
+ * table boundary it usually is not noise: the excess key is a typo *of* the column the
1906
+ * other issue is complaining about, and suppressing it hides the answer.
1907
+ */
1908
+ excessIssues(obj, variant) {
1909
+ const { accepted } = this.payloadShape(variant);
1910
+ const issues = [];
1911
+ for (const key of Object.keys(obj)) {
1912
+ if (accepted.has(key))
1913
+ continue;
1914
+ const column = this.schema.ir.columns.find(candidate => candidate.name === key);
1915
+ const message = !column
1916
+ ? `"${key}" is not a column of "${this.tableName}"`
1917
+ : column.serial
1918
+ ? `the database generates "${key}", so a payload cannot supply it`
1919
+ : `"${key}" identifies the row and cannot be patched`;
1920
+ issues.push({ path: `input.${key}`, message, expected: 'no excess properties', value: obj[key] });
1921
+ }
1922
+ return issues;
1923
+ }
1924
+ /**
1925
+ * Runtime validation of a write payload — the type of the payload, checked by the one
1926
+ * runtime walker.
1927
+ *
1928
+ * This used to be a walk of its own over `ColumnMeta`, and it was the fourth of the
1929
+ * four that `@zmdb/schema/ir` exists to collapse: it accepted `Date | string` for
1930
+ * a `timestamp` while the published document said ISO string and `Entity<T>` said
1931
+ * `Date`, and it had no notion of `Min`/`Max`/`Pattern` at all, so a repository write
1932
+ * skipped every bound the schema declared and the same value was rejected only later,
1933
+ * at the HTTP edge, by a different validator. What it needed was not a walk but the
1934
+ * *type* of a `CreateDTO`, which `objectTypeFromShape` produces from the same IR the
1935
+ * documents come from.
1936
+ *
1937
+ * The app layer, not the wire layer: a caller here holds a `Date`, having decoded the
1938
+ * request body already. A handler that has not is the boundary case `Wire<T>` is for.
1939
+ *
1940
+ * A key the variant does not accept is an issue, not something to drop — see
1941
+ * `excessIssues`.
1942
+ */
1943
+ validatePayload(payload, variant) {
1944
+ const obj = this.sanitizePayload(payload);
1945
+ const { shape, type } = this.payloadShape(variant);
1946
+ const issues = [...issuesFor(obj, type), ...this.excessIssues(obj, variant)];
1947
+ return this.validatedColumns(obj, shape, issues);
1948
+ }
1949
+ /**
1950
+ * Validate a patch without teaching the DTO validator that arbitrary objects are
1951
+ * column values. Plain values still cross the unchanged UpdateDTO object check;
1952
+ * branded expressions are omitted from that object and their one operand is
1953
+ * checked against the same column IR separately.
1954
+ */
1955
+ validateUpdatePatch(payload) {
1956
+ const obj = this.sanitizePayload(payload);
1957
+ const values = {};
1958
+ const expressionIssues = [];
1959
+ const { shape, type, columns } = this.payloadShape('update');
1960
+ for (const key of Object.keys(obj)) {
1961
+ const value = obj[key];
1962
+ if (!isColumnExpression(value)) {
1963
+ values[key] = value;
1964
+ continue;
1965
+ }
1966
+ const column = columns.get(key);
1967
+ if (column === undefined)
1968
+ continue;
1969
+ const operand = expressionOperand(value);
1970
+ if (operand !== NO_EXPRESSION_OPERAND) {
1971
+ expressionIssues.push(...issuesFor(operand, appTypeOf(column), `input.${key}`));
1972
+ }
1973
+ }
1974
+ const issues = [...issuesFor(values, type), ...expressionIssues, ...this.excessIssues(obj, 'update')];
1975
+ return this.validatedColumns(obj, shape, issues);
1976
+ }
1977
+ validatedColumns(obj, shape, issues) {
1978
+ if (issues.length > 0) {
1979
+ throw new ValidationError(`validation failed: ${issues.map(issue => issue.path).join(', ')}`, issues);
1980
+ }
1981
+ const out = {};
1982
+ for (const { column } of shape) {
1983
+ if (column.name in obj)
1984
+ out[column.name] = obj[column.name];
1985
+ }
1986
+ return out;
1987
+ }
1988
+ hasColumnExpression(patch) {
1989
+ return Object.values(patch).some(isColumnExpression);
1990
+ }
1991
+ }
1992
+ export function defineRepository(schema, driver, opts) {
1993
+ // Anonymous subclass binding the schema as a static, exactly like a hand-written
1994
+ // subclass — no proxies, no magic.
1995
+ class Repo extends BaseRepository {
1996
+ static schema = schema;
1997
+ }
1998
+ return new Repo(driver, opts?.dialect ?? driver.dialect, opts);
1999
+ }
2000
+ export { memoryStore } from './cache/index.js';
2001
+ export { createLoaderScope, } from './loaders/index.js';
2002
+ export * from './transactions/index.js';
2003
+ //# sourceMappingURL=index.js.map