turbine-orm 0.77.1 → 0.78.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 (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { NotFoundError, OptimisticLockError, UnsupportedFeatureError, ValidationError } from '../errors.js';
14
14
  import { camelToSnake, snakeToCamel } from '../schema.js';
15
- import { expandCompoundUniqueWhere } from './compound-unique.js';
15
+ import { assertMutationWhereIdentifiesOneRow, expandCompoundUniqueWhere } from './compound-unique.js';
16
16
  import { isUnmatchedPlainObject, UPDATE_OPERATOR_KEYS } from './filters.js';
17
17
  import { resolveSkipGlobalFilters, resolveUnsafeFlag } from './types.js';
18
18
  import { canonicalWriteEntries, coerceTemporalValue, resolveColumnName } from './utils.js';
@@ -346,7 +346,15 @@ export function buildUpdate(qi, args) {
346
346
  // The empty-`where` guard checks the USER predicate only, a global filter
347
347
  // must never turn an unguarded mass update into an allowed one.
348
348
  const userHasPredicate = !whereMod.userPredicateIsEmpty(qi, userWhere) || !!lock;
349
- whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'));
349
+ const allowFullTableScan = resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan');
350
+ whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', allowFullTableScan);
351
+ // `update` returns ONE row, so its where must identify one: the 0.73
352
+ // `findUnique` rule (query/compound-unique.ts) applied to the write that has
353
+ // the same shape. On the USER's where, before the global filter is merged
354
+ // (a filter narrows, it never identifies), and skipped only under the
355
+ // explicit full-table opt-in, which already means "every row".
356
+ if (!allowFullTableScan)
357
+ assertMutationWhereIdentifiesOneRow(qi.tableMeta, qi.table, userWhere, 'update');
350
358
  // The SQL is built from the global-filter-merged where (soft-delete keeps an
351
359
  // update from touching already-deleted rows).
352
360
  const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
@@ -479,7 +487,12 @@ export function buildDelete(qi, args) {
479
487
  // Prisma compound-unique selector → the column conjunction (before the guard).
480
488
  const userWhere = expandCompoundUniqueWhere(qi.tableMeta, args.where);
481
489
  // Guard the USER predicate (a global filter must not satisfy the guard).
482
- whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'));
490
+ const allowFullTableScan = resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan');
491
+ whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', allowFullTableScan);
492
+ // Same identity rule as `update` (see buildUpdate): one returned row means
493
+ // one addressed row. `deleteMany` is the many-row path.
494
+ if (!allowFullTableScan)
495
+ assertMutationWhereIdentifiesOneRow(qi.tableMeta, qi.table, userWhere, 'delete');
483
496
  const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
484
497
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
485
498
  const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
@@ -533,6 +546,23 @@ export function buildUpsert(qi, args) {
533
546
  qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
534
547
  // Prisma compound-unique selector on the conflict target → its member columns.
535
548
  const upsertWhere = expandCompoundUniqueWhere(qi.tableMeta, args.where);
549
+ // An upsert's `where` becomes the CONFLICT TARGET, so it carries the same
550
+ // one-row contract `update` / `delete` do and is refused by the same rule
551
+ // (query/compound-unique.ts), with its own sentence because the consequence
552
+ // differs. Without it a non-unique `where` reached the server as
553
+ // `ON CONFLICT ("role")` and came back as a bare SQLSTATE 42P10 on
554
+ // PostgreSQL, and WORSE than an error on the engines that do not read the
555
+ // conflict target back: MySQL's `ON DUPLICATE KEY UPDATE` ignores it and
556
+ // keys off whichever unique index the row happens to violate, and SQL
557
+ // Server's `MERGE ... ON` matches every row the predicate matches and
558
+ // updates all of them.
559
+ //
560
+ // Before any SQL is assembled, and this is the only where-guard `upsert`
561
+ // runs: `{}` and `{ email: undefined }` are refused here too, and by the
562
+ // right sentence. The empty-`where` guard's own message points at
563
+ // `allowFullTableScan: UNSAFE`, which is not an option on `UpsertArgs` at
564
+ // all, so borrowing it here would name a way out that does not exist.
565
+ assertMutationWhereIdentifiesOneRow(qi.tableMeta, qi.table, upsertWhere, 'upsert');
536
566
  // Build the INSERT part from create data
537
567
  const createEntries = writeEntries(qi, args.create);
538
568
  const columns = createEntries.map(([k]) => qi.toSqlColumn(k));
@@ -542,25 +572,47 @@ export function buildUpsert(qi, args) {
542
572
  // The conflict target comes from `where` keys, must be unique/PK columns
543
573
  const conflictKeys = Object.keys(upsertWhere).filter((k) => upsertWhere[k] !== undefined);
544
574
  const conflictColumns = conflictKeys.map((k) => qi.toSqlColumn(k));
545
- // Build the UPDATE SET part
575
+ // The conflict-UPDATE SET goes through the SAME operator-aware compiler as
576
+ // `update()` (buildSetClause), so `set` / `increment` / `decrement` /
577
+ // `multiply` / `divide` mean here what they mean there and a misspelled
578
+ // operator gets the same E003. This branch used to bind each value directly,
579
+ // which stored an operator OBJECT as the JSON text `{"set":"x"}` in a text
580
+ // column with no error at all. buildSetClause pushes onto `params` as it
581
+ // goes, so the numbering continues after the create params exactly as the
582
+ // hand-rolled `paramIdx` did. No SQL cache here (the statement is rebuilt per
583
+ // call), so the fingerprint / collect mirrors update() keeps are not needed.
546
584
  const updateEntries = writeEntries(qi, args.update);
547
- let paramIdx = createParams.length + 1;
548
- const setClauses = updateEntries.map(([k]) => {
549
- const clause = `${qi.toSqlColumn(k)} = ${qi.p(paramIdx)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`;
550
- paramIdx++;
551
- return clause;
552
- });
553
- const updateParams = updateEntries.map(([k, v]) => coerceWriteValue(qi, k, v));
554
- const params = [...createParams, ...updateParams];
585
+ const params = [...createParams];
586
+ const refQualifier = upsertReferenceQualifier(qi);
587
+ const setClauses = updateEntries.map(([k, v]) => buildSetClause(qi, k, v, params, refQualifier));
555
588
  // Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
556
589
  // upsert never resurrects a soft-deleted row or writes across tenants. Only
557
- // on engines whose upsert can carry a predicate (Postgres); the gf params
558
- // continue the placeholder numbering after create+update params.
590
+ // on engines whose upsert can carry a predicate (Postgres, SQLite); the gf
591
+ // params continue the placeholder numbering after create+update params.
592
+ //
593
+ // Compiled against the TABLE QUALIFIER, not bare: in `ON CONFLICT ... DO
594
+ // UPDATE ... WHERE` both the target table and `excluded` are in scope, so an
595
+ // unqualified column is ambiguous and PostgreSQL rejected EVERY upsert on a
596
+ // globally filtered table at parse time (42702), insert path included.
559
597
  let updateWhere;
560
598
  if (qi.dialect.supportsUpsertUpdateWhere) {
561
599
  const gf = whereMod.resolveGlobalFilter(qi, qi.table);
562
- if (gf)
563
- updateWhere = whereMod.buildWhereClause(qi, gf, params) ?? undefined;
600
+ if (gf) {
601
+ // Compiled through a scope whose FROM-item reference is ALREADY RENDERED
602
+ // (`"users"`), which is what the target table is inside `ON CONFLICT ...
603
+ // DO UPDATE`. `aliasWhereScope` is the wrong seam for that: it takes a
604
+ // BARE alias and quotes it itself for the nested-relation correlation
605
+ // parent, so handing it `q(table)` produced a triple-quoted reference
606
+ // inside an EXISTS body and the whole statement failed with 42P01
607
+ // whenever the configured global filter was a relation filter rather
608
+ // than a plain column. A bare table name is not the answer either: it
609
+ // would leave the qualifier unquoted and break every table whose name
610
+ // needs quoting. `buildRenderedRefWhere` is the rendered-reference seam,
611
+ // one parameter with one meaning, used for the qualifier and the
612
+ // correlation parent alike, and shared with the batched `_count`
613
+ // follow-up, which had the identical bug.
614
+ updateWhere = whereMod.buildRenderedRefWhere(qi, qi.table, qi.tableMeta, qi.q(qi.table), gf, params) ?? undefined;
615
+ }
564
616
  }
565
617
  const sql = qi.dialect.buildUpsertStatement({
566
618
  table: qi.q(qi.table),
@@ -577,11 +629,21 @@ export function buildUpsert(qi, args) {
577
629
  transform: (result) => {
578
630
  const row = result.rows[0];
579
631
  if (!row) {
632
+ // With a conflict-UPDATE predicate in play there IS a way to get no
633
+ // row: the key conflicts with a row the global filter hides, the
634
+ // predicate is false for it, the engine skips the update, and the
635
+ // insert cannot happen either because the key is taken. Nothing was
636
+ // written and the hidden row is untouched; say so, rather than "this
637
+ // should never happen".
580
638
  throw new NotFoundError({
581
639
  table: qi.table,
582
640
  where: args.where,
583
641
  operation: 'upsert',
584
- message: `upsert on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
642
+ message: updateWhere
643
+ ? `upsert on "${qi.table}" wrote nothing: the row that conflicts on the given key is excluded by the ` +
644
+ `configured global filter for "${qi.table}", so it was neither updated (the filter hides it) nor ` +
645
+ 'inserted (the key is taken). Pass `skipGlobalFilters: UNSAFE` to address it anyway.'
646
+ : `upsert on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
585
647
  });
586
648
  }
587
649
  return parseWriteRow(qi, row);
@@ -597,6 +659,29 @@ export function buildUpsert(qi, args) {
597
659
  : undefined,
598
660
  };
599
661
  }
662
+ /**
663
+ * The qualifier an upsert's arithmetic SET operator reads its column through.
664
+ *
665
+ * `INSERT ... ON CONFLICT DO UPDATE SET n = n + 1` is 42702 on PostgreSQL
666
+ * because `n` could be the target row's or `excluded`'s, so the reference is
667
+ * qualified with the table name on every engine whose upsert statement names
668
+ * the table directly (PostgreSQL, SQLite, MySQL all accept `"t"."n"` there).
669
+ * SQL Server is the exception and keeps the bare reference: its `MERGE`
670
+ * aliases the target as `T`, so the TABLE NAME does not resolve there at all,
671
+ * and the alias belongs to that dialect's own `buildUpsertStatement` rather
672
+ * than to this module. An arithmetic operator in an mssql upsert may therefore
673
+ * still be refused by the server as an ambiguous column; that shape has never
674
+ * worked (it previously bound the operator OBJECT as the value), and the sound
675
+ * fix is for the dialect to publish its target alias, which is a change to
676
+ * `dialect.ts`. It fails loudly either way, so no wrong answer is possible.
677
+ * `set` and literal values reference no column and are unaffected everywhere.
678
+ *
679
+ * Gated on the dialect NAME, never on hook presence: every engine dialect
680
+ * spreads `postgresDialect`, so a presence test would say yes for all of them.
681
+ */
682
+ function upsertReferenceQualifier(qi) {
683
+ return qi.dialect.name === 'mssql' ? '' : `${qi.q(qi.table)}.`;
684
+ }
600
685
  export function buildUpdateMany(qi, args) {
601
686
  assertWritable(qi, 'updateMany');
602
687
  qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
@@ -859,9 +944,19 @@ export function assertNoGeneratedColumns(qi, data, operation) {
859
944
  * Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
860
945
  * pushes any required params onto the shared params array so that WHERE
861
946
  * clause numbering continues correctly afterward.
947
+ *
948
+ * `refQualifier` prefixes the column REFERENCE an arithmetic operator reads
949
+ * from (`"view_count" = <qualifier>"view_count" + $n`). A plain `UPDATE` has
950
+ * one table in scope and passes nothing; `upsert` passes the table name,
951
+ * because inside `ON CONFLICT ... DO UPDATE SET` the target table and
952
+ * `excluded` are BOTH in scope and a bare reference is 42702 on PostgreSQL.
953
+ * The assignment target on the left stays bare on every engine (it can only
954
+ * ever be the target table's column), and `set` / literal values reference no
955
+ * column at all, so their SQL is byte-identical with or without a qualifier.
862
956
  */
863
- export function buildSetClause(qi, key, value, params) {
957
+ export function buildSetClause(qi, key, value, params, refQualifier = '') {
864
958
  const col = qi.toSqlColumn(key);
959
+ const ref = `${refQualifier}${col}`;
865
960
  // Enum columns get an explicit `::"EnumName"` cast on their value bind
866
961
  // (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
867
962
  // SQL cache and collectSetParams are unaffected.
@@ -888,19 +983,19 @@ export function buildSetClause(qi, key, value, params) {
888
983
  }
889
984
  if (op === 'increment') {
890
985
  params.push(opValue);
891
- return `${col} = ${col} + ${qi.p(params.length)}`;
986
+ return `${col} = ${ref} + ${qi.p(params.length)}`;
892
987
  }
893
988
  if (op === 'decrement') {
894
989
  params.push(opValue);
895
- return `${col} = ${col} - ${qi.p(params.length)}`;
990
+ return `${col} = ${ref} - ${qi.p(params.length)}`;
896
991
  }
897
992
  if (op === 'multiply') {
898
993
  params.push(opValue);
899
- return `${col} = ${col} * ${qi.p(params.length)}`;
994
+ return `${col} = ${ref} * ${qi.p(params.length)}`;
900
995
  }
901
996
  if (op === 'divide') {
902
997
  params.push(opValue);
903
- return `${col} = ${col} / ${qi.p(params.length)}`;
998
+ return `${col} = ${ref} / ${qi.p(params.length)}`;
904
999
  }
905
1000
  }
906
1001
  // Fall through: multi-key objects or non-operator single-key objects
package/dist/seed.d.ts CHANGED
@@ -12,4 +12,20 @@ export type DefinedSeed = () => Promise<void>;
12
12
  * Exported for unit testing the frame parser in isolation.
13
13
  */
14
14
  export declare function parseStackFramePath(line: string): string | null;
15
+ /**
16
+ * Declare a seed: `fn` receives a connected `TurbineClient` and is awaited.
17
+ *
18
+ * The returned function runs the seed on demand. When the module that calls
19
+ * `defineSeed` is itself the process entry point (`npx tsx seed.ts`, or the
20
+ * child `turbine seed` spawns), the seed also runs automatically.
21
+ *
22
+ * The client connects through `DATABASE_URL`. Under `turbine seed` that value
23
+ * is the configured connection string with `search_path` PINNED to the
24
+ * configured `schema` (as the `options=-c search_path` connection parameter,
25
+ * so it can never leak onto a pooled backend the way a `SET` would). Every
26
+ * unqualified table name the seed writes therefore lands in the same schema
27
+ * `push`, `generate` and the generated client use; a seed that must reach
28
+ * another schema qualifies the name. The default `public` adds nothing, so a
29
+ * project that never set `schema` connects exactly as before.
30
+ */
15
31
  export declare function defineSeed(fn: SeedFunction): DefinedSeed;
package/dist/seed.js CHANGED
@@ -139,6 +139,22 @@ async function runSeed(fn) {
139
139
  await db.disconnect();
140
140
  }
141
141
  }
142
+ /**
143
+ * Declare a seed: `fn` receives a connected `TurbineClient` and is awaited.
144
+ *
145
+ * The returned function runs the seed on demand. When the module that calls
146
+ * `defineSeed` is itself the process entry point (`npx tsx seed.ts`, or the
147
+ * child `turbine seed` spawns), the seed also runs automatically.
148
+ *
149
+ * The client connects through `DATABASE_URL`. Under `turbine seed` that value
150
+ * is the configured connection string with `search_path` PINNED to the
151
+ * configured `schema` (as the `options=-c search_path` connection parameter,
152
+ * so it can never leak onto a pooled backend the way a `SET` would). Every
153
+ * unqualified table name the seed writes therefore lands in the same schema
154
+ * `push`, `generate` and the generated client use; a seed that must reach
155
+ * another schema qualifies the name. The default `public` adds nothing, so a
156
+ * project that never set `schema` connects exactly as before.
157
+ */
142
158
  export function defineSeed(fn) {
143
159
  const run = () => runSeed(fn);
144
160
  if (isDirectSeedModule()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.77.1",
3
+ "version": "0.78.0",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",
@@ -132,7 +132,7 @@
132
132
  "//coverage:cli": "The CLI coverage gate, split into ONE collection run plus a per-file threshold check for every file in it, and an aggregate. c8 enforces a single threshold set per invocation and its --per-file applies the SAME numbers to every file, neither of which can express 'destructive.ts holds 100 while migrate.ts holds 70'. An aggregate-only floor lets the least-covered file spend the whole slack the best-covered file earned: at the measured 3623/2951 lines, migrate.ts could fall from 70.3% to 66.6% with the aggregate still green. So each file gets its OWN floor, checked by re-reporting the coverage already on disk (c8 report re-reads ./coverage/tmp, so this costs no extra test run). The aggregate check is kept as well: it catches all three sagging together inside their individual margins. Per-file gates run FIRST because their failure names the file. DATABASE_URL is neutralized on the collection run: these test files include live migration tests that create and drop tables, and this script runs from prepublishOnly.",
133
133
  "test:coverage:cli": "npm run check:coverage-sync && npm run coverage:cli:collect && npm run coverage:cli:gate:destructive && npm run coverage:cli:gate:sql-statements && npm run coverage:cli:gate:pii-guard && npm run coverage:cli:gate:error-catalog && npm run coverage:cli:gate:mcp && npm run coverage:cli:gate:compile-query && npm run coverage:cli:gate:studio && npm run coverage:cli:gate:migrate && npm run coverage:cli:gate:index && npm run coverage:cli:gate:aggregate",
134
134
  "//coverage:cli:collect": "--check-coverage=false is load-bearing, not tidying. c8 reads .c8rc.json for defaults, so this COLLECTION step was silently enforcing the MAIN gate's global thresholds against a src/cli-only file set. That is a gate nobody wrote and nobody wanted, and it went unnoticed only because the main floor was low enough (75) for the CLI aggregate (89.29) to clear it by accident. When the main floors were re-baselined to 93/89/78 (see //merge-bug in .c8rc.json) it started failing here, several steps before the per-file gates that are supposed to decide. Thresholds for these files belong to the coverage:cli:gate:* scripts, which pass their own explicitly.",
135
- "coverage:cli:collect": "DATABASE_URL= c8 --all --check-coverage=false --reporter text --exclude 'src/test/**' --include src/cli/studio.ts --include src/cli/migrate.ts --include src/cli/destructive.ts --include src/cli/sql-statements.ts --include src/cli/pii-predicate-guard.ts --include src/cli/mcp.ts --include src/cli/compile-query.ts --include src/cli/error-catalog.ts --include src/cli/index.ts tsx --test src/test/studio-write.test.ts src/test/pii-guard-symmetry.test.ts src/test/studio-demo.test.ts src/test/studio.test.ts src/test/studio-security.test.ts src/test/migrate.test.ts src/test/migrate-deploy.test.ts src/test/migrate-smoke-fixes.test.ts src/test/tracking-table-race.test.ts src/test/destructive-dynamic-assembly.test.ts src/test/destructive-migrations.test.ts src/test/backfill-recipe.test.ts src/test/cli.test.ts src/test/cli-diff-migration.test.ts src/test/cli-flags.test.ts src/test/cli-first-run.test.ts src/test/mcp.test.ts src/test/mcp-relations.test.ts src/test/mcp-pii.test.ts src/test/mcp-pii-round2.test.ts src/test/mcp-agent-tools.test.ts src/test/mcp-compile-query.test.ts src/test/mcp-perimeter-bounds.test.ts",
135
+ "coverage:cli:collect": "DATABASE_URL= c8 --all --check-coverage=false --reporter text --exclude 'src/test/**' --include src/cli/studio.ts --include src/cli/migrate.ts --include src/cli/destructive.ts --include src/cli/sql-statements.ts --include src/cli/pii-predicate-guard.ts --include src/cli/mcp.ts --include src/cli/compile-query.ts --include src/cli/error-catalog.ts --include src/cli/index.ts tsx --test src/test/studio-write.test.ts src/test/pii-guard-symmetry.test.ts src/test/studio-demo.test.ts src/test/studio.test.ts src/test/studio-security.test.ts src/test/migrate.test.ts src/test/migrate-deploy.test.ts src/test/migrate-smoke-fixes.test.ts src/test/tracking-table-race.test.ts src/test/destructive-dynamic-assembly.test.ts src/test/destructive-migrations.test.ts src/test/backfill-recipe.test.ts src/test/cli.test.ts src/test/cli-diff-migration.test.ts src/test/cli-flags.test.ts src/test/cli-first-run.test.ts src/test/mcp.test.ts src/test/mcp-relations.test.ts src/test/mcp-pii.test.ts src/test/mcp-pii-round2.test.ts src/test/mcp-agent-tools.test.ts src/test/mcp-compile-query.test.ts src/test/mcp-perimeter-bounds.test.ts src/test/destructive-obfuscation.test.ts src/test/cli-init-schema.test.ts src/test/cli-introspect-options.test.ts src/test/mcp-transport-bounds.test.ts src/test/seed-schema.test.ts src/test/migrate-preview-and-report.test.ts src/test/destructive-quoting-forms.test.ts",
136
136
  "coverage:cli:gate": "c8 report --all --exclude 'src/test/**' --reporter text --check-coverage",
137
137
  "coverage:cli:gate:destructive": "npm run coverage:cli:gate -- --include src/cli/destructive.ts --lines 98 --statements 98 --branches 84 --functions 98",
138
138
  "coverage:cli:gate:sql-statements": "npm run coverage:cli:gate -- --include src/cli/sql-statements.ts --lines 100 --statements 100 --branches 98 --functions 100",
@@ -254,7 +254,7 @@
254
254
  },
255
255
  "lint-staged": {
256
256
  "*.ts": [
257
- "biome check --no-errors-on-unmatched"
257
+ "biome check --error-on-warnings --no-errors-on-unmatched"
258
258
  ]
259
259
  }
260
260
  }
@@ -171,15 +171,29 @@ where: { tastingNotes: { path: ['panel', 'score'], gte: 9 } }
171
171
  where: { credentialBlob: { path: ['tier'], equals: 'master' } }
172
172
  ```
173
173
 
174
- A path takes a NARROWER operator set than a plain column: `equals`, `gt`, `gte`,
175
- `lt`, `lte`, `contains`, `startsWith`, `endsWith`, plus `mode`. `not`, `in` and
176
- `notIn` are refused with `TURBINE_E003` listing what is accepted; express them
177
- with `NOT` / `OR` around the path filter instead.
178
-
179
- `_count` accepts a JSON path, and `groupBy` accepts one as a grouping key via
180
- `{ field, path }`. **`_avg`, `_sum`, `_min` and `_max` do not work on a JSON
181
- path**: they reach the database as `avg(jsonb)` and fail there. Cast the value
182
- into a real column, or aggregate in application code.
174
+ A path takes a DIFFERENT operator set from a plain column: `equals`, `gt`,
175
+ `gte`, `lt`, `lte`, `hasKey`, `contains`, `stringContains`, `stringStartsWith`,
176
+ `stringEndsWith`, plus `mode` (which applies to the three `string*` operators).
177
+ `contains` on a JSON column is containment (`@>`, a whole sub-document such as
178
+ `{ contains: { panel: { seats: 4 } } }`), NOT a substring test; the substring
179
+ operators on a path are `stringContains`, `stringStartsWith` and
180
+ `stringEndsWith`. `not`, `in`, `notIn`, `startsWith` and `endsWith` are refused
181
+ with `TURBINE_E003` listing the accepted set (the last two point at their
182
+ `string*` spelling); express `not` / `in` with `NOT` / `OR` around the path
183
+ filter instead.
184
+
185
+ `groupBy` accepts a JSON path as a grouping key via `{ field, path }`, and its
186
+ `_sum`, `_avg`, `_min` and `_max` accept the same `{ field, path }` object under
187
+ an alias key: `_sum: { score: { field: 'tastingNotes', path: ['panel', 'score'] } }`
188
+ comes back as `_sum.score`. `_sum` / `_avg` cast the value to numeric; `_min` /
189
+ `_max` compare as text unless `type: 'numeric'`. **`aggregate()` has no JSON-path
190
+ form**: every key there must be a column (anything else is `TURBINE_E003`) and
191
+ every value `true`; an object value is read as `true`, so the whole column
192
+ reaches the database as `avg(jsonb)` and fails there with a database error, not
193
+ a Turbine code. **`_count` never takes a path**, in `groupBy` or `aggregate()`:
194
+ an object under a `_count` key is read as `true` (a count of that column's
195
+ non-null values) and any path in it is ignored. To count per JSON value, group
196
+ by the path and read `_count`.
183
197
 
184
198
  ## Aggregates
185
199
 
@@ -192,6 +206,11 @@ await db.ripeningChecks.aggregate({
192
206
  });
193
207
  ```
194
208
 
209
+ `_sum` / `_avg` over an `int8` / `bigint` or `numeric` / `decimal` column return
210
+ PostgreSQL's exact text as a **string** (every value, not only large ones; `AVG`
211
+ is not float-cast there); over `int4` / `float` columns and over a JSON path they
212
+ return a number. `_min` / `_max` return the column's own type.
213
+
195
214
  ## groupBy and having
196
215
 
197
216
  `by` lists the grouping columns. The `having` shape is **column first, aggregate
@@ -219,13 +238,21 @@ where: { guildId_batchRef: { guildId: 4, batchRef: 'WB-0007' } }
219
238
  where: { guildId: 4, batchRef: 'WB-0007' } // equivalent
220
239
  ```
221
240
 
241
+ ## Single-row writes carry the same rule
242
+
243
+ `update`, `delete` and `upsert` each require a `where` that identifies one row,
244
+ by the same rule as `findUnique`, and anything else is `TURBINE_E003` naming the
245
+ keys that would work. For an upsert the `where` **is** the `ON CONFLICT` target,
246
+ so a non-unique predicate is refused rather than sent. Use `updateMany` and
247
+ `deleteMany` for "every row matching a filter".
248
+
222
249
  ## `distinct`
223
250
 
224
251
  `distinct: ['status']` de-duplicates on those columns.
225
252
 
226
253
  ## Errors worth branching on
227
254
 
228
- Every error extends `TurbineError` and carries a stable `code`. The ones a query
255
+ Every error Turbine raises extends `TurbineError` and carries a stable `code`; an unmapped driver SQLSTATE stays the driver's error, with that SQLSTATE on `.code`. The ones a query
229
256
  produces:
230
257
 
231
258
  | code | class | means |