turbine-orm 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/powql.js ADDED
@@ -0,0 +1,1226 @@
1
+ /**
2
+ * PowqlInterface — Turbine's PowQL query generator (the PowDB analogue of
3
+ * {@link QueryInterface}). It exposes the same public method surface as the SQL
4
+ * `QueryInterface` (`findMany`, `create`, `update`, …) but emits **PowQL** — a
5
+ * pipeline language, not SQL — executed through {@link PowdbPool}.
6
+ *
7
+ * It is a *parallel* implementation rather than a `Dialect` of the SQL builder:
8
+ * PowQL's grammar (`T filter <e> order <k> { .col }`) shares no surface with
9
+ * `SELECT … FROM … WHERE`, so the SQL `Dialect` seam cannot express it. Keeping
10
+ * it separate also means the four SQL engines are untouched.
11
+ *
12
+ * Behavioural deltas from the SQL path, all driven by PowDB's wire reality (see
13
+ * `docs/strategy/powdb-parity-matrix.md`, every row verified against a live
14
+ * server):
15
+ * - `create`/`createMany`/`update`/`delete` use PowDB 0.7.0's trailing
16
+ * `returning` keyword (`RETURNING *`, all columns) to surface affected rows
17
+ * in one round-trip. `upsert` is the lone exception — its statement does not
18
+ * accept `returning`, so it reselects the row by PK (a composite-PK upsert
19
+ * reselects-or-writes inside one flat transaction).
20
+ * - The PK is server-assigned when the column is `isGenerated` (PowDB's `auto`
21
+ * int — read back via `returning`); otherwise a defaulted **string** PK is
22
+ * generated client-side (UUID).
23
+ * - `with` (nested relations) uses **batched N+1 loaders** — D round-trips for
24
+ * depth D, not one query — including manyToMany (junction → targets).
25
+ * - **Relation filters** (`some`/`none`/`every`, all cardinalities incl. m2m)
26
+ * are resolved client-side to a literal `in (…)` list, never an IN-subquery:
27
+ * PowDB's executor caches a subquery's result by plan shape and would return
28
+ * a stale prior result for a later subquery of the same shape.
29
+ * - **Nested writes** (relation ops in `create`/`update` data) run through the
30
+ * shared nested-write engine as one flat top-level transaction (PowDB is
31
+ * single-writer, no savepoints).
32
+ * - pgvector / JSON / array filters and cursor streaming throw
33
+ * {@link UnsupportedFeatureError} (E017) — they have no PowDB equivalent.
34
+ *
35
+ * @module
36
+ */
37
+ import { randomUUID } from 'node:crypto';
38
+ import { NotFoundError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
+ import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
+ import { PowdbFloatParam, powqlColumnType, rowToEntity } from './powdb.js';
41
+ import { escapeLike } from './query/utils.js';
42
+ import { normalizeKeyColumns, } from './schema.js';
43
+ /**
44
+ * Max parent keys per relation-loader `in (…)` query. A `with` over a large
45
+ * parent set is split into batches of this size so a single query never exceeds
46
+ * PowDB's per-statement param / row limits; the batches' children are merged
47
+ * before grouping. Mirrors the chunking the parity matrix documents.
48
+ */
49
+ const MAX_RELATION_KEYS = 1000;
50
+ /** Operator keys recognised inside a `WhereOperator` object. */
51
+ const OPERATOR_KEYS = new Set([
52
+ 'equals',
53
+ 'gt',
54
+ 'gte',
55
+ 'lt',
56
+ 'lte',
57
+ 'not',
58
+ 'in',
59
+ 'notIn',
60
+ 'contains',
61
+ 'startsWith',
62
+ 'endsWith',
63
+ 'mode',
64
+ ]);
65
+ /** Filters that have no PowDB representation and must throw E017. */
66
+ function rejectUnsupportedFilter(value, field) {
67
+ if ('distance' in value || 'metric' in value) {
68
+ throw new UnsupportedFeatureError('pgvector distance filters', 'PowDB', `field "${field}"`);
69
+ }
70
+ if ('path' in value || 'hasKey' in value) {
71
+ throw new UnsupportedFeatureError('JSON path/key filters', 'PowDB', `field "${field}"`);
72
+ }
73
+ if ('hasEvery' in value || 'hasSome' in value || 'isEmpty' in value) {
74
+ throw new UnsupportedFeatureError('array filters', 'PowDB', `field "${field}"`);
75
+ }
76
+ if ('search' in value) {
77
+ throw new UnsupportedFeatureError('full-text search filters', 'PowDB', `field "${field}"`);
78
+ }
79
+ }
80
+ /**
81
+ * The PowQL query interface. Constructed by `turbinePowDB` via the
82
+ * `queryInterfaceFactory` seam and cast to `QueryInterface<object>` so
83
+ * `TurbineClient.table()` can return it transparently.
84
+ */
85
+ export class PowqlInterface {
86
+ pool;
87
+ table;
88
+ schema;
89
+ middlewares;
90
+ options;
91
+ meta;
92
+ defaultLimit;
93
+ warnOnUnlimited;
94
+ onQuery;
95
+ currentAction = 'raw';
96
+ warnedUnlimited = false;
97
+ constructor(pool, table, schema, middlewares = [], options = {}) {
98
+ this.pool = pool;
99
+ this.table = table;
100
+ this.schema = schema;
101
+ this.middlewares = middlewares;
102
+ this.options = options;
103
+ const meta = schema.tables[table];
104
+ if (!meta) {
105
+ throw new ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
106
+ }
107
+ this.meta = meta;
108
+ this.defaultLimit = options.defaultLimit;
109
+ this.warnOnUnlimited = options.warnOnUnlimited !== false;
110
+ this.onQuery = options._onQuery;
111
+ }
112
+ // -------------------------------------------------------------------------
113
+ // Column / value helpers
114
+ // -------------------------------------------------------------------------
115
+ /** Resolve a camelCase field name (or raw snake) to its column metadata. */
116
+ column(field) {
117
+ const snake = this.meta.columnMap[field] ?? field;
118
+ const col = this.meta.columns.find((c) => c.name === snake || c.field === field);
119
+ if (!col) {
120
+ throw new ValidationError(`[turbine] Unknown column "${field}" on table "${this.table}". Known: ${this.meta.columns
121
+ .map((c) => c.field)
122
+ .join(', ')}`);
123
+ }
124
+ return col;
125
+ }
126
+ /** PowQL column reference (`.snake_name`) for a field. */
127
+ ref(field) {
128
+ return `.${this.column(field).name}`;
129
+ }
130
+ /**
131
+ * Push a value into the param array and return its `$N` placeholder. When the
132
+ * value targets a `float` column it is wrapped in {@link PowdbFloatParam} so
133
+ * the *embedded* literal encoder emits a float-form literal even for an
134
+ * integer value (PowQL's `42` is an int literal, `42.0` a float). The
135
+ * networked driver unwraps the marker back to the plain number in
136
+ * {@link toPowdbParam}, so the wire param is unchanged.
137
+ */
138
+ param(value, params, col) {
139
+ const tagged = col && typeof value === 'number' && this.isFloatCol(col) ? new PowdbFloatParam(value) : value;
140
+ params.push(tagged);
141
+ return `$${params.length}`;
142
+ }
143
+ /**
144
+ * Render a value for a write *assignment* (`col := …`). Every value — float
145
+ * columns included — is sent as a positional `$N` param. PowDB ≥ 0.7.0 fixed
146
+ * the int→float UPDATE coercion bug (`score := $n` with an integer param now
147
+ * reads back the integer value, not the raw i64 bits), so the float-literal
148
+ * inlining workaround Turbine carried for ≤ 0.6.2 is gone. Marks the column as
149
+ * float-typed for the *embedded* literal encoder (which materializes params
150
+ * into PowQL text) so an integer-valued float still encodes as a float literal
151
+ * and coercion stays unambiguous. Non-finite floats are still rejected.
152
+ */
153
+ writeRef(value, col, params) {
154
+ if (typeof value === 'number' && !Number.isFinite(value) && this.isFloatCol(col)) {
155
+ throw new ValidationError(`[turbine] Non-finite value for float column "${col.name}" on "${this.table}".`);
156
+ }
157
+ return this.param(value, params, col);
158
+ }
159
+ isFloatCol(col) {
160
+ try {
161
+ return powqlColumnType(col) === 'float';
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ /** A predicate that is always false — the empty-`in` / contradiction sentinel. */
168
+ alwaysFalse() {
169
+ const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
170
+ return `(.${pk} is null and .${pk} is not null)`;
171
+ }
172
+ // -------------------------------------------------------------------------
173
+ // WHERE builder
174
+ // -------------------------------------------------------------------------
175
+ /**
176
+ * Compile a {@link WhereClause} into a PowQL filter expression, pushing every
177
+ * value as a positional `$N` param. Returns `''` when there are no conditions.
178
+ */
179
+ buildWhere(where, params) {
180
+ if (!where)
181
+ return '';
182
+ const parts = [];
183
+ for (const [key, value] of Object.entries(where)) {
184
+ if (value === undefined)
185
+ continue;
186
+ if (key === 'AND') {
187
+ const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
188
+ if (sub.length)
189
+ parts.push(`(${sub.join(' and ')})`);
190
+ }
191
+ else if (key === 'OR') {
192
+ const sub = value.map((w) => this.buildWhere(w, params)).filter(Boolean);
193
+ if (sub.length)
194
+ parts.push(`(${sub.join(' or ')})`);
195
+ }
196
+ else if (key === 'NOT') {
197
+ const sub = this.buildWhere(value, params);
198
+ if (sub)
199
+ parts.push(`not (${sub})`);
200
+ }
201
+ else if (this.meta.relations[key]) {
202
+ // Relation filters are pre-resolved to scalar in/notIn by
203
+ // resolveRelationFilters() before buildWhere runs — reaching here means
204
+ // a caller skipped that step (an internal bug, not user error).
205
+ throw new ValidationError(`[turbine] internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
206
+ }
207
+ else {
208
+ parts.push(this.buildFieldCondition(key, value, params));
209
+ }
210
+ }
211
+ return parts.join(' and ');
212
+ }
213
+ /** Build a single `field: value | operator` condition. */
214
+ buildFieldCondition(field, value, params) {
215
+ const ref = this.ref(field);
216
+ if (value === null)
217
+ return `${ref} is null`;
218
+ if (value instanceof Date || typeof value !== 'object') {
219
+ return `${ref} = ${this.param(value, params)}`;
220
+ }
221
+ const op = value;
222
+ rejectUnsupportedFilter(op, field);
223
+ if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
224
+ // A bare object that is not an operator set — equality by value.
225
+ return `${ref} = ${this.param(value, params)}`;
226
+ }
227
+ const insensitive = op.mode === 'insensitive';
228
+ const lhs = insensitive ? `lower(${ref})` : ref;
229
+ const conds = [];
230
+ for (const [opName, opVal] of Object.entries(op)) {
231
+ if (opVal === undefined || opName === 'mode')
232
+ continue;
233
+ switch (opName) {
234
+ case 'equals':
235
+ conds.push(opVal === null ? `${ref} is null` : `${lhs} = ${this.bind(opVal, params, insensitive)}`);
236
+ break;
237
+ case 'not':
238
+ conds.push(opVal === null ? `${ref} is not null` : `not (${lhs} = ${this.bind(opVal, params, insensitive)})`);
239
+ break;
240
+ case 'gt':
241
+ conds.push(`${lhs} > ${this.bind(opVal, params, insensitive)}`);
242
+ break;
243
+ case 'gte':
244
+ conds.push(`${lhs} >= ${this.bind(opVal, params, insensitive)}`);
245
+ break;
246
+ case 'lt':
247
+ conds.push(`${lhs} < ${this.bind(opVal, params, insensitive)}`);
248
+ break;
249
+ case 'lte':
250
+ conds.push(`${lhs} <= ${this.bind(opVal, params, insensitive)}`);
251
+ break;
252
+ case 'in':
253
+ conds.push(this.buildInList(lhs, opVal, params, insensitive, false));
254
+ break;
255
+ case 'notIn':
256
+ conds.push(this.buildInList(lhs, opVal, params, insensitive, true));
257
+ break;
258
+ case 'contains':
259
+ conds.push(`${lhs} like ${this.bindLike(`%${escapeLike(String(opVal))}%`, params, insensitive)}`);
260
+ break;
261
+ case 'startsWith':
262
+ conds.push(`${lhs} like ${this.bindLike(`${escapeLike(String(opVal))}%`, params, insensitive)}`);
263
+ break;
264
+ case 'endsWith':
265
+ conds.push(`${lhs} like ${this.bindLike(`%${escapeLike(String(opVal))}`, params, insensitive)}`);
266
+ break;
267
+ default:
268
+ throw new ValidationError(`[turbine] Unsupported operator "${opName}" on PowDB.`);
269
+ }
270
+ }
271
+ return conds.length > 1 ? `(${conds.join(' and ')})` : (conds[0] ?? this.alwaysFalse());
272
+ }
273
+ /** Bind a value, lowercasing for case-insensitive comparisons. */
274
+ bind(value, params, insensitive) {
275
+ const ph = this.param(value, params);
276
+ return insensitive ? `lower(${ph})` : ph;
277
+ }
278
+ /** Bind a LIKE pattern (already escaped), lowercasing for insensitive mode. */
279
+ bindLike(pattern, params, insensitive) {
280
+ const ph = this.param(pattern, params);
281
+ return insensitive ? `lower(${ph})` : ph;
282
+ }
283
+ /** `lhs [not] in ($1, $2, …)` — empty list collapses to a constant. */
284
+ buildInList(lhs, values, params, insensitive, negate) {
285
+ if (!Array.isArray(values) || values.length === 0) {
286
+ // `in []` matches nothing; `not in []` matches everything.
287
+ return negate ? '(1 = 1)' : this.alwaysFalse();
288
+ }
289
+ const items = values.map((v) => this.bind(v, params, insensitive)).join(', ');
290
+ return `${lhs} ${negate ? 'not in' : 'in'} (${items})`;
291
+ }
292
+ /**
293
+ * Pre-resolve every relation filter (`some`/`none`/`every`) in a where clause
294
+ * into a plain scalar `in`/`notIn` condition on the **local key**, by running
295
+ * the inner predicate as its own query and materializing the matching keys as
296
+ * a literal list. The compiled where is then relation-free and `buildWhere`
297
+ * emits only `in (<literal list>)`.
298
+ *
299
+ * Why not an IN-subquery (`.k in (Target filter <e> { .fk })`)? PowDB's
300
+ * executor caches a subquery's result by **plan shape, ignoring the literal**,
301
+ * so a second subquery of the same shape with a different value returns the
302
+ * first one's stale rows (reproduced live on the embedded engine; the
303
+ * single-statement literal `in (list)` form is always correct). Resolving
304
+ * client-side trades extra round-trips for correctness, and recurses — nested
305
+ * relation filters in the inner predicate resolve when the target query runs.
306
+ */
307
+ async resolveRelationFilters(where, timeout) {
308
+ if (!where)
309
+ return where;
310
+ const scalar = {};
311
+ const relConds = [];
312
+ for (const [key, value] of Object.entries(where)) {
313
+ if (value === undefined)
314
+ continue;
315
+ if (key === 'AND' || key === 'OR') {
316
+ scalar[key] = await Promise.all(value.map((w) => this.resolveRelationFilters(w, timeout)));
317
+ }
318
+ else if (key === 'NOT') {
319
+ scalar[key] = await this.resolveRelationFilters(value, timeout);
320
+ }
321
+ else if (this.meta.relations[key]) {
322
+ relConds.push(await this.resolveRelationCondition(this.meta.relations[key], value, timeout));
323
+ }
324
+ else {
325
+ scalar[key] = value;
326
+ }
327
+ }
328
+ if (!relConds.length)
329
+ return scalar;
330
+ const hasScalar = Object.keys(scalar).length > 0;
331
+ return { AND: [...(hasScalar ? [scalar] : []), ...relConds] };
332
+ }
333
+ /** Resolve one hasMany/hasOne/belongsTo filter to `{ localField: { in|notIn: [...] } }`. */
334
+ async resolveRelationCondition(rel, filter, timeout) {
335
+ const mode = filter.some ? 'some' : filter.none ? 'none' : filter.every ? 'every' : null;
336
+ if (!mode)
337
+ return {};
338
+ const innerWhere = filter[mode];
339
+ const innerEmpty = !innerWhere || Object.keys(innerWhere).length === 0;
340
+ if (rel.type === 'manyToMany') {
341
+ return this.resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout);
342
+ }
343
+ const fk = normalizeKeyColumns(rel.foreignKey);
344
+ const rk = normalizeKeyColumns(rel.referenceKey);
345
+ if (fk.length > 1 || rk.length > 1) {
346
+ throw new UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key — PowQL has no tuple-\`in\` to express it`);
347
+ }
348
+ const targetMeta = this.schema.tables[rel.to];
349
+ if (!targetMeta)
350
+ throw new ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
351
+ // hasMany/hasOne: localKey = our referenceKey, collect the child's foreignKey.
352
+ // belongsTo: localKey = our foreignKey, collect the target's referenceKey.
353
+ const localCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
354
+ const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
355
+ const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
356
+ const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
357
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
358
+ const collect = async (w) => {
359
+ const rows = await targetQi.findMany({
360
+ where: w,
361
+ select: { [childField]: true },
362
+ timeout,
363
+ });
364
+ return [...new Set(rows.map((r) => r[childField]).filter((v) => v != null))];
365
+ };
366
+ if (mode === 'some')
367
+ return { [localField]: { in: await collect(innerWhere) } };
368
+ if (mode === 'none')
369
+ return { [localField]: { notIn: await collect(innerWhere) } };
370
+ // every: a parent qualifies unless it has a child FAILING the predicate.
371
+ if (innerEmpty)
372
+ return {}; // every:{} ⇒ trivially true for all parents
373
+ return { [localField]: { notIn: await collect({ NOT: innerWhere }) } };
374
+ }
375
+ /** Resolve a manyToMany filter through the junction to `{ sourceRefField: { in|notIn: [...] } }`. */
376
+ async resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout) {
377
+ const through = rel.through;
378
+ if (!through)
379
+ throw new ValidationError(`[turbine] manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
380
+ const sourceJ = normalizeKeyColumns(through.sourceKey);
381
+ const targetJ = normalizeKeyColumns(through.targetKey);
382
+ const sourceRef = normalizeKeyColumns(rel.referenceKey);
383
+ const targetMeta = this.schema.tables[rel.to];
384
+ if (!targetMeta)
385
+ throw new ValidationError(`[turbine] Relation "${rel.name}" targets unknown table "${rel.to}".`);
386
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
387
+ throw new UnsupportedFeatureError('composite-key manyToMany filters', 'PowDB', `relation "${rel.name}" — PowQL has no tuple-\`in\` for composite junction/target keys`);
388
+ }
389
+ const sourceJCol = sourceJ[0];
390
+ const targetJCol = targetJ[0];
391
+ const sourceRefCol = sourceRef[0];
392
+ const targetPkCol = targetMeta.primaryKey[0];
393
+ const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
394
+ const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
395
+ const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
396
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
397
+ const collectTargetPks = async (w) => {
398
+ const rows = await targetQi.findMany({
399
+ where: w,
400
+ select: { [targetPkField]: true },
401
+ timeout,
402
+ });
403
+ return [...new Set(rows.map((r) => r[targetPkField]).filter((v) => v != null))];
404
+ };
405
+ // Junction source keys linking any of `targetPks` (literal IN-list — never a subquery).
406
+ const sourcesForTargets = async (targetPks) => {
407
+ if (!targetPks.length)
408
+ return [];
409
+ const out = new Set();
410
+ for (let i = 0; i < targetPks.length; i += MAX_RELATION_KEYS) {
411
+ const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
412
+ const params = [];
413
+ const ph = chunk.map((v) => this.param(v, params)).join(', ');
414
+ const { rows } = await this.exec(`${through.table} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout);
415
+ for (const r of rows) {
416
+ const v = r[sourceJCol];
417
+ if (v != null)
418
+ out.add(sourceRefColMeta ? coerceScalar(String(v), sourceRefColMeta.tsType) : v);
419
+ }
420
+ }
421
+ return [...out];
422
+ };
423
+ if (mode === 'some') {
424
+ return { [sourceRefField]: { in: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
425
+ }
426
+ if (mode === 'none') {
427
+ return { [sourceRefField]: { notIn: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
428
+ }
429
+ // every: exclude parents that link a target FAILING the predicate.
430
+ if (innerEmpty)
431
+ return {}; // every:{} ⇒ trivially true
432
+ const failing = await sourcesForTargets(await collectTargetPks({ NOT: innerWhere }));
433
+ return { [sourceRefField]: { notIn: failing } };
434
+ }
435
+ // -------------------------------------------------------------------------
436
+ // Projection / order
437
+ // -------------------------------------------------------------------------
438
+ /** Resolve the set of columns to project, honouring `select` / `omit`. */
439
+ projectedColumns(select, omit) {
440
+ let cols = this.meta.columns.map((c) => c.name);
441
+ if (select && Object.keys(select).length) {
442
+ const picked = new Set(Object.entries(select)
443
+ .filter(([, v]) => v)
444
+ .map(([k]) => this.column(k).name));
445
+ // Always keep the PK so reselect / relation stitching has a key to work with.
446
+ for (const pk of this.meta.primaryKey)
447
+ picked.add(pk);
448
+ cols = cols.filter((c) => picked.has(c));
449
+ }
450
+ if (omit && Object.keys(omit).length) {
451
+ const dropped = new Set(Object.entries(omit)
452
+ .filter(([, v]) => v)
453
+ .map(([k]) => this.column(k).name));
454
+ cols = cols.filter((c) => !dropped.has(c));
455
+ }
456
+ return cols;
457
+ }
458
+ /** `{ .c1, .c2, … }` projection clause. */
459
+ projection(cols) {
460
+ return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
461
+ }
462
+ /** `order .c1 asc, .c2 desc` clause (empty string when no orderBy). */
463
+ buildOrder(orderBy) {
464
+ if (!orderBy)
465
+ return '';
466
+ const keys = Object.entries(orderBy).filter(([, dir]) => dir !== undefined);
467
+ if (!keys.length)
468
+ return '';
469
+ const parts = keys.map(([field, dir]) => {
470
+ if (dir && typeof dir === 'object') {
471
+ throw new UnsupportedFeatureError('vector / distance ordering', 'PowDB', `field "${field}"`);
472
+ }
473
+ return `${this.ref(field)} ${dir === 'desc' ? 'desc' : 'asc'}`;
474
+ });
475
+ return ` order ${parts.join(', ')}`;
476
+ }
477
+ // -------------------------------------------------------------------------
478
+ // Execution plumbing
479
+ // -------------------------------------------------------------------------
480
+ /** Run PowQL with optional timeout, emitting a query event either way. */
481
+ async exec(powql, params, timeout) {
482
+ const start = performance.now();
483
+ const run = this.pool.query(powql, params);
484
+ try {
485
+ const result = timeout
486
+ ? await Promise.race([
487
+ run,
488
+ new Promise((_, reject) => setTimeout(() => reject(new TimeoutError(timeout)), timeout)),
489
+ ])
490
+ : await run;
491
+ this.emit(powql, params, performance.now() - start, result.rowCount ?? result.rows.length);
492
+ return result;
493
+ }
494
+ catch (err) {
495
+ this.emit(powql, params, performance.now() - start, 0, err);
496
+ throw err;
497
+ }
498
+ }
499
+ emit(sql, params, duration, rows, error) {
500
+ if (!this.onQuery)
501
+ return;
502
+ try {
503
+ this.onQuery({
504
+ sql,
505
+ params,
506
+ duration,
507
+ model: this.table,
508
+ action: this.currentAction,
509
+ rows,
510
+ timestamp: new Date(),
511
+ error,
512
+ });
513
+ }
514
+ catch {
515
+ /* listener errors never crash a query */
516
+ }
517
+ }
518
+ /** Run a method body through the middleware chain (mirrors QueryInterface). */
519
+ async withMiddleware(action, args, executor) {
520
+ this.currentAction = action;
521
+ if (this.middlewares.length === 0)
522
+ return executor();
523
+ let index = 0;
524
+ const next = async (p) => {
525
+ if (index < this.middlewares.length)
526
+ return this.middlewares[index++](p, next);
527
+ return executor();
528
+ };
529
+ return next({ model: this.table, action, args: { ...args } });
530
+ }
531
+ /** Map raw rows to typed entities. */
532
+ shape(rows) {
533
+ return rows.map((r) => rowToEntity(r, this.meta));
534
+ }
535
+ // -------------------------------------------------------------------------
536
+ // Reads
537
+ // -------------------------------------------------------------------------
538
+ async findMany(args = {}) {
539
+ return this.withMiddleware('findMany', args, async () => {
540
+ const rows = await this.runFind(args);
541
+ const entities = this.shape(rows);
542
+ if (args.with)
543
+ await this.loadRelations(entities, args.with, args.timeout);
544
+ return entities;
545
+ });
546
+ }
547
+ /** Build + run the flat findMany select; returns raw rows. */
548
+ async runFind(args) {
549
+ if (args.cursor) {
550
+ throw new UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
551
+ }
552
+ const params = [];
553
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
554
+ const where = this.buildWhere(resolvedWhere, params);
555
+ const cols = this.projectedColumns(args.select, args.omit);
556
+ const distinct = args.distinct?.length ? ' distinct' : '';
557
+ const filter = where ? ` filter ${where}` : '';
558
+ const order = this.buildOrder(args.orderBy);
559
+ const limit = args.limit ?? args.take ?? this.defaultLimit;
560
+ if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
561
+ this.warnedUnlimited = true;
562
+ console.warn(`[turbine] findMany on "${this.table}" has no limit — this scans the whole table.`);
563
+ }
564
+ const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
565
+ const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
566
+ const powql = `${this.table}${distinct}${filter}${order}${limitClause}${offsetClause} ${this.projection(cols)}`;
567
+ const { rows } = await this.exec(powql, params, args.timeout);
568
+ return rows;
569
+ }
570
+ async findUnique(args) {
571
+ return this.withMiddleware('findUnique', args, async () => {
572
+ const rows = await this.runFind({ ...args, limit: 1 });
573
+ if (!rows.length)
574
+ return null;
575
+ const entities = this.shape(rows);
576
+ if (args.with)
577
+ await this.loadRelations(entities, args.with, args.timeout);
578
+ return entities[0];
579
+ });
580
+ }
581
+ async findFirst(args = {}) {
582
+ return this.withMiddleware('findFirst', args, async () => {
583
+ const rows = await this.runFind({ ...args, limit: 1 });
584
+ if (!rows.length)
585
+ return null;
586
+ const entities = this.shape(rows);
587
+ if (args.with)
588
+ await this.loadRelations(entities, args.with, args.timeout);
589
+ return entities[0];
590
+ });
591
+ }
592
+ async findUniqueOrThrow(args) {
593
+ const row = await this.findUnique(args);
594
+ if (!row)
595
+ throw new NotFoundError({ table: this.table, where: args.where });
596
+ return row;
597
+ }
598
+ async findFirstOrThrow(args = {}) {
599
+ const row = await this.findFirst(args);
600
+ if (!row)
601
+ throw new NotFoundError({ table: this.table, where: (args.where ?? {}) });
602
+ return row;
603
+ }
604
+ // -------------------------------------------------------------------------
605
+ // Nested relations — batched N+1 loaders (hasMany / hasOne / belongsTo)
606
+ // -------------------------------------------------------------------------
607
+ /** Load each requested relation for `parents` and attach it onto each row. */
608
+ async loadRelations(parents, withClause, timeout, depth = 0) {
609
+ if (depth >= 10) {
610
+ throw new ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
611
+ }
612
+ if (!parents.length)
613
+ return;
614
+ for (const [relName, opt] of Object.entries(withClause)) {
615
+ if (!opt)
616
+ continue;
617
+ const rel = this.meta.relations[relName];
618
+ if (!rel)
619
+ throw new ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
620
+ if (rel.type === 'manyToMany') {
621
+ await this.loadManyToMany(parents, rel, relName, opt, timeout);
622
+ continue;
623
+ }
624
+ const fk = normalizeKeyColumns(rel.foreignKey);
625
+ const rk = normalizeKeyColumns(rel.referenceKey);
626
+ if (fk.length > 1 || rk.length > 1) {
627
+ throw new UnsupportedFeatureError('composite-key nested reads', 'PowDB', `relation "${relName}"`);
628
+ }
629
+ const options = (opt === true ? {} : opt);
630
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
631
+ const targetMeta = this.schema.tables[rel.to];
632
+ // Local key on the parent, remote key on the target child.
633
+ const parentKeyCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
634
+ const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
635
+ const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
636
+ const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
637
+ const keys = [
638
+ ...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
639
+ ];
640
+ const childByKey = new Map();
641
+ // Chunk the key set so a single `in (…)` never exceeds PowDB's
642
+ // per-statement param / row limits; merge each chunk's children.
643
+ for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
644
+ const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
645
+ const childWhere = {
646
+ ...options.where,
647
+ [childKeyField]: { in: chunk },
648
+ };
649
+ const children = (await targetQi.findMany({
650
+ ...options,
651
+ where: childWhere,
652
+ with: options.with,
653
+ timeout: options.timeout ?? timeout,
654
+ }));
655
+ for (const child of children) {
656
+ const k = child[childKeyField];
657
+ const bucket = childByKey.get(k);
658
+ if (bucket)
659
+ bucket.push(child);
660
+ else
661
+ childByKey.set(k, [child]);
662
+ }
663
+ }
664
+ const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
665
+ for (const parent of parents) {
666
+ const k = parent[parentKeyField];
667
+ const matches = childByKey.get(k) ?? [];
668
+ parent[relName] = single ? (matches[0] ?? null) : matches;
669
+ }
670
+ }
671
+ }
672
+ /**
673
+ * manyToMany nested read — a three-hop batched loader (no `json_agg`/join
674
+ * pushdown): (1) read the junction rows for all parents in `sourceKey in (…)`
675
+ * chunks, (2) read the target rows for the collected `targetKey`s, (3) stitch
676
+ * each parent → its junction rows → its targets in memory. Mirrors the
677
+ * single-key N+1 loaders; the junction's source/target columns must be single
678
+ * (composite junction keys would need PowQL tuple-`in`, which it lacks).
679
+ */
680
+ async loadManyToMany(parents, rel, relName, opt, timeout) {
681
+ const through = rel.through;
682
+ if (!through)
683
+ throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
684
+ const sourceJ = normalizeKeyColumns(through.sourceKey);
685
+ const targetJ = normalizeKeyColumns(through.targetKey);
686
+ const sourceRef = normalizeKeyColumns(rel.referenceKey);
687
+ const targetMeta = this.schema.tables[rel.to];
688
+ if (!targetMeta)
689
+ throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${rel.to}".`);
690
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
691
+ throw new UnsupportedFeatureError('composite-key manyToMany', 'PowDB', `relation "${relName}" — PowQL has no tuple-\`in\`, so composite junction/target keys can't be loaded`);
692
+ }
693
+ const sourceJCol = sourceJ[0];
694
+ const targetJCol = targetJ[0];
695
+ const sourceRefCol = sourceRef[0];
696
+ const targetPkCol = targetMeta.primaryKey[0];
697
+ const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
698
+ const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
699
+ const targetPkColMeta = targetMeta.columns.find((c) => c.name === targetPkCol);
700
+ const parentKeys = [
701
+ ...new Set(parents.map((p) => p[parentRefField]).filter((k) => k != null)),
702
+ ];
703
+ if (!parentKeys.length) {
704
+ for (const parent of parents)
705
+ parent[relName] = [];
706
+ return;
707
+ }
708
+ // (1) Junction rows: sourceKeyVal(String) → [targetKeyVal(String)].
709
+ const targetsBySource = new Map();
710
+ const allTargetVals = new Set();
711
+ for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
712
+ const chunk = parentKeys.slice(i, i + MAX_RELATION_KEYS);
713
+ const params = [];
714
+ const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
715
+ const powql = `${through.table} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
716
+ const { rows } = await this.exec(powql, params, timeout);
717
+ for (const row of rows) {
718
+ const sv = String(row[sourceJCol]);
719
+ const tv = String(row[targetJCol]);
720
+ const bucket = targetsBySource.get(sv);
721
+ if (bucket)
722
+ bucket.push(tv);
723
+ else
724
+ targetsBySource.set(sv, [tv]);
725
+ allTargetVals.add(tv);
726
+ }
727
+ }
728
+ // (2) Target rows by PK, honouring the relation's own where/with/select/…
729
+ const options = (opt === true ? {} : opt);
730
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
731
+ const targetByPk = new Map();
732
+ const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
733
+ for (let i = 0; i < targetValList.length; i += MAX_RELATION_KEYS) {
734
+ const chunk = targetValList.slice(i, i + MAX_RELATION_KEYS);
735
+ const where = {
736
+ ...options.where,
737
+ [targetPkField]: { in: chunk },
738
+ };
739
+ const targets = (await targetQi.findMany({
740
+ ...options,
741
+ where,
742
+ with: options.with,
743
+ timeout: options.timeout ?? timeout,
744
+ }));
745
+ for (const t of targets)
746
+ targetByPk.set(String(t[targetPkField]), t);
747
+ }
748
+ // (3) Stitch: each parent → its junction targets (m2m is always a list).
749
+ for (const parent of parents) {
750
+ const sv = String(parent[parentRefField]);
751
+ const tvs = targetsBySource.get(sv) ?? [];
752
+ const children = [];
753
+ for (const tv of tvs) {
754
+ const child = targetByPk.get(tv);
755
+ if (child)
756
+ children.push(child);
757
+ }
758
+ parent[relName] = children;
759
+ }
760
+ }
761
+ // -------------------------------------------------------------------------
762
+ // Writes (reselect — PowDB has no RETURNING)
763
+ // -------------------------------------------------------------------------
764
+ /** Split `data` into scalar assignments; reject relation (nested-write) keys. */
765
+ scalarData(data) {
766
+ const out = [];
767
+ for (const [field, value] of Object.entries(data)) {
768
+ if (value === undefined)
769
+ continue;
770
+ if (this.meta.relations[field]) {
771
+ throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not createMany()/upsert()`);
772
+ }
773
+ out.push({ col: this.column(field), value });
774
+ }
775
+ return out;
776
+ }
777
+ /**
778
+ * Fill in a client-generated UUID for a defaulted **string** PK that wasn't
779
+ * supplied. A server-generated PK ({@link ColumnMetadata.isGenerated}, e.g. an
780
+ * `int` column with PowDB's `auto` modifier) is left untouched — PowDB assigns
781
+ * it and the trailing `returning` reads it back — as is any non-string PK.
782
+ */
783
+ applyPkDefault(data) {
784
+ const out = { ...data };
785
+ for (const pk of this.meta.primaryKey) {
786
+ const field = this.meta.reverseColumnMap[pk] ?? pk;
787
+ const col = this.meta.columns.find((c) => c.name === pk);
788
+ if (out[field] == null &&
789
+ col?.hasDefault &&
790
+ !col.isGenerated &&
791
+ col.tsType.replace(/\s*\|\s*null$/, '').trim() === 'string') {
792
+ out[field] = randomUUID();
793
+ }
794
+ }
795
+ return out;
796
+ }
797
+ async create(args) {
798
+ return this.withMiddleware('create', args, async () => {
799
+ if (hasRelationFields(args.data, this.meta)) {
800
+ return this.nestedCreate(args);
801
+ }
802
+ const data = this.applyPkDefault(args.data);
803
+ const assigns = this.scalarData(data);
804
+ const params = [];
805
+ const body = assigns.map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`).join(', ');
806
+ // `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
807
+ const { rows } = await this.exec(`insert ${this.table} { ${body} } returning`, params, args.timeout);
808
+ const row = rows.length ? this.shape(rows)[0] : null;
809
+ if (!row)
810
+ throw new NotFoundError({ table: this.table, where: data });
811
+ return row;
812
+ });
813
+ }
814
+ async createMany(args) {
815
+ return this.withMiddleware('createMany', args, async () => {
816
+ const inputs = args.data.map((d) => this.applyPkDefault(d));
817
+ if (!inputs.length)
818
+ return [];
819
+ const params = [];
820
+ const tuples = inputs.map((d) => {
821
+ const assigns = this.scalarData(d);
822
+ return `{ ${assigns.map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`).join(', ')} }`;
823
+ });
824
+ // Multi-row insert with `returning` hands back every inserted row in one round-trip.
825
+ const { rows } = await this.exec(`insert ${this.table} ${tuples.join(', ')} returning`, params, args.timeout);
826
+ return this.shape(rows);
827
+ });
828
+ }
829
+ async update(args) {
830
+ return this.withMiddleware('update', args, async () => {
831
+ if (hasRelationFields(args.data, this.meta)) {
832
+ return this.nestedUpdate(args);
833
+ }
834
+ const params = [];
835
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
836
+ const where = this.buildWhere(resolvedWhere, params);
837
+ this.assertCompiledWhere(where, false, 'update');
838
+ const setClause = this.buildUpdateAssignments(args.data, params);
839
+ // `returning` hands back the post-update row(s); take the first (single-row contract).
840
+ const { rows } = await this.exec(`${this.table} filter ${where} update { ${setClause} } returning`, params, args.timeout);
841
+ const row = rows.length ? this.shape(rows)[0] : null;
842
+ if (!row)
843
+ throw new NotFoundError({ table: this.table, where: args.where });
844
+ return row;
845
+ });
846
+ }
847
+ async updateMany(args) {
848
+ return this.withMiddleware('updateMany', args, async () => {
849
+ const params = [];
850
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
851
+ const where = this.buildWhere(resolvedWhere, params);
852
+ this.assertCompiledWhere(where, args.allowFullTableScan, 'updateMany');
853
+ const setClause = this.buildUpdateAssignments(args.data, params);
854
+ const filter = where ? ` filter ${where}` : '';
855
+ const { rowCount } = await this.exec(`${this.table}${filter} update { ${setClause} }`, params, args.timeout);
856
+ return { count: rowCount };
857
+ });
858
+ }
859
+ /** Compile `data` into PowQL update assignments, including atomic operators. */
860
+ buildUpdateAssignments(data, params) {
861
+ const parts = [];
862
+ for (const [field, value] of Object.entries(data)) {
863
+ if (value === undefined)
864
+ continue;
865
+ if (this.meta.relations[field]) {
866
+ throw new UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}" — nested writes need create()/update(), not updateMany()/upsert()`);
867
+ }
868
+ const colMeta = this.column(field);
869
+ const ref = this.ref(field);
870
+ const col = colMeta.name;
871
+ if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
872
+ const opObj = value;
873
+ if ('set' in opObj)
874
+ parts.push(`${col} := ${this.writeRef(opObj.set, colMeta, params)}`);
875
+ else if ('increment' in opObj)
876
+ parts.push(`${col} := ${ref} + ${this.writeRef(opObj.increment, colMeta, params)}`);
877
+ else if ('decrement' in opObj)
878
+ parts.push(`${col} := ${ref} - ${this.writeRef(opObj.decrement, colMeta, params)}`);
879
+ else if ('multiply' in opObj)
880
+ parts.push(`${col} := ${ref} * ${this.writeRef(opObj.multiply, colMeta, params)}`);
881
+ else if ('divide' in opObj)
882
+ parts.push(`${col} := ${ref} / ${this.writeRef(opObj.divide, colMeta, params)}`);
883
+ else
884
+ throw new ValidationError(`[turbine] Unsupported update operator on "${field}".`);
885
+ }
886
+ else {
887
+ parts.push(`${col} := ${this.writeRef(value, colMeta, params)}`);
888
+ }
889
+ }
890
+ if (!parts.length)
891
+ throw new ValidationError(`[turbine] update on "${this.table}" has no fields to set.`);
892
+ return parts.join(', ');
893
+ }
894
+ // -------------------------------------------------------------------------
895
+ // Nested writes — create/update whose `data` carries relation ops (create,
896
+ // connect, connectOrCreate, disconnect, set, delete, update, upsert). Reuses
897
+ // the engine-agnostic nested-write engine (it only needs ctx.schema + the
898
+ // table accessors PowqlInterface already provides). Runs as ONE flat top-level
899
+ // PowDB transaction — single global write lock, no savepoints, so the whole
900
+ // tree commits or rolls back together (mirrors the SQL path's coverage:
901
+ // hasMany / hasOne / belongsTo; manyToMany nested writes are not handled by
902
+ // the shared engine on any backend).
903
+ // -------------------------------------------------------------------------
904
+ isTxScoped() {
905
+ return this.options._txScoped === true;
906
+ }
907
+ async nestedCreate(args) {
908
+ const data = args.data;
909
+ if (this.isTxScoped()) {
910
+ return executeNestedCreate(this.buildNestedCtx(), this.table, data);
911
+ }
912
+ return this.runInImplicitTx((ctx) => executeNestedCreate(ctx, this.table, data));
913
+ }
914
+ async nestedUpdate(args) {
915
+ const data = args.data;
916
+ const where = args.where;
917
+ if (this.isTxScoped()) {
918
+ return executeNestedUpdate(this.buildNestedCtx(), this.table, where, data);
919
+ }
920
+ return this.runInImplicitTx((ctx) => executeNestedUpdate(ctx, this.table, where, data));
921
+ }
922
+ /** Open a flat PowDB transaction on a pinned connection and run `fn` inside it. */
923
+ async runInImplicitTx(fn) {
924
+ // Route tx keywords through the dialect (like the SQL path) so this never
925
+ // drifts from `powdbDialect`; falls back to the literal lowercase keywords.
926
+ const d = this.options.dialect;
927
+ const client = await this.pool.connect();
928
+ try {
929
+ await client.query(d?.beginStatement?.() ?? 'begin');
930
+ const { TransactionClient } = await import('./client.js');
931
+ const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
932
+ const ctx = { schema: this.schema, tx: tx };
933
+ const result = await fn(ctx);
934
+ await client.query(d?.commitStatement?.() ?? 'commit');
935
+ return result;
936
+ }
937
+ catch (err) {
938
+ try {
939
+ await client.query(d?.rollbackStatement?.() ?? 'rollback');
940
+ }
941
+ catch {
942
+ /* best-effort — the connection may be gone */
943
+ }
944
+ throw err;
945
+ }
946
+ finally {
947
+ client.release();
948
+ }
949
+ }
950
+ /** Already inside a transaction: build a context whose table accessors reuse the pinned pool. */
951
+ buildNestedCtx() {
952
+ const opts = { ...this.options, _txScoped: true };
953
+ const tx = {
954
+ table: (name) => new PowqlInterface(this.pool, name, this.schema, this.middlewares, opts),
955
+ };
956
+ return { schema: this.schema, tx: tx };
957
+ }
958
+ async delete(args) {
959
+ return this.withMiddleware('delete', args, async () => {
960
+ const params = [];
961
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
962
+ const where = this.buildWhere(resolvedWhere, params);
963
+ this.assertCompiledWhere(where, false, 'delete');
964
+ // `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
965
+ const { rows } = await this.exec(`${this.table} filter ${where} delete returning`, params, args.timeout);
966
+ const row = rows.length ? this.shape(rows)[0] : null;
967
+ if (!row)
968
+ throw new NotFoundError({ table: this.table, where: args.where });
969
+ return row;
970
+ });
971
+ }
972
+ async deleteMany(args) {
973
+ return this.withMiddleware('deleteMany', args, async () => {
974
+ const params = [];
975
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
976
+ const where = this.buildWhere(resolvedWhere, params);
977
+ this.assertCompiledWhere(where, args.allowFullTableScan, 'deleteMany');
978
+ const filter = where ? ` filter ${where}` : '';
979
+ const { rowCount } = await this.exec(`${this.table}${filter} delete`, params, args.timeout);
980
+ return { count: rowCount };
981
+ });
982
+ }
983
+ async upsert(args) {
984
+ return this.withMiddleware('upsert', args, async () => {
985
+ const createData = this.applyPkDefault(args.create);
986
+ const pkCol = this.meta.primaryKey[0];
987
+ if (this.meta.primaryKey.length !== 1 || !pkCol) {
988
+ // PowQL's native `upsert … on .col` takes a single conflict column, so a
989
+ // composite PK falls back to an atomic reselect-or-write transaction.
990
+ return this.upsertComposite(createData, args.update);
991
+ }
992
+ const params = [];
993
+ const createBody = this.scalarData(createData)
994
+ .map((a) => `${a.col.name} := ${this.writeRef(a.value, a.col, params)}`)
995
+ .join(', ');
996
+ const updateBody = this.buildUpdateAssignments(args.update, params);
997
+ // PowDB 0.7.0's `upsert` statement does NOT accept a trailing `returning`
998
+ // (verified: "unexpected trailing token … 'returning'"), because it is one
999
+ // atomic insert-or-update, not two branches. So upsert alone keeps the
1000
+ // reselect-by-PK fetch; create/update/delete all use `returning`.
1001
+ await this.exec(`upsert ${this.table} on .${pkCol} { ${createBody} } on conflict { ${updateBody} }`, params, args.timeout);
1002
+ const pkField = this.meta.reverseColumnMap[pkCol] ?? pkCol;
1003
+ const row = await this.reselectByPk(createData[pkField], args.timeout);
1004
+ if (!row)
1005
+ throw new NotFoundError({ table: this.table, where: createData });
1006
+ return row;
1007
+ });
1008
+ }
1009
+ /**
1010
+ * Composite-key upsert: PowQL's `upsert … on .col` only takes one conflict
1011
+ * column, so reselect by the full composite PK and update-or-create inside one
1012
+ * flat transaction (PowDB single-writer makes the read-then-write safe from
1013
+ * concurrent writers; the transaction makes it atomic with the write).
1014
+ */
1015
+ async upsertComposite(createData, updateData) {
1016
+ // Accept either the camelCase field or the snake_case column in `create`
1017
+ // (create() resolves both), and key the where by field name.
1018
+ const pkPairs = this.meta.primaryKey.map((pk) => {
1019
+ const field = this.meta.reverseColumnMap[pk] ?? pk;
1020
+ return { field, value: createData[field] ?? createData[pk] };
1021
+ });
1022
+ if (pkPairs.some((p) => p.value == null)) {
1023
+ throw new ValidationError(`[turbine] upsert on "${this.table}" needs every composite-PK field in \`create\` (${pkPairs
1024
+ .map((p) => p.field)
1025
+ .join(', ')}).`);
1026
+ }
1027
+ const pkWhere = Object.fromEntries(pkPairs.map((p) => [p.field, p.value]));
1028
+ const run = async (ctx) => {
1029
+ const tbl = ctx.tx.table(this.table);
1030
+ const existing = await tbl.findUnique({ where: pkWhere });
1031
+ return existing
1032
+ ? (await tbl.update({ where: pkWhere, data: updateData }))
1033
+ : (await tbl.create({ data: createData }));
1034
+ };
1035
+ return this.isTxScoped() ? run(this.buildNestedCtx()) : this.runInImplicitTx(run);
1036
+ }
1037
+ // -------------------------------------------------------------------------
1038
+ // Aggregates
1039
+ // -------------------------------------------------------------------------
1040
+ async count(args = {}) {
1041
+ return this.withMiddleware('count', (args ?? {}), async () => {
1042
+ const params = [];
1043
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1044
+ const where = this.buildWhere(resolvedWhere, params);
1045
+ const filter = where ? ` filter ${where}` : '';
1046
+ const { rows } = await this.exec(`count(${this.table}${filter})`, params, args.timeout);
1047
+ return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
1048
+ });
1049
+ }
1050
+ async aggregate(args) {
1051
+ return this.withMiddleware('aggregate', args, async () => {
1052
+ // One scalar query per aggregate — PowDB's bare-projection aggregate is broken.
1053
+ const result = {};
1054
+ const filterParams = [];
1055
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1056
+ const where = this.buildWhere(resolvedWhere, filterParams);
1057
+ const filter = where ? ` filter ${where}` : '';
1058
+ const scalar = async (expr) => {
1059
+ const params = [...filterParams];
1060
+ const { rows } = await this.exec(expr, params, args.timeout);
1061
+ const v = rows[0]?.value;
1062
+ return v == null || v === 'null' ? null : Number(v);
1063
+ };
1064
+ if (args._count) {
1065
+ if (args._count === true) {
1066
+ result._count = (await scalar(`count(${this.table}${filter})`)) ?? 0;
1067
+ }
1068
+ else {
1069
+ const counts = {};
1070
+ for (const field of Object.keys(args._count).filter((f) => args._count[f])) {
1071
+ counts[field] = (await scalar(`count(${this.table}${filter} { ${this.ref(field)} })`)) ?? 0;
1072
+ }
1073
+ result._count = counts;
1074
+ }
1075
+ }
1076
+ for (const fn of ['_sum', '_avg', '_min', '_max']) {
1077
+ const spec = args[fn];
1078
+ if (!spec)
1079
+ continue;
1080
+ const acc = {};
1081
+ for (const field of Object.keys(spec).filter((f) => spec[f])) {
1082
+ const powfn = fn.slice(1); // sum/avg/min/max
1083
+ acc[field] = await scalar(`${powfn}(${this.table}${filter} { ${this.ref(field)} })`);
1084
+ }
1085
+ result[fn] = acc;
1086
+ }
1087
+ return result;
1088
+ });
1089
+ }
1090
+ async groupBy(args) {
1091
+ return this.withMiddleware('groupBy', args, async () => {
1092
+ const params = [];
1093
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
1094
+ const where = this.buildWhere(resolvedWhere, params);
1095
+ const filter = where ? ` filter ${where}` : '';
1096
+ const groupKeys = args.by.map((f) => this.ref(f));
1097
+ // Safe aliases — PowQL rejects reserved-word aliases (e.g. `count:`).
1098
+ const aliasMap = [];
1099
+ let n = 0;
1100
+ const proj = args.by.map((f) => this.ref(f));
1101
+ if (args._count) {
1102
+ aliasMap.push({ alias: `agg_${n++}`, fn: 'count', field: null, outKey: '_count' });
1103
+ }
1104
+ for (const fn of ['_sum', '_avg', '_min', '_max']) {
1105
+ const spec = args[fn];
1106
+ if (!spec)
1107
+ continue;
1108
+ for (const field of Object.keys(spec).filter((f) => spec[f])) {
1109
+ aliasMap.push({ alias: `agg_${n++}`, fn: fn.slice(1), field, outKey: `${fn}:${field}` });
1110
+ }
1111
+ }
1112
+ for (const a of aliasMap) {
1113
+ proj.push(`${a.alias}: ${a.fn}(${a.field ? this.ref(a.field) : `.${this.meta.primaryKey[0]}`})`);
1114
+ }
1115
+ const having = this.buildHaving(args.having, params);
1116
+ const order = this.buildOrder(args.orderBy);
1117
+ const powql = `${this.table}${filter} group ${groupKeys.join(', ')}${having}${order} { ${proj.join(', ')} }`;
1118
+ const { rows } = await this.exec(powql, params, args.timeout);
1119
+ // Reshape: group keys → camel fields + coerced; aggregates → nested {_sum:{field}}.
1120
+ return rows.map((raw) => {
1121
+ const out = {};
1122
+ for (const f of args.by) {
1123
+ const col = this.column(f);
1124
+ out[f] =
1125
+ typeof raw[col.name] === 'string' ? coerceScalar(raw[col.name], col.tsType) : raw[col.name];
1126
+ }
1127
+ for (const a of aliasMap) {
1128
+ const val = raw[a.alias];
1129
+ const num = val == null || val === 'null' ? null : Number(val);
1130
+ if (a.outKey === '_count')
1131
+ out._count = num ?? 0;
1132
+ else {
1133
+ const [bucket, field] = a.outKey.split(':');
1134
+ out[bucket] ??= {};
1135
+ out[bucket][field] = num;
1136
+ }
1137
+ }
1138
+ return out;
1139
+ });
1140
+ });
1141
+ }
1142
+ /** `having <expr>` over group aggregates (count/sum/avg/min/max). */
1143
+ buildHaving(having, params) {
1144
+ if (!having)
1145
+ return '';
1146
+ const conds = [];
1147
+ const cmp = (expr, filter) => {
1148
+ if (typeof filter === 'number')
1149
+ return `${expr} = ${this.param(filter, params)}`;
1150
+ const f = filter;
1151
+ const ops = { equals: '=', gt: '>', gte: '>=', lt: '<', lte: '<=', not: '!=' };
1152
+ return Object.entries(f)
1153
+ .filter(([k]) => ops[k])
1154
+ .map(([k, v]) => `${expr} ${ops[k]} ${this.param(v, params)}`)
1155
+ .join(' and ');
1156
+ };
1157
+ for (const [key, spec] of Object.entries(having)) {
1158
+ if (spec == null)
1159
+ continue;
1160
+ if (key === '_count') {
1161
+ conds.push(cmp(`count(.${this.meta.primaryKey[0]})`, spec));
1162
+ }
1163
+ else {
1164
+ for (const [fn, filter] of Object.entries(spec)) {
1165
+ if (filter == null)
1166
+ continue;
1167
+ conds.push(cmp(`${fn.slice(1)}(${this.ref(key)})`, filter));
1168
+ }
1169
+ }
1170
+ }
1171
+ return conds.length ? ` having ${conds.join(' and ')}` : '';
1172
+ }
1173
+ // -------------------------------------------------------------------------
1174
+ // Streaming / unsupported
1175
+ // -------------------------------------------------------------------------
1176
+ // biome-ignore lint/correctness/useYield: intentionally throws before yielding — PowDB has no server cursor.
1177
+ async *findManyStream() {
1178
+ throw new UnsupportedFeatureError('cursor streaming (findManyStream)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
1179
+ }
1180
+ // -------------------------------------------------------------------------
1181
+ // Reselect helper (upsert only — PowDB's upsert has no `returning`)
1182
+ // -------------------------------------------------------------------------
1183
+ /** Reselect a single row by its single-column primary key value. */
1184
+ async reselectByPk(pkValue, timeout) {
1185
+ const pkField = this.meta.reverseColumnMap[this.meta.primaryKey[0]] ?? this.meta.primaryKey[0];
1186
+ const rows = await this.runFind({
1187
+ where: { [pkField]: pkValue },
1188
+ limit: 1,
1189
+ timeout,
1190
+ });
1191
+ return rows.length ? this.shape(rows)[0] : null;
1192
+ }
1193
+ /**
1194
+ * Empty-where guard — blocks accidental whole-table writes. Mirrors the SQL
1195
+ * path's `assertMutationHasPredicate` (query/builder.ts): it gates on the
1196
+ * *compiled* PowQL filter fragment, NOT the shape of the `where` object. A
1197
+ * `where` whose conditions all evaporate during compilation — `{}`,
1198
+ * `{ id: undefined }`, `{ OR: [] }`, `{ AND: [] }`, `{ NOT: {} }`,
1199
+ * `{ OR: [{ f: undefined }] }` — compiles to the empty string and is refused,
1200
+ * because emitting a filter-less write would hit every row.
1201
+ */
1202
+ assertCompiledWhere(compiledWhere, allow, action) {
1203
+ if (allow === true)
1204
+ return;
1205
+ if (compiledWhere.length > 0)
1206
+ return;
1207
+ throw new ValidationError(`[turbine] ${action} on "${this.table}" refused: the \`where\` clause is empty. ` +
1208
+ `Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
1209
+ }
1210
+ }
1211
+ /** Coerce a group-key scalar string by the column's TS type (numbers/bools). */
1212
+ function coerceScalar(raw, tsType) {
1213
+ const ts = tsType.replace(/\s*\|\s*null$/i, '').trim();
1214
+ if (raw === 'null')
1215
+ return null;
1216
+ if (ts === 'number' || ts === 'bigint') {
1217
+ const n = Number(raw);
1218
+ return Number.isFinite(n) ? n : raw;
1219
+ }
1220
+ if (ts === 'boolean')
1221
+ return raw === 'true';
1222
+ if (ts === 'Date')
1223
+ return new Date(Number(raw) / 1000);
1224
+ return raw;
1225
+ }
1226
+ //# sourceMappingURL=powql.js.map