turbine-orm 0.34.0 → 0.36.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.
Files changed (76) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/client.js +26 -4
  8. package/dist/cjs/dialect.js +2 -1
  9. package/dist/cjs/errors.js +41 -1
  10. package/dist/cjs/generate.js +23 -2
  11. package/dist/cjs/index.js +4 -2
  12. package/dist/cjs/mssql.js +27 -5
  13. package/dist/cjs/mysql.js +4 -0
  14. package/dist/cjs/powdb.js +197 -25
  15. package/dist/cjs/powql.js +515 -51
  16. package/dist/cjs/query/aggregates.js +683 -0
  17. package/dist/cjs/query/batched-loader.js +2 -0
  18. package/dist/cjs/query/builder.js +361 -4508
  19. package/dist/cjs/query/filters.js +12 -0
  20. package/dist/cjs/query/relations.js +1698 -0
  21. package/dist/cjs/query/where-compile.js +180 -0
  22. package/dist/cjs/query/where.js +1491 -0
  23. package/dist/cjs/query/writes.js +680 -0
  24. package/dist/cjs/schema-builder.js +6 -0
  25. package/dist/cjs/schema-metadata.js +4 -0
  26. package/dist/cjs/schema-sql.js +265 -3
  27. package/dist/cjs/sqlite.js +4 -1
  28. package/dist/cli/index.d.ts +8 -2
  29. package/dist/cli/index.js +111 -18
  30. package/dist/cli/migrate.d.ts +24 -1
  31. package/dist/cli/migrate.js +77 -3
  32. package/dist/cli/studio-ui.generated.js +1 -1
  33. package/dist/cli/studio.d.ts +46 -13
  34. package/dist/cli/studio.js +331 -23
  35. package/dist/cli/ui.js +7 -1
  36. package/dist/client.d.ts +32 -5
  37. package/dist/client.js +26 -4
  38. package/dist/dialect.d.ts +28 -6
  39. package/dist/dialect.js +2 -1
  40. package/dist/errors.d.ts +36 -0
  41. package/dist/errors.js +39 -0
  42. package/dist/generate.js +23 -2
  43. package/dist/index.d.ts +3 -3
  44. package/dist/index.js +2 -2
  45. package/dist/mssql.js +27 -5
  46. package/dist/mysql.js +4 -0
  47. package/dist/powdb.d.ts +135 -9
  48. package/dist/powdb.js +197 -25
  49. package/dist/powql.d.ts +166 -4
  50. package/dist/powql.js +516 -52
  51. package/dist/query/aggregates.d.ts +74 -0
  52. package/dist/query/aggregates.js +641 -0
  53. package/dist/query/batched-loader.d.ts +6 -0
  54. package/dist/query/batched-loader.js +2 -0
  55. package/dist/query/builder.d.ts +98 -830
  56. package/dist/query/builder.js +366 -4513
  57. package/dist/query/deferred.d.ts +13 -2
  58. package/dist/query/filters.d.ts +7 -0
  59. package/dist/query/filters.js +11 -0
  60. package/dist/query/relations.d.ts +441 -0
  61. package/dist/query/relations.js +1627 -0
  62. package/dist/query/types.d.ts +25 -6
  63. package/dist/query/where-compile.d.ts +139 -0
  64. package/dist/query/where-compile.js +175 -0
  65. package/dist/query/where.d.ts +494 -0
  66. package/dist/query/where.js +1431 -0
  67. package/dist/query/writes.d.ts +131 -0
  68. package/dist/query/writes.js +626 -0
  69. package/dist/schema-builder.d.ts +18 -3
  70. package/dist/schema-builder.js +6 -0
  71. package/dist/schema-metadata.js +4 -0
  72. package/dist/schema-sql.d.ts +60 -3
  73. package/dist/schema-sql.js +261 -4
  74. package/dist/schema.d.ts +10 -0
  75. package/dist/sqlite.js +4 -1
  76. package/package.json +4 -4
@@ -0,0 +1,626 @@
1
+ /**
2
+ * turbine-orm: write compilation (extracted from builder.ts)
3
+ *
4
+ * SQL builders for the mutating operations (create / createMany / update /
5
+ * delete / upsert / updateMany / deleteMany) plus the write-projection helpers
6
+ * (writeReturningColumns / writeReselectSelection / parseWriteRow, the PII
7
+ * column set, optimistic-lock and atomic-operator SET clauses). All functions
8
+ * take a {@link BuilderCtx} first argument; WHERE compilation is reused from
9
+ * where.ts (via `whereMod`), and the cache / dialect / row-parse primitives
10
+ * stay class-resident, reached through the ctx. See builder.ts for the thin
11
+ * delegating methods and the async execute wrappers.
12
+ */
13
+ import { NotFoundError, OptimisticLockError, ValidationError } from '../errors.js';
14
+ import { camelToSnake, snakeToCamel } from '../schema.js';
15
+ import { UPDATE_OPERATOR_KEYS } from './filters.js';
16
+ import * as whereMod from './where.js';
17
+ /**
18
+ * Build a `SELECT * ... WHERE <predicate>` that re-fetches the row(s) matched
19
+ * by a write's `where` clause. Used by the `'reselect'` result strategy to
20
+ * return rows from non-RETURNING engines. Reuses the same parameterized WHERE
21
+ * builder as reads, so no user value is interpolated.
22
+ */
23
+ export function buildReselectByWhere(qi, whereObj) {
24
+ const params = [];
25
+ const clause = whereMod.buildWhereClause(qi, whereObj, params);
26
+ const where = clause ? ` WHERE ${clause}` : '';
27
+ return { sql: `SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, params };
28
+ }
29
+ export function buildCreate(qi, args) {
30
+ assertWritable(qi, 'create');
31
+ assertNoGeneratedColumns(qi, args.data, 'create');
32
+ const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
33
+ const columns = entries.map(([k]) => qi.toSqlColumn(k));
34
+ const params = entries.map(([, v]) => v);
35
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
36
+ const placeholders = entries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
37
+ const sql = qi.dialect.buildInsertStatement({
38
+ table: qi.q(qi.table),
39
+ columns,
40
+ valuePlaceholders: placeholders,
41
+ returning: writeReturningColumns(qi),
42
+ });
43
+ return {
44
+ sql,
45
+ params,
46
+ transform: (result) => {
47
+ const row = result.rows[0];
48
+ if (!row) {
49
+ throw new NotFoundError({
50
+ table: qi.table,
51
+ operation: 'create',
52
+ message: `[turbine] create on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
53
+ });
54
+ }
55
+ return parseWriteRow(qi, row);
56
+ },
57
+ tag: `${qi.table}.create`,
58
+ // Non-RETURNING engines: INSERT, then re-fetch the new row by primary key
59
+ // (provided value, else the driver's generated insert id).
60
+ reselect: makeCreateReselect(qi, sql, params, args.data),
61
+ };
62
+ }
63
+ /**
64
+ * Build the `'reselect'` plan for {@link buildCreate}: run the INSERT, then
65
+ * `SELECT * WHERE pk = ?`. Returns `undefined` (skipped) unless the active
66
+ * dialect's result strategy is `'reselect'`, so the PostgreSQL/RETURNING path
67
+ * pays nothing. Not yet wired to a real non-RETURNING engine.
68
+ */
69
+ export function makeCreateReselect(qi, insertSql, insertParams, data) {
70
+ if (qi.dialect.resultStrategy !== 'reselect')
71
+ return undefined;
72
+ return async (exec) => {
73
+ const writeResult = await exec(insertSql, insertParams);
74
+ const insertId = qi.mutationInsertId(writeResult);
75
+ const conds = [];
76
+ const selParams = [];
77
+ let idx = 1;
78
+ for (const pk of qi.tableMeta.primaryKey) {
79
+ const field = qi.tableMeta.reverseColumnMap[pk] ?? snakeToCamel(pk);
80
+ selParams.push(data[field] ?? data[pk] ?? insertId);
81
+ conds.push(`${qi.q(pk)} = ${qi.p(idx++)}`);
82
+ }
83
+ const where = conds.length > 0 ? ` WHERE ${conds.join(' AND ')}` : '';
84
+ return exec(`SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, selParams);
85
+ };
86
+ }
87
+ export function buildCreateMany(qi, args) {
88
+ const qt = qi.q(qi.table);
89
+ if (args.data.length === 0) {
90
+ return {
91
+ sql: `SELECT * FROM ${qt} WHERE false`,
92
+ params: [],
93
+ transform: () => [],
94
+ tag: `${qi.table}.createMany`,
95
+ };
96
+ }
97
+ assertWritable(qi, 'createMany');
98
+ for (const row of args.data) {
99
+ assertNoGeneratedColumns(qi, row, 'createMany');
100
+ }
101
+ const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
102
+ const columns = keys.map((k) => qi.toColumn(k));
103
+ const rowValues = args.data.map((row) => {
104
+ const record = row;
105
+ return keys.map((key) => record[key]);
106
+ });
107
+ // Use actual Postgres types for array casts in the default PostgreSQL dialect.
108
+ // Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
109
+ // type the UNNEST output as text, which Postgres refuses to coerce to the
110
+ // enum ("column X is of type Y but expression is of type text").
111
+ const typeCasts = columns.map((col) => {
112
+ const enumType = whereMod.enumTypeForColumn(qi, col);
113
+ return enumType ? `${qi.q(enumType)}[]` : whereMod.getColumnArrayType(qi, col);
114
+ });
115
+ const quotedColumns = columns.map((c) => qi.q(c));
116
+ const built = qi.dialect.buildBulkInsertStatement({
117
+ table: qt,
118
+ columns: quotedColumns,
119
+ rowValues,
120
+ columnArrayTypes: typeCasts,
121
+ skipDuplicates: args.skipDuplicates,
122
+ returning: writeReturningColumns(qi),
123
+ });
124
+ return {
125
+ sql: built.sql,
126
+ params: built.params,
127
+ transform: (result) => result.rows.map((row) => parseWriteRow(qi, row)),
128
+ tag: `${qi.table}.createMany`,
129
+ };
130
+ }
131
+ export function buildUpdate(qi, args) {
132
+ assertWritable(qi, 'update');
133
+ qi.currentSkip = args.skipGlobalFilters;
134
+ const dataObj = args.data;
135
+ assertNoGeneratedColumns(qi, dataObj, 'update');
136
+ const userWhere = args.where;
137
+ const lock = args.optimisticLock;
138
+ // The empty-`where` guard checks the USER predicate only — a global filter
139
+ // must never turn an unguarded mass update into an allowed one.
140
+ const userHasPredicate = !whereMod.userPredicateIsEmpty(qi, userWhere) || !!lock;
141
+ whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
142
+ // The SQL is built from the global-filter-merged where (soft-delete keeps an
143
+ // update from touching already-deleted rows).
144
+ const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
145
+ const setFp = fingerprintSet(qi, dataObj);
146
+ const whereFp = whereMod.fingerprintWhere(qi, whereObj);
147
+ const ck = lock ? null : `u:${setFp}|${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
148
+ const params = [];
149
+ const buildSql = (freshParams) => {
150
+ const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
151
+ const setClauses = setEntries.map(([k, v]) => buildSetClause(qi, k, v, freshParams));
152
+ if (lock) {
153
+ const versionCol = qi.toSqlColumn(lock.field);
154
+ setClauses.push(`${versionCol} = ${versionCol} + 1`);
155
+ }
156
+ const whereClause = whereMod.buildWhereClause(qi, whereObj, freshParams);
157
+ let whereSql = whereClause ? ` WHERE ${whereClause}` : '';
158
+ if (lock) {
159
+ const versionCol = qi.toSqlColumn(lock.field);
160
+ freshParams.push(lock.expected);
161
+ const versionCheck = `${versionCol} = ${qi.p(freshParams.length)}`;
162
+ whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
163
+ }
164
+ // Engines that inject their returning shape MID-statement (SQL Server
165
+ // `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
166
+ // absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
167
+ // `returning` excludes PII columns on tagged tables (else '*').
168
+ const returning = writeReturningColumns(qi);
169
+ return qi.dialect.buildUpdateStatement
170
+ ? qi.dialect.buildUpdateStatement({ table: qi.q(qi.table), setClauses, whereSql, returning })
171
+ : `UPDATE ${qi.q(qi.table)} SET ${setClauses.join(', ')}${whereSql}${qi.dialect.buildReturningClause(returning)}`;
172
+ };
173
+ let sql;
174
+ let preparedName;
175
+ let cacheEntry;
176
+ if (ck) {
177
+ cacheEntry = qi.acquireSql(ck, buildSql);
178
+ sql = cacheEntry.sql;
179
+ preparedName = cacheEntry.name;
180
+ }
181
+ else {
182
+ // optimisticLock path: value-variant version check → uncacheable, no cross-check.
183
+ sql = buildSql([]);
184
+ }
185
+ // Collect params: SET first, then WHERE, then version check (same order as fresh build)
186
+ collectSetParams(qi, dataObj, params);
187
+ whereMod.collectWhereParams(qi, whereObj, params);
188
+ if (lock) {
189
+ params.push(lock.expected);
190
+ }
191
+ if (ck && cacheEntry) {
192
+ qi.crossCheckCache('update', ck, cacheEntry, buildSql, params);
193
+ }
194
+ return {
195
+ sql,
196
+ params,
197
+ transform: (result) => {
198
+ const row = result.rows[0];
199
+ if (!row) {
200
+ if (lock) {
201
+ throw new OptimisticLockError({
202
+ table: qi.table,
203
+ versionField: lock.field,
204
+ expectedVersion: lock.expected,
205
+ });
206
+ }
207
+ throw new NotFoundError({
208
+ table: qi.table,
209
+ where: args.where,
210
+ operation: 'update',
211
+ });
212
+ }
213
+ return parseWriteRow(qi, row);
214
+ },
215
+ tag: `${qi.table}.update`,
216
+ preparedName,
217
+ // Non-RETURNING engines: UPDATE, then re-fetch the row by the same where.
218
+ reselect: qi.dialect.resultStrategy === 'reselect'
219
+ ? async (exec) => {
220
+ const writeResult = await exec(sql, params, preparedName);
221
+ // Optimistic-lock conflict: the version-checked UPDATE matched no
222
+ // row. The re-fetch below uses `where` WITHOUT the version
223
+ // predicate, so it would return the stale row and silently mask
224
+ // the conflict — detect it from affected-rows here instead, to
225
+ // match the OptimisticLockError thrown on RETURNING/OUTPUT engines.
226
+ if (lock && (writeResult.rowCount ?? 0) === 0) {
227
+ throw new OptimisticLockError({
228
+ table: qi.table,
229
+ versionField: lock.field,
230
+ expectedVersion: lock.expected,
231
+ });
232
+ }
233
+ const sel = buildReselectByWhere(qi, whereObj);
234
+ return exec(sel.sql, sel.params);
235
+ }
236
+ : undefined,
237
+ };
238
+ }
239
+ export function buildDelete(qi, args) {
240
+ assertWritable(qi, 'delete');
241
+ qi.currentSkip = args.skipGlobalFilters;
242
+ // Guard the USER predicate (a global filter must not satisfy the guard).
243
+ whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
244
+ const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
245
+ const whereFp = whereMod.fingerprintWhere(qi, whereObj);
246
+ const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
247
+ const params = [];
248
+ const buildSql = (freshParams) => {
249
+ const clause = whereMod.buildWhereClause(qi, whereObj, freshParams);
250
+ const whereSql = clause ? ` WHERE ${clause}` : '';
251
+ // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
252
+ // absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
253
+ // `returning` excludes PII columns on tagged tables (else '*').
254
+ const returning = writeReturningColumns(qi);
255
+ return qi.dialect.buildDeleteStatement
256
+ ? qi.dialect.buildDeleteStatement({ table: qi.q(qi.table), whereSql, returning })
257
+ : `DELETE FROM ${qi.q(qi.table)}${whereSql}${qi.dialect.buildReturningClause(returning)}`;
258
+ };
259
+ const entry = qi.acquireSql(ck, buildSql);
260
+ whereMod.collectWhereParams(qi, whereObj, params);
261
+ qi.crossCheckCache('delete', ck, entry, buildSql, params);
262
+ return {
263
+ sql: entry.sql,
264
+ params,
265
+ transform: (result) => {
266
+ const row = result.rows[0];
267
+ if (!row) {
268
+ throw new NotFoundError({
269
+ table: qi.table,
270
+ where: args.where,
271
+ operation: 'delete',
272
+ });
273
+ }
274
+ return parseWriteRow(qi, row);
275
+ },
276
+ tag: `${qi.table}.delete`,
277
+ preparedName: entry.name,
278
+ // Non-RETURNING engines: the row is gone after DELETE, so pre-SELECT it
279
+ // by the same where, then run the DELETE, returning the captured row.
280
+ reselect: qi.dialect.resultStrategy === 'reselect'
281
+ ? async (exec) => {
282
+ const sel = buildReselectByWhere(qi, whereObj);
283
+ const pre = await exec(sel.sql, sel.params);
284
+ await exec(entry.sql, params, entry.name);
285
+ return pre;
286
+ }
287
+ : undefined,
288
+ };
289
+ }
290
+ export function buildUpsert(qi, args) {
291
+ assertWritable(qi, 'upsert');
292
+ assertNoGeneratedColumns(qi, args.create, 'upsert');
293
+ assertNoGeneratedColumns(qi, args.update, 'upsert');
294
+ qi.currentSkip = args.skipGlobalFilters;
295
+ // Build the INSERT part from create data
296
+ const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
297
+ const columns = createEntries.map(([k]) => qi.toSqlColumn(k));
298
+ const createParams = createEntries.map(([, v]) => v);
299
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
300
+ const placeholders = createEntries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
301
+ // The conflict target comes from `where` keys — must be unique/PK columns
302
+ const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
303
+ const conflictColumns = conflictKeys.map((k) => qi.toSqlColumn(k));
304
+ // Build the UPDATE SET part
305
+ const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
306
+ let paramIdx = createParams.length + 1;
307
+ const setClauses = updateEntries.map(([k]) => {
308
+ const clause = `${qi.toSqlColumn(k)} = ${qi.p(paramIdx)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`;
309
+ paramIdx++;
310
+ return clause;
311
+ });
312
+ const updateParams = updateEntries.map(([, v]) => v);
313
+ const params = [...createParams, ...updateParams];
314
+ // Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
315
+ // upsert never resurrects a soft-deleted row or writes across tenants. Only
316
+ // on engines whose upsert can carry a predicate (Postgres); the gf params
317
+ // continue the placeholder numbering after create+update params.
318
+ let updateWhere;
319
+ if (qi.dialect.supportsUpsertUpdateWhere) {
320
+ const gf = whereMod.resolveGlobalFilter(qi, qi.table);
321
+ if (gf)
322
+ updateWhere = whereMod.buildWhereClause(qi, gf, params) ?? undefined;
323
+ }
324
+ const sql = qi.dialect.buildUpsertStatement({
325
+ table: qi.q(qi.table),
326
+ insertColumns: columns,
327
+ valuePlaceholders: placeholders,
328
+ conflictColumns,
329
+ updateSetClauses: setClauses,
330
+ updateWhere,
331
+ returning: writeReturningColumns(qi),
332
+ });
333
+ return {
334
+ sql,
335
+ params,
336
+ transform: (result) => {
337
+ const row = result.rows[0];
338
+ if (!row) {
339
+ throw new NotFoundError({
340
+ table: qi.table,
341
+ where: args.where,
342
+ operation: 'upsert',
343
+ message: `[turbine] upsert on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
344
+ });
345
+ }
346
+ return parseWriteRow(qi, row);
347
+ },
348
+ tag: `${qi.table}.upsert`,
349
+ // Non-RETURNING engines: run the upsert, then re-fetch by the where keys.
350
+ reselect: qi.dialect.resultStrategy === 'reselect'
351
+ ? async (exec) => {
352
+ await exec(sql, params);
353
+ const sel = buildReselectByWhere(qi, (whereMod.mergeGlobalFilter(qi, args.where) ?? {}));
354
+ return exec(sel.sql, sel.params);
355
+ }
356
+ : undefined,
357
+ };
358
+ }
359
+ export function buildUpdateMany(qi, args) {
360
+ assertWritable(qi, 'updateMany');
361
+ qi.currentSkip = args.skipGlobalFilters;
362
+ const dataObj = args.data;
363
+ assertNoGeneratedColumns(qi, dataObj, 'updateMany');
364
+ whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
365
+ const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
366
+ const setFp = fingerprintSet(qi, dataObj);
367
+ const whereFp = whereMod.fingerprintWhere(qi, whereObj);
368
+ const ck = `um:${setFp}|${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
369
+ const params = [];
370
+ const buildSql = (freshParams) => {
371
+ const setEntries = Object.entries(dataObj).filter(([, v]) => v !== undefined);
372
+ const setClauses = setEntries.map(([k, v]) => buildSetClause(qi, k, v, freshParams));
373
+ const whereClause = whereMod.buildWhereClause(qi, whereObj, freshParams);
374
+ const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
375
+ return `UPDATE ${qi.q(qi.table)} SET ${setClauses.join(', ')}${whereSql}`;
376
+ };
377
+ const entry = qi.acquireSql(ck, buildSql);
378
+ collectSetParams(qi, dataObj, params);
379
+ whereMod.collectWhereParams(qi, whereObj, params);
380
+ qi.crossCheckCache('updateMany', ck, entry, buildSql, params);
381
+ return {
382
+ sql: entry.sql,
383
+ params,
384
+ transform: (result) => ({ count: result.rowCount ?? 0 }),
385
+ tag: `${qi.table}.updateMany`,
386
+ preparedName: entry.name,
387
+ };
388
+ }
389
+ export function buildDeleteMany(qi, args) {
390
+ assertWritable(qi, 'deleteMany');
391
+ qi.currentSkip = args.skipGlobalFilters;
392
+ whereMod.assertMutationHasPredicate(qi, 'deleteMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
393
+ const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
394
+ const whereFp = whereMod.fingerprintWhere(qi, whereObj);
395
+ const ck = `dm:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
396
+ const params = [];
397
+ const buildSql = (freshParams) => {
398
+ const clause = whereMod.buildWhereClause(qi, whereObj, freshParams);
399
+ const whereSql = clause ? ` WHERE ${clause}` : '';
400
+ return `DELETE FROM ${qi.q(qi.table)}${whereSql}`;
401
+ };
402
+ const entry = qi.acquireSql(ck, buildSql);
403
+ whereMod.collectWhereParams(qi, whereObj, params);
404
+ qi.crossCheckCache('deleteMany', ck, entry, buildSql, params);
405
+ return {
406
+ sql: entry.sql,
407
+ params,
408
+ transform: (result) => ({ count: result.rowCount ?? 0 }),
409
+ tag: `${qi.table}.deleteMany`,
410
+ preparedName: entry.name,
411
+ };
412
+ }
413
+ /**
414
+ * The snake_case names of a table's PII-tagged (`defineSchema` `pii: true`)
415
+ * columns. PII columns are excluded from default projections (findMany /
416
+ * findUnique / relation subqueries / batched loads) unless the query opts in
417
+ * via `includePii` or names the column explicitly in `select`. Returns an
418
+ * empty set for any table with no PII column, so untagged schemas keep their
419
+ * byte-identical SQL.
420
+ */
421
+ export function piiColumns(_qi, meta) {
422
+ const out = new Set();
423
+ for (const col of meta.columns) {
424
+ if (col.pii)
425
+ out.add(col.name);
426
+ }
427
+ return out;
428
+ }
429
+ /**
430
+ * The camelCase field names of a table's PII-tagged columns: the read-side
431
+ * counterpart of {@link piiColumns} applied to already-parsed entities.
432
+ * Used to strip PII from a write's RETURNING/reselect row (writes accept no
433
+ * `includePii`/`select`, so their returned row always applies the default
434
+ * exclusion; you may still write PII fields freely).
435
+ */
436
+ export function piiFields(_qi, meta) {
437
+ const out = [];
438
+ for (const col of meta.columns) {
439
+ if (col.pii)
440
+ out.push(col.field);
441
+ }
442
+ return out;
443
+ }
444
+ /**
445
+ * The `RETURNING` / `OUTPUT` selection for a write on this table. A table with
446
+ * no PII column returns `'*'` (every column — byte-identical SQL to before);
447
+ * a table WITH PII columns returns an explicit quoted list of every non-PII
448
+ * column so the PII values never leave the database on a write. A PII-tagged
449
+ * PRIMARY KEY column is kept in the projection regardless (the returned row
450
+ * must stay addressable): tag sensitive data, not keys — a PII PK is
451
+ * documented out of scope for stripping. Writes accept no `select`/`includePii`
452
+ * (unlike reads), so this is the whole write-return policy at the SQL level;
453
+ * {@link parseWriteRow} remains as a defense-in-depth strip (a no-op once the
454
+ * SQL already excludes the columns). Derived purely from static per-table
455
+ * schema metadata, so the write SQL cache needs no extra key segment.
456
+ */
457
+ export function writeReturningColumns(qi) {
458
+ const piiCols = piiColumns(qi, qi.tableMeta);
459
+ if (piiCols.size === 0)
460
+ return '*';
461
+ const pk = new Set(qi.tableMeta.primaryKey);
462
+ return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col) || pk.has(col)).map((col) => qi.q(col));
463
+ }
464
+ /**
465
+ * String form of {@link writeReturningColumns} for a `SELECT` list (the
466
+ * `'reselect'` result strategy re-fetches via a SELECT, not RETURNING).
467
+ * `'*'` when there is no PII column; otherwise the comma-joined quoted list.
468
+ */
469
+ export function writeReselectSelection(qi) {
470
+ const cols = writeReturningColumns(qi);
471
+ return cols === '*' ? '*' : cols.join(', ');
472
+ }
473
+ /**
474
+ * Parse a write's returned row (create/update/upsert/delete), then strip the
475
+ * table's PII fields: the write-side read policy. On PII-tagged tables the
476
+ * statement's RETURNING/OUTPUT already omits these columns (see
477
+ * {@link writeReturningColumns}), so this strip is defense-in-depth and a
478
+ * no-op. Untagged tables incur only one `for` over a zero-length field list,
479
+ * so behavior is unchanged.
480
+ */
481
+ export function parseWriteRow(qi, row) {
482
+ const parsed = qi.parseRow(row, qi.table);
483
+ for (const field of piiFields(qi, qi.tableMeta)) {
484
+ delete parsed[field];
485
+ }
486
+ return parsed;
487
+ }
488
+ /**
489
+ * Reject any write against a view (H4). Views are introspected with
490
+ * `isView: true` and are read-only in every engine; a write raises a
491
+ * {@link ValidationError} (E003) rather than emitting SQL Postgres would
492
+ * reject (or, worse, silently applying to an updatable view).
493
+ */
494
+ export function assertWritable(qi, operation) {
495
+ if (qi.tableMeta.isView) {
496
+ throw new ValidationError(`[turbine] Cannot ${operation} "${qi.table}": it is a view (read-only). ` +
497
+ 'Views support reads (findMany/findFirst/…) but not writes.');
498
+ }
499
+ }
500
+ /**
501
+ * Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
502
+ * column (H3). Postgres computes these from other columns and errors if you
503
+ * try to write them; we fail early with a clear {@link ValidationError} (E003)
504
+ * instead of surfacing a cryptic driver error. Undefined values are ignored
505
+ * (they're stripped from the statement anyway).
506
+ */
507
+ export function assertNoGeneratedColumns(qi, data, operation) {
508
+ for (const [key, value] of Object.entries(data)) {
509
+ if (value === undefined)
510
+ continue;
511
+ const col = qi.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
512
+ if (col?.isGeneratedStored) {
513
+ throw new ValidationError(`[turbine] Cannot ${operation} "${qi.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
514
+ 'column whose value the database computes — remove it from your data.');
515
+ }
516
+ }
517
+ }
518
+ /**
519
+ * Build a single SET clause entry for update/updateMany.
520
+ *
521
+ * Supports plain values and atomic operator objects ({ set, increment,
522
+ * decrement, multiply, divide }). An operator object is detected ONLY when
523
+ * it has EXACTLY one key that is one of the 5 operator keys — this avoids
524
+ * misinterpreting JSON column values like `{ set: 'x' }` as operators
525
+ * (real operator objects always have exactly one key, and a plain JSON
526
+ * payload that happens to have a single `set` key is extremely unusual).
527
+ * Multi-key objects are always treated as plain (JSON) values.
528
+ *
529
+ * Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
530
+ * pushes any required params onto the shared params array so that WHERE
531
+ * clause numbering continues correctly afterward.
532
+ */
533
+ export function buildSetClause(qi, key, value, params) {
534
+ const col = qi.toSqlColumn(key);
535
+ // Enum columns get an explicit `::"EnumName"` cast on their value bind
536
+ // (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
537
+ // SQL cache and collectSetParams are unaffected.
538
+ const cast = whereMod.enumCastSuffix(qi, qi.toColumn(key));
539
+ // Detect atomic-operator object: plain object (not null, not array, not
540
+ // Date, not Buffer) with EXACTLY one key matching an operator name.
541
+ if (value !== null &&
542
+ typeof value === 'object' &&
543
+ !Array.isArray(value) &&
544
+ !(value instanceof Date) &&
545
+ !Buffer.isBuffer(value)) {
546
+ const v = value;
547
+ const keys = Object.keys(v);
548
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
549
+ const op = keys[0];
550
+ const opValue = v[op];
551
+ if (op === 'set') {
552
+ params.push(opValue);
553
+ return `${col} = ${qi.p(params.length)}${cast}`;
554
+ }
555
+ // Arithmetic operators: must be finite numbers
556
+ if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
557
+ throw new ValidationError(`[turbine] update operator "${op}" on "${qi.table}.${key}" requires a finite number, got ${typeof opValue}`);
558
+ }
559
+ if (op === 'increment') {
560
+ params.push(opValue);
561
+ return `${col} = ${col} + ${qi.p(params.length)}`;
562
+ }
563
+ if (op === 'decrement') {
564
+ params.push(opValue);
565
+ return `${col} = ${col} - ${qi.p(params.length)}`;
566
+ }
567
+ if (op === 'multiply') {
568
+ params.push(opValue);
569
+ return `${col} = ${col} * ${qi.p(params.length)}`;
570
+ }
571
+ if (op === 'divide') {
572
+ params.push(opValue);
573
+ return `${col} = ${col} / ${qi.p(params.length)}`;
574
+ }
575
+ }
576
+ // Fall through: multi-key objects or non-operator single-key objects
577
+ // are treated as plain values (e.g., JSONB column payloads).
578
+ }
579
+ // Plain value (including null, Date, Buffer, arrays, JSON objects)
580
+ params.push(value);
581
+ return `${col} = ${qi.p(params.length)}${cast}`;
582
+ }
583
+ /**
584
+ * Fingerprint SET clauses for update/updateMany.
585
+ * Captures key names + operator types (set/increment/etc) but not values.
586
+ */
587
+ export function fingerprintSet(_qi, data) {
588
+ const entries = Object.entries(data).filter(([, v]) => v !== undefined);
589
+ const parts = [];
590
+ for (const [k, v] of entries) {
591
+ if (v !== null &&
592
+ typeof v === 'object' &&
593
+ !Array.isArray(v) &&
594
+ !(v instanceof Date) &&
595
+ !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
596
+ const keys = Object.keys(v);
597
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
598
+ parts.push(`${k}:${keys[0]}`);
599
+ continue;
600
+ }
601
+ }
602
+ parts.push(`${k}:eq`);
603
+ }
604
+ return parts.join(',');
605
+ }
606
+ /**
607
+ * Collect SET params for update/updateMany. Mirrors buildSetClause param order.
608
+ */
609
+ export function collectSetParams(_qi, data, params) {
610
+ const entries = Object.entries(data).filter(([, v]) => v !== undefined);
611
+ for (const [, v] of entries) {
612
+ if (v !== null &&
613
+ typeof v === 'object' &&
614
+ !Array.isArray(v) &&
615
+ !(v instanceof Date) &&
616
+ !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
617
+ const obj = v;
618
+ const keys = Object.keys(obj);
619
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
620
+ params.push(obj[keys[0]]);
621
+ continue;
622
+ }
623
+ }
624
+ params.push(v);
625
+ }
626
+ }
@@ -64,6 +64,15 @@ export interface ColumnDef {
64
64
  array?: boolean;
65
65
  /** Column-level `CHECK` expression (raw SQL, e.g. `price >= 0`). */
66
66
  check?: string;
67
+ /**
68
+ * Marks this column as personally identifiable information (PII). Purely a
69
+ * code-first declaration: it carries onto {@link ColumnConfig.pii} and, via
70
+ * `schemaDefToMetadata` / codegen, onto
71
+ * {@link import('./schema.js').ColumnMetadata.pii}. A PII column is excluded
72
+ * from default projections (read back only via an explicit `select` or
73
+ * `includePii: true`) and redacted by Studio. Introspection never auto-tags PII.
74
+ */
75
+ pii?: boolean;
67
76
  }
68
77
  /** Postgres-level column type (uppercase, as used in DDL) */
69
78
  export type ColumnType = 'SERIAL' | 'BIGSERIAL' | 'BIGINT' | 'INTEGER' | 'SMALLINT' | 'TEXT' | 'BOOLEAN' | 'TIMESTAMPTZ' | 'JSONB' | 'UUID' | 'REAL' | 'DOUBLE PRECISION' | 'NUMERIC' | 'BYTEA' | 'DATE' | 'VARCHAR' | 'ENUM' | 'VECTOR';
@@ -88,6 +97,8 @@ export interface ColumnConfig {
88
97
  isArray: boolean;
89
98
  /** Column-level `CHECK` expression, or null. */
90
99
  check: string | null;
100
+ /** Whether this column is tagged as PII (personally identifiable information). */
101
+ pii: boolean;
91
102
  }
92
103
  /**
93
104
  * Explicit many-to-many relation declaration for the code-first schema.
@@ -155,9 +166,12 @@ export interface DocFieldIndexDef {
155
166
  * ({@link ColumnIndexDef}) or a doc-field expression index into a json column
156
167
  * ({@link DocFieldIndexDef}).
157
168
  *
158
- * Consumed today by the PowDB DDL generator (`powqlSchemaDDL`) and carried onto
159
- * {@link import('./schema.js').IndexMetadata} by `schemaDefToMetadata`. The SQL
160
- * DDL generators (`schema-sql.ts` / `schemaDiff`) do NOT consume these yet.
169
+ * Consumed by the PowDB DDL generator (`powqlSchemaDDL`), carried onto
170
+ * {@link import('./schema.js').IndexMetadata} by `schemaDefToMetadata`, and
171
+ * since 0.36 emitted as `CREATE [UNIQUE] INDEX` by the SQL DDL generators:
172
+ * `schemaToSQL` emits plain column-list indexes, and `schemaDiff` adds ones
173
+ * missing from the live database (matching by name; it warns on definition
174
+ * mismatches and never drops). Doc-field indexes stay PowDB-only.
161
175
  */
162
176
  export type SchemaIndexDef = ColumnIndexDef | DocFieldIndexDef;
163
177
  /** Type guard: is this index declaration a doc-field expression index? */
@@ -296,6 +310,7 @@ export declare class ColumnBuilder {
296
310
  onUpdate?: ReferentialAction;
297
311
  }): this;
298
312
  check(expression: string): this;
313
+ pii(): this;
299
314
  array(): this;
300
315
  build(): ColumnConfig;
301
316
  }