turbine-orm 0.50.0 → 0.51.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 (186) hide show
  1. package/README.md +66 -66
  2. package/dist/adapters/cockroachdb.d.ts +5 -5
  3. package/dist/adapters/cockroachdb.js +10 -10
  4. package/dist/adapters/index.d.ts +5 -5
  5. package/dist/adapters/index.js +7 -7
  6. package/dist/adapters/yugabytedb.d.ts +7 -7
  7. package/dist/adapters/yugabytedb.js +10 -10
  8. package/dist/cjs/adapters/cockroachdb.d.ts +5 -5
  9. package/dist/cjs/adapters/cockroachdb.js +10 -10
  10. package/dist/cjs/adapters/index.d.ts +5 -5
  11. package/dist/cjs/adapters/index.js +7 -7
  12. package/dist/cjs/adapters/yugabytedb.d.ts +7 -7
  13. package/dist/cjs/adapters/yugabytedb.js +10 -10
  14. package/dist/cjs/cli/config.d.ts +13 -2
  15. package/dist/cjs/cli/config.js +3 -2
  16. package/dist/cjs/cli/destructive.d.ts +1 -1
  17. package/dist/cjs/cli/destructive.js +1 -1
  18. package/dist/cjs/cli/index.d.ts +10 -10
  19. package/dist/cjs/cli/index.js +49 -45
  20. package/dist/cjs/cli/loader.d.ts +7 -7
  21. package/dist/cjs/cli/loader.js +9 -9
  22. package/dist/cjs/cli/mcp.js +4 -4
  23. package/dist/cjs/cli/migrate.d.ts +5 -5
  24. package/dist/cjs/cli/migrate.js +11 -11
  25. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  26. package/dist/cjs/cli/ui.d.ts +2 -2
  27. package/dist/cjs/cli/ui.js +2 -2
  28. package/dist/cjs/client.d.ts +49 -38
  29. package/dist/cjs/client.js +57 -56
  30. package/dist/cjs/dialect.d.ts +62 -18
  31. package/dist/cjs/dialect.js +40 -2
  32. package/dist/cjs/errors.d.ts +5 -5
  33. package/dist/cjs/errors.js +11 -11
  34. package/dist/cjs/generate.d.ts +6 -6
  35. package/dist/cjs/generate.js +31 -29
  36. package/dist/cjs/index-advisor.d.ts +5 -5
  37. package/dist/cjs/index-advisor.js +0 -0
  38. package/dist/cjs/index.d.ts +1 -1
  39. package/dist/cjs/index.js +7 -7
  40. package/dist/cjs/introspect.d.ts +35 -9
  41. package/dist/cjs/introspect.js +83 -32
  42. package/dist/cjs/mssql.d.ts +11 -11
  43. package/dist/cjs/mssql.js +64 -29
  44. package/dist/cjs/mysql.d.ts +8 -8
  45. package/dist/cjs/mysql.js +61 -23
  46. package/dist/cjs/nested-write.d.ts +21 -2
  47. package/dist/cjs/nested-write.js +51 -14
  48. package/dist/cjs/optional-peer-import.cjs +7 -7
  49. package/dist/cjs/optional-peer-import.d.cts +7 -7
  50. package/dist/cjs/pipeline-submittable.d.ts +2 -2
  51. package/dist/cjs/pipeline-submittable.js +6 -6
  52. package/dist/cjs/pipeline.d.ts +1 -1
  53. package/dist/cjs/pipeline.js +4 -4
  54. package/dist/cjs/powdb-introspect.d.ts +1 -1
  55. package/dist/cjs/powdb-introspect.js +1 -1
  56. package/dist/cjs/powdb.d.ts +28 -28
  57. package/dist/cjs/powdb.js +66 -66
  58. package/dist/cjs/powql.d.ts +27 -27
  59. package/dist/cjs/powql.js +73 -52
  60. package/dist/cjs/query/aggregates.d.ts +1 -1
  61. package/dist/cjs/query/aggregates.js +5 -5
  62. package/dist/cjs/query/batched-loader.d.ts +11 -11
  63. package/dist/cjs/query/batched-loader.js +24 -24
  64. package/dist/cjs/query/builder.d.ts +39 -21
  65. package/dist/cjs/query/builder.js +99 -57
  66. package/dist/cjs/query/compound-unique.d.ts +1 -1
  67. package/dist/cjs/query/compound-unique.js +0 -0
  68. package/dist/cjs/query/deferred.d.ts +12 -6
  69. package/dist/cjs/query/deferred.js +1 -1
  70. package/dist/cjs/query/filters.d.ts +31 -11
  71. package/dist/cjs/query/filters.js +67 -14
  72. package/dist/cjs/query/index.d.ts +1 -1
  73. package/dist/cjs/query/index.js +1 -1
  74. package/dist/cjs/query/relations.d.ts +9 -9
  75. package/dist/cjs/query/relations.js +164 -57
  76. package/dist/cjs/query/types.d.ts +86 -35
  77. package/dist/cjs/query/types.js +1 -1
  78. package/dist/cjs/query/utils.d.ts +27 -10
  79. package/dist/cjs/query/utils.js +86 -14
  80. package/dist/cjs/query/where.d.ts +47 -28
  81. package/dist/cjs/query/where.js +130 -31
  82. package/dist/cjs/query/writes.d.ts +24 -5
  83. package/dist/cjs/query/writes.js +102 -13
  84. package/dist/cjs/realtime.d.ts +7 -7
  85. package/dist/cjs/realtime.js +9 -9
  86. package/dist/cjs/schema-builder.d.ts +18 -7
  87. package/dist/cjs/schema-builder.js +17 -10
  88. package/dist/cjs/schema-metadata.d.ts +3 -3
  89. package/dist/cjs/schema-metadata.js +9 -9
  90. package/dist/cjs/schema-sql.d.ts +9 -9
  91. package/dist/cjs/schema-sql.js +20 -20
  92. package/dist/cjs/schema.d.ts +19 -9
  93. package/dist/cjs/schema.js +6 -6
  94. package/dist/cjs/serverless.d.ts +15 -15
  95. package/dist/cjs/serverless.js +16 -16
  96. package/dist/cjs/sqlite.d.ts +8 -8
  97. package/dist/cjs/sqlite.js +53 -22
  98. package/dist/cjs/typed-sql.d.ts +4 -4
  99. package/dist/cjs/typed-sql.js +5 -5
  100. package/dist/cli/config.d.ts +13 -2
  101. package/dist/cli/config.js +3 -2
  102. package/dist/cli/destructive.d.ts +1 -1
  103. package/dist/cli/destructive.js +1 -1
  104. package/dist/cli/index.d.ts +10 -10
  105. package/dist/cli/index.js +49 -45
  106. package/dist/cli/loader.d.ts +7 -7
  107. package/dist/cli/loader.js +9 -9
  108. package/dist/cli/mcp.js +4 -4
  109. package/dist/cli/migrate.d.ts +5 -5
  110. package/dist/cli/migrate.js +11 -11
  111. package/dist/cli/studio-ui.generated.js +1 -1
  112. package/dist/cli/ui.d.ts +2 -2
  113. package/dist/cli/ui.js +2 -2
  114. package/dist/client.d.ts +49 -38
  115. package/dist/client.js +57 -56
  116. package/dist/dialect.d.ts +62 -18
  117. package/dist/dialect.js +40 -2
  118. package/dist/errors.d.ts +5 -5
  119. package/dist/errors.js +11 -11
  120. package/dist/generate.d.ts +6 -6
  121. package/dist/generate.js +31 -29
  122. package/dist/index-advisor.d.ts +5 -5
  123. package/dist/index-advisor.js +0 -0
  124. package/dist/index.d.ts +1 -1
  125. package/dist/index.js +7 -7
  126. package/dist/introspect.d.ts +35 -9
  127. package/dist/introspect.js +82 -32
  128. package/dist/mssql.d.ts +11 -11
  129. package/dist/mssql.js +64 -29
  130. package/dist/mysql.d.ts +8 -8
  131. package/dist/mysql.js +61 -23
  132. package/dist/nested-write.d.ts +21 -2
  133. package/dist/nested-write.js +51 -14
  134. package/dist/optional-peer-import.cjs +7 -7
  135. package/dist/optional-peer-import.d.cts +7 -7
  136. package/dist/pipeline-submittable.d.ts +2 -2
  137. package/dist/pipeline-submittable.js +6 -6
  138. package/dist/pipeline.d.ts +1 -1
  139. package/dist/pipeline.js +4 -4
  140. package/dist/powdb-introspect.d.ts +1 -1
  141. package/dist/powdb-introspect.js +1 -1
  142. package/dist/powdb.d.ts +28 -28
  143. package/dist/powdb.js +66 -66
  144. package/dist/powql.d.ts +27 -27
  145. package/dist/powql.js +73 -52
  146. package/dist/query/aggregates.d.ts +1 -1
  147. package/dist/query/aggregates.js +5 -5
  148. package/dist/query/batched-loader.d.ts +11 -11
  149. package/dist/query/batched-loader.js +24 -24
  150. package/dist/query/builder.d.ts +39 -21
  151. package/dist/query/builder.js +100 -58
  152. package/dist/query/compound-unique.d.ts +1 -1
  153. package/dist/query/compound-unique.js +0 -0
  154. package/dist/query/deferred.d.ts +12 -6
  155. package/dist/query/deferred.js +1 -1
  156. package/dist/query/filters.d.ts +31 -11
  157. package/dist/query/filters.js +66 -13
  158. package/dist/query/index.d.ts +1 -1
  159. package/dist/query/index.js +1 -1
  160. package/dist/query/relations.d.ts +9 -9
  161. package/dist/query/relations.js +165 -58
  162. package/dist/query/types.d.ts +86 -35
  163. package/dist/query/types.js +1 -1
  164. package/dist/query/utils.d.ts +27 -10
  165. package/dist/query/utils.js +84 -14
  166. package/dist/query/where.d.ts +47 -28
  167. package/dist/query/where.js +129 -32
  168. package/dist/query/writes.d.ts +24 -5
  169. package/dist/query/writes.js +101 -13
  170. package/dist/realtime.d.ts +7 -7
  171. package/dist/realtime.js +9 -9
  172. package/dist/schema-builder.d.ts +18 -7
  173. package/dist/schema-builder.js +17 -10
  174. package/dist/schema-metadata.d.ts +3 -3
  175. package/dist/schema-metadata.js +9 -9
  176. package/dist/schema-sql.d.ts +9 -9
  177. package/dist/schema-sql.js +20 -20
  178. package/dist/schema.d.ts +19 -9
  179. package/dist/schema.js +6 -6
  180. package/dist/serverless.d.ts +15 -15
  181. package/dist/serverless.js +16 -16
  182. package/dist/sqlite.d.ts +8 -8
  183. package/dist/sqlite.js +53 -22
  184. package/dist/typed-sql.d.ts +4 -4
  185. package/dist/typed-sql.js +5 -5
  186. package/package.json +2 -2
@@ -21,10 +21,10 @@ import * as whereMod from './where.js';
21
21
  *
22
22
  * Two value shapes need rewriting, both a JS `Date` on a temporal column:
23
23
  *
24
- * 1. A time-of-day column (`time` / `timetz`) — the driver serializes a `Date`
24
+ * 1. A time-of-day column (`time` / `timetz`), the driver serializes a `Date`
25
25
  * as a full ISO timestamp with the process offset and Postgres answers
26
26
  * `22007 invalid input syntax for type time`. Rewritten on every engine.
27
- * 2. A zone-less `date` / `timestamp` column on PostgreSQL — the driver's
27
+ * 2. A zone-less `date` / `timestamp` column on PostgreSQL, the driver's
28
28
  * local-offset serialization stores the PROCESS's calendar fields, so in a
29
29
  * non-UTC process the stored value is wrong and (because the read path
30
30
  * interprets an offset-less value as UTC) does not round-trip. Rewritten to
@@ -59,7 +59,7 @@ export function coerceWriteValue(qi, key, value) {
59
59
  * PostgreSQL only: MySQL's `TIMESTAMP`/`DATETIME` and SQL Server's
60
60
  * `datetime2` are converted or bound by their own drivers, and MySQL in
61
61
  * particular reads a zone-less literal in the SESSION time zone, so a UTC
62
- * literal would be misread there. `utcTimestamps: false` opts out — it is the
62
+ * literal would be misread there. `utcTimestamps: false` opts out, it is the
63
63
  * same switch that turns off the UTC READ parsing, so the two stay symmetric.
64
64
  */
65
65
  function utcDateTimeWrites(qi) {
@@ -158,7 +158,7 @@ export function buildCreateMany(qi, args) {
158
158
  return keys.map((key) => coerceWriteValue(qi, key, record[key]));
159
159
  });
160
160
  // Use actual Postgres types for array casts in the default PostgreSQL dialect.
161
- // Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
161
+ // Enum columns cast to `"EnumName"[]`, the generic text[] fallback would
162
162
  // type the UNNEST output as text, which Postgres refuses to coerce to the
163
163
  // enum ("column X is of type Y but expression is of type text").
164
164
  const typeCasts = columns.map((col) => {
@@ -166,6 +166,18 @@ export function buildCreateMany(qi, args) {
166
166
  return enumType ? `${qi.q(enumType)}[]` : whereMod.getColumnArrayType(qi, col);
167
167
  });
168
168
  const quotedColumns = columns.map((c) => qi.q(c));
169
+ // ARRAY-TYPED COLUMNS force the row-at-a-time VALUES form.
170
+ //
171
+ // The default PostgreSQL shape is `SELECT * FROM UNNEST($1::text[], …)`,
172
+ // one bound array per COLUMN. That is a column-major transpose, and it
173
+ // cannot express an array-valued column: `unnest` flattens, so N rows each
174
+ // holding a `text[]` arrive as one flat `text[]` and PostgreSQL rejects the
175
+ // insert with 42804 ("column is of type text[] but expression is of type
176
+ // text"). Single-row `create` never hit it because it binds each value
177
+ // directly. Detected from the column's own declared type rather than from
178
+ // the shape of the first row's value, so a row whose array column happens to
179
+ // be null or absent still takes the correct form.
180
+ const hasArrayColumn = columns.some((col) => (qi.columnPgTypeMap.get(col) ?? '').startsWith('_'));
169
181
  const built = qi.dialect.buildBulkInsertStatement({
170
182
  table: qt,
171
183
  columns: quotedColumns,
@@ -173,6 +185,7 @@ export function buildCreateMany(qi, args) {
173
185
  columnArrayTypes: typeCasts,
174
186
  skipDuplicates: args.skipDuplicates,
175
187
  returning: writeReturningColumns(qi),
188
+ requireRowValues: hasArrayColumn,
176
189
  });
177
190
  return {
178
191
  sql: built.sql,
@@ -184,20 +197,51 @@ export function buildCreateMany(qi, args) {
184
197
  export function buildUpdate(qi, args) {
185
198
  assertWritable(qi, 'update');
186
199
  qi.currentSkip = args.skipGlobalFilters;
187
- const dataObj = args.data;
200
+ // `updatedAt`-tagged columns are filled in before anything reads `data`, so
201
+ // the SET list, the fingerprint and the param collector all see one object.
202
+ const dataObj = applyUpdatedAtColumns(qi, args.data);
188
203
  assertNoGeneratedColumns(qi, dataObj, 'update');
189
204
  // Prisma compound-unique selector (e.g. `{ orgId_userId: { orgId, userId } }`)
190
205
  // → the column conjunction, before the empty-`where` guard so the expanded
191
206
  // members count as a real predicate.
192
207
  const userWhere = expandCompoundUniqueWhere(qi.tableMeta, args.where);
193
208
  const lock = args.optimisticLock;
194
- // The empty-`where` guard checks the USER predicate only — a global filter
209
+ // The empty-`where` guard checks the USER predicate only, a global filter
195
210
  // must never turn an unguarded mass update into an allowed one.
196
211
  const userHasPredicate = !whereMod.userPredicateIsEmpty(qi, userWhere) || !!lock;
197
212
  whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
198
213
  // The SQL is built from the global-filter-merged where (soft-delete keeps an
199
214
  // update from touching already-deleted rows).
200
215
  const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
216
+ // An update with nothing to set is a NO-OP that returns the current row.
217
+ //
218
+ // It used to render `UPDATE t SET WHERE …` and fail with a raw PostgreSQL
219
+ // 42601 syntax error. That is easy to hit honestly: any handler that builds
220
+ // its payload from optional request fields produces `{}` on a request that
221
+ // supplied none of them, so a legitimate request turned into a 500. Prisma
222
+ // treats the same call as a no-op and returns the row, so a ported handler
223
+ // inherited a crash where the original returned 200.
224
+ //
225
+ // The row is re-selected through the same projection a real update would
226
+ // return (PII columns excluded on tagged tables), and a where matching no
227
+ // row still raises NotFoundError, so only the SQL differs, not the contract.
228
+ // `optimisticLock` is excluded: it always has a version column to SET and a
229
+ // version check that must still run.
230
+ const hasSetData = Object.values(dataObj).some((v) => v !== undefined);
231
+ if (!hasSetData && !lock) {
232
+ const sel = buildReselectByWhere(qi, whereObj);
233
+ return {
234
+ sql: sel.sql,
235
+ params: sel.params,
236
+ transform: (result) => {
237
+ const row = result.rows[0];
238
+ if (!row)
239
+ throw new NotFoundError({ table: qi.table, where: args.where, operation: 'update' });
240
+ return parseWriteRow(qi, row);
241
+ },
242
+ tag: `${qi.table}.update`,
243
+ };
244
+ }
201
245
  const setFp = fingerprintSet(qi, dataObj);
202
246
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
203
247
  const ck = lock ? null : `u:${setFp}|${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
@@ -277,7 +321,7 @@ export function buildUpdate(qi, args) {
277
321
  // Optimistic-lock conflict: the version-checked UPDATE matched no
278
322
  // row. The re-fetch below uses `where` WITHOUT the version
279
323
  // predicate, so it would return the stale row and silently mask
280
- // the conflict — detect it from affected-rows here instead, to
324
+ // the conflict, detect it from affected-rows here instead, to
281
325
  // match the OptimisticLockError thrown on RETURNING/OUTPUT engines.
282
326
  if (lock && (writeResult.rowCount ?? 0) === 0) {
283
327
  throw new OptimisticLockError({
@@ -358,7 +402,7 @@ export function buildUpsert(qi, args) {
358
402
  const createParams = createEntries.map(([k, v]) => coerceWriteValue(qi, k, v));
359
403
  // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
360
404
  const placeholders = createEntries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
361
- // The conflict target comes from `where` keys — must be unique/PK columns
405
+ // The conflict target comes from `where` keys, must be unique/PK columns
362
406
  const conflictKeys = Object.keys(upsertWhere).filter((k) => upsertWhere[k] !== undefined);
363
407
  const conflictColumns = conflictKeys.map((k) => qi.toSqlColumn(k));
364
408
  // Build the UPDATE SET part
@@ -419,10 +463,22 @@ export function buildUpsert(qi, args) {
419
463
  export function buildUpdateMany(qi, args) {
420
464
  assertWritable(qi, 'updateMany');
421
465
  qi.currentSkip = args.skipGlobalFilters;
422
- const dataObj = args.data;
466
+ const dataObj = applyUpdatedAtColumns(qi, args.data);
423
467
  assertNoGeneratedColumns(qi, dataObj, 'updateMany');
424
468
  whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
425
469
  const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
470
+ // Nothing to SET: a no-op, for the same reason as `update` above (that path
471
+ // has the full rationale). Reports `count: 0` because zero rows were
472
+ // modified, no statement is issued at all, so this is not the count of rows
473
+ // the predicate MATCHED.
474
+ if (!Object.values(dataObj).some((v) => v !== undefined)) {
475
+ return {
476
+ sql: `SELECT ${qi.p(1)} AS count`,
477
+ params: [0],
478
+ transform: () => ({ count: 0 }),
479
+ tag: `${qi.table}.updateMany`,
480
+ };
481
+ }
426
482
  const setFp = fingerprintSet(qi, dataObj);
427
483
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
428
484
  const ck = `um:${setFp}|${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
@@ -503,17 +559,49 @@ export function piiFields(_qi, meta) {
503
559
  }
504
560
  /**
505
561
  * The `RETURNING` / `OUTPUT` selection for a write on this table. A table with
506
- * no PII column returns `'*'` (every column — byte-identical SQL to before);
562
+ * no PII column returns `'*'` (every column, byte-identical SQL to before);
507
563
  * a table WITH PII columns returns an explicit quoted list of every non-PII
508
564
  * column so the PII values never leave the database on a write. A PII-tagged
509
565
  * PRIMARY KEY column is kept in the projection regardless (the returned row
510
- * must stay addressable): tag sensitive data, not keys — a PII PK is
566
+ * must stay addressable): tag sensitive data, not keys, a PII PK is
511
567
  * documented out of scope for stripping. Writes accept no `select`/`includePii`
512
568
  * (unlike reads), so this is the whole write-return policy at the SQL level;
513
569
  * {@link parseWriteRow} remains as a defense-in-depth strip (a no-op once the
514
570
  * SQL already excludes the columns). Derived purely from static per-table
515
571
  * schema metadata, so the write SQL cache needs no extra key segment.
516
572
  */
573
+ /**
574
+ * Return `data` with every `updatedAt`-tagged column the caller did not name
575
+ * set to `now`, or the original object when the table has none.
576
+ *
577
+ * Prisma's `@updatedAt` has no turbine equivalent, so a migrated application
578
+ * had to remember the field on every single update, and the value is usually
579
+ * load-bearing in the response body, so forgetting it is a silent staleness
580
+ * bug rather than a crash. The tag is opt-in per column and never inferred
581
+ * from a column's name, so a schema that does not use it emits byte-identical
582
+ * SQL and an application already managing its own timestamp is untouched.
583
+ *
584
+ * The timestamp is generated CLIENT-side (like Prisma) rather than as a SQL
585
+ * `now()`, so it flows through the same temporal coercion as any other bound
586
+ * `Date` and lands in UTC on every engine.
587
+ *
588
+ * An explicit value always wins, including an explicit `null`: naming the
589
+ * column is a statement of intent.
590
+ */
591
+ export function applyUpdatedAtColumns(qi, data) {
592
+ const tagged = qi.tableMeta.columns.filter((c) => c.updatedAt);
593
+ if (tagged.length === 0)
594
+ return data;
595
+ let out = null;
596
+ const now = new Date();
597
+ for (const col of tagged) {
598
+ if (Object.hasOwn(data, col.field) && data[col.field] !== undefined)
599
+ continue;
600
+ out ??= { ...data };
601
+ out[col.field] = now;
602
+ }
603
+ return out ?? data;
604
+ }
517
605
  export function writeReturningColumns(qi) {
518
606
  const piiCols = piiColumns(qi, qi.tableMeta);
519
607
  if (piiCols.size === 0)
@@ -571,7 +659,7 @@ export function assertNoGeneratedColumns(qi, data, operation) {
571
659
  const col = qi.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
572
660
  if (col?.isGeneratedStored) {
573
661
  throw new ValidationError(`[turbine] Cannot ${operation} "${qi.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
574
- 'column whose value the database computes — remove it from your data.');
662
+ 'column whose value the database computes, remove it from your data.');
575
663
  }
576
664
  }
577
665
  }
@@ -580,7 +668,7 @@ export function assertNoGeneratedColumns(qi, data, operation) {
580
668
  *
581
669
  * Supports plain values and atomic operator objects ({ set, increment,
582
670
  * decrement, multiply, divide }). An operator object is detected ONLY when
583
- * it has EXACTLY one key that is one of the 5 operator keys — this avoids
671
+ * it has EXACTLY one key that is one of the 5 operator keys, this avoids
584
672
  * misinterpreting JSON column values like `{ set: 'x' }` as operators
585
673
  * (real operator objects always have exactly one key, and a plain JSON
586
674
  * payload that happens to have a single `set` key is extremely unusual).
@@ -1,11 +1,11 @@
1
1
  /**
2
- * turbine-orm — LISTEN/NOTIFY realtime pub/sub
2
+ * turbine-orm, LISTEN/NOTIFY realtime pub/sub
3
3
  *
4
4
  * Postgres LISTEN/NOTIFY is a first-class realtime primitive that neither
5
5
  * Prisma nor Drizzle expose ergonomically. This module backs the thin
6
6
  * `$listen` / `$notify` methods on TurbineClient.
7
7
  *
8
- * Design — **one dedicated connection per subscription**:
8
+ * Design, **one dedicated connection per subscription**:
9
9
  *
10
10
  * Each `$listen(channel, handler)` acquires its OWN long-lived client from
11
11
  * the pool, runs `LISTEN "chan"`, and keeps that connection checked out for
@@ -13,7 +13,7 @@
13
13
  * subscription owns its lifecycle, `unsubscribe()` cleanly UNLISTENs and
14
14
  * releases exactly one connection, and there is no shared multiplexing
15
15
  * state to reason about. The trade-off is one pool slot per active channel
16
- * — for the handful of channels a typical app listens on, that's a fine
16
+ * - for the handful of channels a typical app listens on, that's a fine
17
17
  * price for clarity. (A future optimization could multiplex many channels
18
18
  * over a single shared notification connection.)
19
19
  *
@@ -23,14 +23,14 @@
23
23
  * notification messages back to the client. Stateless HTTP drivers
24
24
  * (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
25
25
  * `$listen` will surface a clear error rather than hang. `$notify` works
26
- * everywhere — it's a single round-trip `SELECT pg_notify(...)`.
26
+ * everywhere, it's a single round-trip `SELECT pg_notify(...)`.
27
27
  */
28
28
  import type { PgCompatPool } from './client.js';
29
29
  /**
30
30
  * Validate a LISTEN/NOTIFY channel name. Throws ValidationError on anything
31
31
  * that isn't a plain, reasonable-length SQL identifier. This is enforced for
32
32
  * BOTH `$listen` (where the channel is interpolated) and `$notify` (where the
33
- * channel is a bound param) — defensive parity, and it catches user typos
33
+ * channel is a bound param), defensive parity, and it catches user typos
34
34
  * loudly.
35
35
  */
36
36
  export declare function validateChannel(channel: string): void;
@@ -45,7 +45,7 @@ export interface Subscription {
45
45
  readonly channel: string;
46
46
  /**
47
47
  * Stop listening: runs `UNLISTEN "chan"`, removes the notification listener,
48
- * and releases the dedicated connection. Idempotent — safe to call twice.
48
+ * and releases the dedicated connection. Idempotent, safe to call twice.
49
49
  */
50
50
  unsubscribe(): Promise<void>;
51
51
  }
@@ -61,7 +61,7 @@ export interface ActiveSubscription extends Subscription {
61
61
  * Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
62
62
  *
63
63
  * @param pool the pg-compatible pool to check a long-lived client out of
64
- * @param channel channel name — MUST already be validated by the caller
64
+ * @param channel channel name, MUST already be validated by the caller
65
65
  * @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
66
66
  * @param handler called with each notification's payload
67
67
  * @param onClosed invoked when the subscription releases, so the client can
package/dist/realtime.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * turbine-orm — LISTEN/NOTIFY realtime pub/sub
2
+ * turbine-orm, LISTEN/NOTIFY realtime pub/sub
3
3
  *
4
4
  * Postgres LISTEN/NOTIFY is a first-class realtime primitive that neither
5
5
  * Prisma nor Drizzle expose ergonomically. This module backs the thin
6
6
  * `$listen` / `$notify` methods on TurbineClient.
7
7
  *
8
- * Design — **one dedicated connection per subscription**:
8
+ * Design, **one dedicated connection per subscription**:
9
9
  *
10
10
  * Each `$listen(channel, handler)` acquires its OWN long-lived client from
11
11
  * the pool, runs `LISTEN "chan"`, and keeps that connection checked out for
@@ -13,7 +13,7 @@
13
13
  * subscription owns its lifecycle, `unsubscribe()` cleanly UNLISTENs and
14
14
  * releases exactly one connection, and there is no shared multiplexing
15
15
  * state to reason about. The trade-off is one pool slot per active channel
16
- * — for the handful of channels a typical app listens on, that's a fine
16
+ * - for the handful of channels a typical app listens on, that's a fine
17
17
  * price for clarity. (A future optimization could multiplex many channels
18
18
  * over a single shared notification connection.)
19
19
  *
@@ -23,7 +23,7 @@
23
23
  * notification messages back to the client. Stateless HTTP drivers
24
24
  * (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
25
25
  * `$listen` will surface a clear error rather than hang. `$notify` works
26
- * everywhere — it's a single round-trip `SELECT pg_notify(...)`.
26
+ * everywhere, it's a single round-trip `SELECT pg_notify(...)`.
27
27
  */
28
28
  import { ConnectionError, ValidationError, wrapPgError } from './errors.js';
29
29
  // ---------------------------------------------------------------------------
@@ -33,7 +33,7 @@ import { ConnectionError, ValidationError, wrapPgError } from './errors.js';
33
33
  * Strict Postgres identifier: a letter or underscore followed by letters,
34
34
  * digits, or underscores. Channel names CANNOT be parameterized in
35
35
  * LISTEN/UNLISTEN (`LISTEN $1` is a syntax error), so the channel is the one
36
- * place an identifier is interpolated into SQL — it MUST pass this regex AND
36
+ * place an identifier is interpolated into SQL, it MUST pass this regex AND
37
37
  * go through `quoteIdent` before reaching the SQL string.
38
38
  */
39
39
  const CHANNEL_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -43,7 +43,7 @@ const MAX_CHANNEL_LEN = 63;
43
43
  * Validate a LISTEN/NOTIFY channel name. Throws ValidationError on anything
44
44
  * that isn't a plain, reasonable-length SQL identifier. This is enforced for
45
45
  * BOTH `$listen` (where the channel is interpolated) and `$notify` (where the
46
- * channel is a bound param) — defensive parity, and it catches user typos
46
+ * channel is a bound param), defensive parity, and it catches user typos
47
47
  * loudly.
48
48
  */
49
49
  export function validateChannel(channel) {
@@ -54,7 +54,7 @@ export function validateChannel(channel) {
54
54
  throw new ValidationError(`[turbine] $listen/$notify channel "${channel}" exceeds the ${MAX_CHANNEL_LEN}-character Postgres identifier limit`);
55
55
  }
56
56
  if (!CHANNEL_REGEX.test(channel)) {
57
- throw new ValidationError(`[turbine] Invalid $listen/$notify channel "${channel}" — must match /^[A-Za-z_][A-Za-z0-9_]*$/ ` +
57
+ throw new ValidationError(`[turbine] Invalid $listen/$notify channel "${channel}", must match /^[A-Za-z_][A-Za-z0-9_]*$/ ` +
58
58
  '(letters, digits, underscores; cannot start with a digit)');
59
59
  }
60
60
  }
@@ -62,7 +62,7 @@ export function validateChannel(channel) {
62
62
  * Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
63
63
  *
64
64
  * @param pool the pg-compatible pool to check a long-lived client out of
65
- * @param channel channel name — MUST already be validated by the caller
65
+ * @param channel channel name, MUST already be validated by the caller
66
66
  * @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
67
67
  * @param handler called with each notification's payload
68
68
  * @param onClosed invoked when the subscription releases, so the client can
@@ -77,7 +77,7 @@ export async function createSubscription(pool, channel, quotedChannel, handler,
77
77
  throw wrapPgError(err);
78
78
  }
79
79
  // Verify the checked-out client can actually receive async notifications.
80
- // Stateless HTTP drivers return a client with no `.on` — LISTEN would hang
80
+ // Stateless HTTP drivers return a client with no `.on`, LISTEN would hang
81
81
  // forever waiting for messages that can never arrive, so fail loudly now and
82
82
  // give the connection straight back.
83
83
  if (typeof client.on !== 'function') {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * turbine-orm — Schema Builder
2
+ * turbine-orm, Schema Builder
3
3
  *
4
4
  * TypeScript-first schema definition API. Define your database schema
5
- * as plain objects — no method chaining, no DSL. Fully type-checked,
5
+ * as plain objects, no method chaining, no DSL. Fully type-checked,
6
6
  * JSON-serializable, and easy to read.
7
7
  *
8
8
  * @example
@@ -56,9 +56,9 @@ export interface ColumnDef {
56
56
  references?: string | ReferenceDef;
57
57
  /** Max length for varchar columns */
58
58
  maxLength?: number;
59
- /** Enum type name — required when `type: 'enum'`. */
59
+ /** Enum type name, required when `type: 'enum'`. */
60
60
  enumName?: string;
61
- /** pgvector dimension count — required when `type: 'vector'`. */
61
+ /** pgvector dimension count, required when `type: 'vector'`. */
62
62
  dimensions?: number;
63
63
  /** When true, the column is an array of `type` (e.g. `text[]`). */
64
64
  array?: boolean;
@@ -73,6 +73,13 @@ export interface ColumnDef {
73
73
  * `includePii: true`) and redacted by Studio. Introspection never auto-tags PII.
74
74
  */
75
75
  pii?: boolean;
76
+ /**
77
+ * Set this column to the current time on every `update` that does not name
78
+ * it explicitly (Prisma's `@updatedAt`). Opt-in per column and never
79
+ * inferred from the column name: see
80
+ * {@link import('./schema.js').ColumnMetadata.updatedAt}.
81
+ */
82
+ updatedAt?: boolean;
76
83
  }
77
84
  /** Postgres-level column type (uppercase, as used in DDL) */
78
85
  export type ColumnType = 'SERIAL' | 'BIGSERIAL' | 'BIGINT' | 'INTEGER' | 'SMALLINT' | 'TEXT' | 'BOOLEAN' | 'TIMESTAMPTZ' | 'JSONB' | 'UUID' | 'REAL' | 'DOUBLE PRECISION' | 'NUMERIC' | 'BYTEA' | 'DATE' | 'VARCHAR' | 'ENUM' | 'VECTOR';
@@ -99,13 +106,15 @@ export interface ColumnConfig {
99
106
  check: string | null;
100
107
  /** Whether this column is tagged as PII (personally identifiable information). */
101
108
  pii: boolean;
109
+ /** Whether this column is auto-set to the current time on update. */
110
+ updatedAt: boolean;
102
111
  }
103
112
  /**
104
113
  * Explicit many-to-many relation declaration for the code-first schema.
105
114
  *
106
115
  * Auto-detecting m2m from a junction table is intentionally conservative (a
107
116
  * junction with payload columns is treated as a first-class entity, not a join
108
- * table — see `introspect.ts`). This declaration lets users opt in to an m2m
117
+ * table, see `introspect.ts`). This declaration lets users opt in to an m2m
109
118
  * relation explicitly, mirroring how Prisma/Drizzle require an explicit
110
119
  * `@relation` / `relation()` for join tables.
111
120
  *
@@ -196,7 +205,7 @@ export interface TableDef {
196
205
  /**
197
206
  * Optional composite primary key. When present, takes precedence over any
198
207
  * column-level `primaryKey: true` flags. Column names listed here are the
199
- * camelCase JS-facing field names — they will be converted to snake_case
208
+ * camelCase JS-facing field names, they will be converted to snake_case
200
209
  * when emitted as a `PRIMARY KEY (...)` table constraint.
201
210
  */
202
211
  primaryKey?: readonly string[];
@@ -311,6 +320,8 @@ export declare class ColumnBuilder {
311
320
  }): this;
312
321
  check(expression: string): this;
313
322
  pii(): this;
323
+ /** Auto-set this column to the current time on every update. Prisma's `@updatedAt`. */
324
+ updatedAt(): this;
314
325
  array(): this;
315
326
  build(): ColumnConfig;
316
327
  }
@@ -331,7 +342,7 @@ export declare function table(columns: Record<string, ColumnBuilder>): TableDef;
331
342
  *
332
343
  * This is the runtime bridge for the code-first m2m API: `defineSchema` only
333
344
  * produces DDL, so after `introspect()`ing the live database you call this to
334
- * attach the m2m relations you declared. It is PURELY ADDITIVE — existing
345
+ * attach the m2m relations you declared. It is PURELY ADDITIVE, existing
335
346
  * belongsTo/hasMany/hasOne relations are preserved, and a declared relation is
336
347
  * skipped (not overwritten) if its name already exists on the source table.
337
348
  *
@@ -1,8 +1,8 @@
1
1
  /**
2
- * turbine-orm — Schema Builder
2
+ * turbine-orm, Schema Builder
3
3
  *
4
4
  * TypeScript-first schema definition API. Define your database schema
5
- * as plain objects — no method chaining, no DSL. Fully type-checked,
5
+ * as plain objects, no method chaining, no DSL. Fully type-checked,
6
6
  * JSON-serializable, and easy to read.
7
7
  *
8
8
  * @example
@@ -25,7 +25,7 @@
25
25
  /** Maps shorthand names to actual Postgres type strings */
26
26
  const TYPE_MAP = {
27
27
  // `serial` maps to SERIAL (int4). Its values fit in a JS `number` and pg
28
- // returns them as numbers — so the generated `number` type is accurate.
28
+ // returns them as numbers, so the generated `number` type is accurate.
29
29
  // For 64-bit auto-increment keys use `bigserial` (int8), noting that values
30
30
  // above Number.MAX_SAFE_INTEGER read back as string. (Changed in 0.24.0;
31
31
  // `serial` previously emitted BIGSERIAL.)
@@ -37,7 +37,7 @@ const TYPE_MAP = {
37
37
  text: 'TEXT',
38
38
  varchar: 'VARCHAR',
39
39
  boolean: 'BOOLEAN',
40
- // `timestamp` is an honest alias for TIMESTAMPTZ (timezone-aware) — Turbine
40
+ // `timestamp` is an honest alias for TIMESTAMPTZ (timezone-aware), Turbine
41
41
  // has always emitted TIMESTAMPTZ for it. `timestamptz` is the explicit spelling.
42
42
  timestamp: 'TIMESTAMPTZ',
43
43
  timestamptz: 'TIMESTAMPTZ',
@@ -51,7 +51,7 @@ const TYPE_MAP = {
51
51
  double: 'DOUBLE PRECISION',
52
52
  numeric: 'NUMERIC',
53
53
  bytea: 'BYTEA',
54
- // Sentinels — the real DDL type is derived from `enumName` / `dimensions`
54
+ // Sentinels, the real DDL type is derived from `enumName` / `dimensions`
55
55
  // in schema-sql.ts, never from these placeholders.
56
56
  enum: 'ENUM',
57
57
  vector: 'VECTOR',
@@ -95,6 +95,7 @@ function resolveColumn(def) {
95
95
  isArray: def.array ?? false,
96
96
  check: def.check ?? null,
97
97
  pii: def.pii ?? false,
98
+ updatedAt: def.updatedAt ?? false,
98
99
  };
99
100
  }
100
101
  /** Type guard: is this index declaration a doc-field expression index? */
@@ -188,7 +189,7 @@ export function defineSchema(input, options) {
188
189
  }
189
190
  // Validate composite PK references real columns and clear column-level PKs
190
191
  // for those columns so we don't double-emit `PRIMARY KEY` clauses.
191
- // Composite PK members are implicitly NOT NULL — preserve that even
192
+ // Composite PK members are implicitly NOT NULL, preserve that even
192
193
  // when the user clears the column-level `primaryKey: true` flag.
193
194
  if (pk && pk.length > 0) {
194
195
  for (const colName of pk) {
@@ -197,7 +198,7 @@ export function defineSchema(input, options) {
197
198
  `Known columns: ${Object.keys(columns).join(', ') || '(none)'}`);
198
199
  }
199
200
  // A composite PK at the table level supersedes any column-level
200
- // `primaryKey: true` flag — silently clear it so DDL emission
201
+ // `primaryKey: true` flag, silently clear it so DDL emission
201
202
  // produces a single, valid table-level PRIMARY KEY constraint.
202
203
  // Force NOT NULL since PK columns can never be nullable.
203
204
  const c = columns[colName];
@@ -228,7 +229,7 @@ function camelToSnakeLocal(s) {
228
229
  return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
229
230
  }
230
231
  // ---------------------------------------------------------------------------
231
- // Legacy compat — ColumnBuilder still works for existing code
232
+ // Legacy compat, ColumnBuilder still works for existing code
232
233
  // ---------------------------------------------------------------------------
233
234
  export class ColumnBuilder {
234
235
  _config;
@@ -249,6 +250,7 @@ export class ColumnBuilder {
249
250
  isArray: false,
250
251
  check: null,
251
252
  pii: false,
253
+ updatedAt: false,
252
254
  };
253
255
  }
254
256
  serial() {
@@ -359,6 +361,11 @@ export class ColumnBuilder {
359
361
  this._config.pii = true;
360
362
  return this;
361
363
  }
364
+ /** Auto-set this column to the current time on every update. Prisma's `@updatedAt`. */
365
+ updatedAt() {
366
+ this._config.updatedAt = true;
367
+ return this;
368
+ }
362
369
  array() {
363
370
  this._config.isArray = true;
364
371
  return this;
@@ -421,7 +428,7 @@ export function table(columns) {
421
428
  *
422
429
  * This is the runtime bridge for the code-first m2m API: `defineSchema` only
423
430
  * produces DDL, so after `introspect()`ing the live database you call this to
424
- * attach the m2m relations you declared. It is PURELY ADDITIVE — existing
431
+ * attach the m2m relations you declared. It is PURELY ADDITIVE, existing
425
432
  * belongsTo/hasMany/hasOne relations are preserved, and a declared relation is
426
433
  * skipped (not overwritten) if its name already exists on the source table.
427
434
  *
@@ -460,7 +467,7 @@ export function applyManyToManyRelations(meta, def) {
460
467
  const sourceTable = tableDef.name;
461
468
  const sourceMeta = tables[sourceTable];
462
469
  if (!sourceMeta)
463
- continue; // table not present in introspected metadata — skip
470
+ continue; // table not present in introspected metadata, skip
464
471
  const relations = { ...sourceMeta.relations };
465
472
  for (const m of tableDef.manyToMany) {
466
473
  // Additive-only: never clobber an existing relation name.
@@ -1,9 +1,9 @@
1
1
  /**
2
- * turbine-orm — defineSchema() → SchemaMetadata bridge
2
+ * turbine-orm, defineSchema() → SchemaMetadata bridge
3
3
  *
4
4
  * Converts a code-first {@link SchemaDef} (the output of `defineSchema()`)
5
5
  * into the runtime {@link SchemaMetadata} shape that the query builder,
6
- * `TurbineClient`, and the non-SQL engines consume — without touching a
6
+ * `TurbineClient`, and the non-SQL engines consume, without touching a
7
7
  * live database.
8
8
  *
9
9
  * Why this exists: the historical converter path (`introspect()` +
@@ -49,7 +49,7 @@ import { type SchemaDef } from './schema-builder.js';
49
49
  /**
50
50
  * Convert a code-first {@link SchemaDef} into runtime {@link SchemaMetadata}.
51
51
  *
52
- * Pure function — no database connection, no side effects, input untouched.
52
+ * Pure function, no database connection, no side effects, input untouched.
53
53
  * The output is shaped identically to the `SCHEMA` constant `turbine generate`
54
54
  * emits from introspection, so it can be handed to any consumer that expects
55
55
  * introspected metadata: `new TurbineClient(config, metadata)`,
@@ -1,9 +1,9 @@
1
1
  /**
2
- * turbine-orm — defineSchema() → SchemaMetadata bridge
2
+ * turbine-orm, defineSchema() → SchemaMetadata bridge
3
3
  *
4
4
  * Converts a code-first {@link SchemaDef} (the output of `defineSchema()`)
5
5
  * into the runtime {@link SchemaMetadata} shape that the query builder,
6
- * `TurbineClient`, and the non-SQL engines consume — without touching a
6
+ * `TurbineClient`, and the non-SQL engines consume, without touching a
7
7
  * live database.
8
8
  *
9
9
  * Why this exists: the historical converter path (`introspect()` +
@@ -77,13 +77,13 @@ function udtName(config) {
77
77
  return 'vector';
78
78
  return DDL_TO_UDT[config.type];
79
79
  }
80
- /** Server-generated (sequence-backed) types — pg's `nextval(...)` default. */
80
+ /** Server-generated (sequence-backed) types, pg's `nextval(...)` default. */
81
81
  function isSerialType(type) {
82
82
  return type === 'SERIAL' || type === 'BIGSERIAL';
83
83
  }
84
84
  /**
85
85
  * Resolve the raw column part of a `references: 'table.column'` target to a
86
- * snake_case column name — accepting either the camelCase field name or the
86
+ * snake_case column name, accepting either the camelCase field name or the
87
87
  * snake_case DDL name, mirroring how schema-sql.ts accepts both table forms.
88
88
  */
89
89
  function resolveColumnName(raw, target) {
@@ -163,7 +163,7 @@ function mapIndexes(tableDef, declared) {
163
163
  /**
164
164
  * Convert a code-first {@link SchemaDef} into runtime {@link SchemaMetadata}.
165
165
  *
166
- * Pure function — no database connection, no side effects, input untouched.
166
+ * Pure function, no database connection, no side effects, input untouched.
167
167
  * The output is shaped identically to the `SCHEMA` constant `turbine generate`
168
168
  * emits from introspection, so it can be handed to any consumer that expects
169
169
  * introspected metadata: `new TurbineClient(config, metadata)`,
@@ -197,7 +197,7 @@ function mapIndexes(tableDef, declared) {
197
197
  export function schemaDefToMetadata(def) {
198
198
  // ----- Pass 1: resolve every table's snake_case names for FK lookups -----
199
199
  // Lookup accepts the accessor key (camelCase), the DDL name (snake_case),
200
- // and the explicit `accessor` field — same tolerance as schema-sql.ts.
200
+ // and the explicit `accessor` field, same tolerance as schema-sql.ts.
201
201
  const lookup = new Map();
202
202
  for (const [key, tableDef] of Object.entries(def.tables)) {
203
203
  const fieldToColumn = new Map();
@@ -224,7 +224,7 @@ export function schemaDefToMetadata(def) {
224
224
  if (parts.length !== 2)
225
225
  continue;
226
226
  const target = lookup.get(parts[0]);
227
- // Reference to a table outside this SchemaDef — skip, exactly like
227
+ // Reference to a table outside this SchemaDef, skip, exactly like
228
228
  // introspection skips FKs whose target is excluded from the table set.
229
229
  if (!target)
230
230
  continue;
@@ -246,14 +246,14 @@ export function schemaDefToMetadata(def) {
246
246
  // catalog: legacy-first naming, per-column disambiguation for several FKs to
247
247
  // the same target, and collision resolution against scalar column fields
248
248
  // (json/jsonb `unknown`-typed shadows keep the historical name). The old
249
- // local reimplementation had NO collision guard — `posts.user` (text) +
249
+ // local reimplementation had NO collision guard, `posts.user` (text) +
250
250
  // `userId references users.id` produced a relation `user` that shadowed the
251
251
  // scalar, and two FKs deriving the same name silently clobbered each other
252
252
  // (N-4).
253
253
  //
254
254
  // Constraint names are synthesized in pg's default `<table>_<column>_fkey`
255
255
  // form; they only feed the referential-action lookup and composite-FK
256
- // naming (never hit here — `references:` is single-column by design).
256
+ // naming (never hit here, `references:` is single-column by design).
257
257
  const fkEntries = [];
258
258
  const fkActions = new Map();
259
259
  for (const fk of foreignKeys) {