turbine-orm 0.76.0 → 0.77.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 (87) hide show
  1. package/dist/cjs/cli/index.js +2 -2
  2. package/dist/cjs/cli/migrate.js +8 -8
  3. package/dist/cjs/cli/studio.js +6 -3
  4. package/dist/cjs/client.js +10 -10
  5. package/dist/cjs/dialect.js +1 -1
  6. package/dist/cjs/errors.d.ts +2 -2
  7. package/dist/cjs/errors.js +21 -25
  8. package/dist/cjs/generate.js +1 -1
  9. package/dist/cjs/introspect.js +5 -5
  10. package/dist/cjs/mssql.js +12 -12
  11. package/dist/cjs/mysql.js +5 -5
  12. package/dist/cjs/nested-write.js +31 -31
  13. package/dist/cjs/observe.js +1 -1
  14. package/dist/cjs/pipeline-submittable.js +5 -1
  15. package/dist/cjs/pipeline.js +8 -0
  16. package/dist/cjs/powdb-introspect.d.ts +1 -1
  17. package/dist/cjs/powdb-introspect.js +4 -4
  18. package/dist/cjs/powdb-shared.d.ts +348 -0
  19. package/dist/cjs/powdb-shared.js +587 -0
  20. package/dist/cjs/powdb.d.ts +12 -299
  21. package/dist/cjs/powdb.js +106 -567
  22. package/dist/cjs/powql.d.ts +1 -1
  23. package/dist/cjs/powql.js +89 -89
  24. package/dist/cjs/prisma-compat.js +16 -16
  25. package/dist/cjs/query/aggregates.js +19 -20
  26. package/dist/cjs/query/batched-loader.js +8 -8
  27. package/dist/cjs/query/builder.js +8 -8
  28. package/dist/cjs/query/compound-unique.js +2 -2
  29. package/dist/cjs/query/filters.js +1 -1
  30. package/dist/cjs/query/relations.js +60 -48
  31. package/dist/cjs/query/types.js +4 -4
  32. package/dist/cjs/query/utils.js +5 -5
  33. package/dist/cjs/query/warn-registry.d.ts +7 -0
  34. package/dist/cjs/query/warn-registry.js +7 -0
  35. package/dist/cjs/query/where-compile.js +1 -1
  36. package/dist/cjs/query/where.js +30 -31
  37. package/dist/cjs/query/writes.js +7 -7
  38. package/dist/cjs/realtime.js +4 -4
  39. package/dist/cjs/schema-metadata.js +1 -1
  40. package/dist/cjs/schema-sql.js +3 -3
  41. package/dist/cjs/seed.js +1 -1
  42. package/dist/cjs/sqlite.js +7 -7
  43. package/dist/cjs/typed-sql.js +1 -1
  44. package/dist/cli/index.js +2 -2
  45. package/dist/cli/migrate.js +8 -8
  46. package/dist/cli/studio.js +6 -3
  47. package/dist/client.js +10 -10
  48. package/dist/dialect.js +1 -1
  49. package/dist/errors.d.ts +2 -2
  50. package/dist/errors.js +21 -25
  51. package/dist/generate.js +1 -1
  52. package/dist/introspect.js +5 -5
  53. package/dist/mssql.js +12 -12
  54. package/dist/mysql.js +5 -5
  55. package/dist/nested-write.js +31 -31
  56. package/dist/observe.js +1 -1
  57. package/dist/pipeline-submittable.js +6 -2
  58. package/dist/pipeline.js +9 -1
  59. package/dist/powdb-introspect.d.ts +1 -1
  60. package/dist/powdb-introspect.js +2 -2
  61. package/dist/powdb-shared.d.ts +348 -0
  62. package/dist/powdb-shared.js +570 -0
  63. package/dist/powdb.d.ts +12 -299
  64. package/dist/powdb.js +71 -534
  65. package/dist/powql.d.ts +1 -1
  66. package/dist/powql.js +43 -43
  67. package/dist/prisma-compat.js +16 -16
  68. package/dist/query/aggregates.js +19 -20
  69. package/dist/query/batched-loader.js +8 -8
  70. package/dist/query/builder.js +8 -8
  71. package/dist/query/compound-unique.js +2 -2
  72. package/dist/query/filters.js +1 -1
  73. package/dist/query/relations.js +60 -48
  74. package/dist/query/types.js +4 -4
  75. package/dist/query/utils.js +5 -5
  76. package/dist/query/warn-registry.d.ts +7 -0
  77. package/dist/query/warn-registry.js +7 -0
  78. package/dist/query/where-compile.js +1 -1
  79. package/dist/query/where.js +30 -31
  80. package/dist/query/writes.js +7 -7
  81. package/dist/realtime.js +4 -4
  82. package/dist/schema-metadata.js +1 -1
  83. package/dist/schema-sql.js +3 -3
  84. package/dist/seed.js +1 -1
  85. package/dist/sqlite.js +7 -7
  86. package/dist/typed-sql.js +1 -1
  87. package/package.json +15 -7
@@ -533,7 +533,7 @@ allowFullTableScan) {
533
533
  return;
534
534
  if (allowFullTableScan === true)
535
535
  return;
536
- throw new ValidationError(`[turbine] ${operation} on "${qi.table}" refused: the \`where\` clause is empty. ` +
536
+ throw new ValidationError(`${operation} on "${qi.table}" refused: the \`where\` clause is empty. ` +
537
537
  "Pass `allowFullTableScan: UNSAFE` to opt in (import { UNSAFE } from 'turbine-orm'), " +
538
538
  'or check that your filter values are defined.');
539
539
  }
@@ -625,13 +625,13 @@ export function buildScalarClause(qi, key, value, params, andClauses) {
625
625
  // A JSON-only operator on a non-JSON column was almost certainly a typo
626
626
  // or schema mismatch. `contains`/`equals` are shared with WhereOperator
627
627
  // (LIKE / equality), so only shape-unique keys reach here.
628
- throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${qi.table}" is not a JSON column ` +
628
+ throw new ValidationError(`Column "${rawColumn}" on table "${qi.table}" is not a JSON column ` +
629
629
  `(actual type: ${getColumnPgType(qi, rawColumn)}); cannot apply JSON operator '${cls.jsonKey}'.`);
630
630
  case 'array':
631
631
  andClauses.push(...buildArrayFilterClauses(qi, column, value, params, cls.colType));
632
632
  return;
633
633
  case 'arrayThrow':
634
- throw new ValidationError(`[turbine] Column "${rawColumn}" on table "${qi.table}" is not an array column ` +
634
+ throw new ValidationError(`Column "${rawColumn}" on table "${qi.table}" is not an array column ` +
635
635
  `(actual type: ${getColumnPgType(qi, rawColumn)}); cannot apply array operator '${cls.arrayKey}'.`);
636
636
  case 'textsearch':
637
637
  andClauses.push(buildTextSearchClause(qi, column, value, params));
@@ -689,7 +689,7 @@ export function relationWhereScope(qi, targetTable, meta) {
689
689
  qualifier: `${qi.q(targetTable)}.`,
690
690
  relationParent: targetTable,
691
691
  host: scopedWhereHost(qi, meta),
692
- unknownColumn: (field) => new ValidationError(`[turbine] Unknown field "${field}" in relation filter for table "${targetTable}". ` +
692
+ unknownColumn: (field) => new ValidationError(`Unknown field "${field}" in relation filter for table "${targetTable}". ` +
693
693
  `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`),
694
694
  };
695
695
  }
@@ -701,7 +701,7 @@ export function aliasWhereScope(qi, targetTable, meta, alias) {
701
701
  qualifier: `${alias}.`,
702
702
  relationParent: alias,
703
703
  host: scopedWhereHost(qi, meta),
704
- unknownColumn: (field) => new ValidationError(`[turbine] Unknown column "${field}" in where for table "${targetTable}"`),
704
+ unknownColumn: (field) => new ValidationError(`Unknown column "${field}" in where for table "${targetTable}"`),
705
705
  };
706
706
  }
707
707
  /**
@@ -776,7 +776,7 @@ export function buildScopedScalarClause(qi, scope, field, value, params, clauses
776
776
  }
777
777
  const jsonKey = findJsonUniqueKey(value);
778
778
  if (jsonKey) {
779
- throw new ValidationError(`[turbine] Column "${col}" on table "${scope.table}" is not a JSON column ` +
779
+ throw new ValidationError(`Column "${col}" on table "${scope.table}" is not a JSON column ` +
780
780
  `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
781
781
  }
782
782
  }
@@ -788,7 +788,7 @@ export function buildScopedScalarClause(qi, scope, field, value, params, clauses
788
788
  }
789
789
  const arrayKey = findArrayUniqueKey(value);
790
790
  if (arrayKey) {
791
- throw new ValidationError(`[turbine] Column "${col}" on table "${scope.table}" is not an array column ` +
791
+ throw new ValidationError(`Column "${col}" on table "${scope.table}" is not an array column ` +
792
792
  `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
793
793
  }
794
794
  }
@@ -929,23 +929,23 @@ depth = 0) {
929
929
  // nested relation filters inside the branch keep their qualification. The
930
930
  // fragment binds no params, so collectRelationFilterParams needs no mirror.
931
931
  if (!relDef.through) {
932
- throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
932
+ throw new ValidationError(`manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
933
933
  }
934
934
  const qJunction = qi.q(relDef.through.table);
935
935
  const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
936
936
  const targetPk = targetMeta.primaryKey;
937
937
  if (targetPk.length === 0) {
938
- throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
938
+ throw new ValidationError(`manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
939
939
  `cannot correlate the relation filter through the junction.`);
940
940
  }
941
941
  if (targetKeys.length !== targetPk.length) {
942
- throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
942
+ throw new ValidationError(`manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
943
943
  `but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
944
944
  }
945
945
  const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
946
946
  const refKeys = normalizeKeyColumns(relDef.referenceKey);
947
947
  if (sourceKeys.length !== refKeys.length) {
948
- throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
948
+ throw new ValidationError(`manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
949
949
  `but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
950
950
  }
951
951
  const targetLink = targetKeys
@@ -1134,9 +1134,9 @@ export function assertBindableEqualityValue(qi, rawColumn, value, columnPgType,
1134
1134
  return;
1135
1135
  const badKeys = Object.keys(value);
1136
1136
  throw new ValidationError(badKeys.length === 0
1137
- ? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
1137
+ ? `Empty filter object on "${rawColumn}" for table "${table}". ` +
1138
1138
  `Provide a value or an operator like { gt: 1 }.`
1139
- : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
1139
+ : `Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
1140
1140
  `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
1141
1141
  `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
1142
1142
  }
@@ -1179,13 +1179,13 @@ export function fingerprintAliasWhere(qi, where, targetTable) {
1179
1179
  */
1180
1180
  export function resolveColumnRef(_qi, ref, ctx, mode) {
1181
1181
  if (mode === 'insensitive') {
1182
- throw new ValidationError(`[turbine] mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
1182
+ throw new ValidationError(`mode: 'insensitive' cannot be combined with a column reference ({ col: "${ref.col}" }). ` +
1183
1183
  `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
1184
1184
  `for lower(a) = lower(b).`);
1185
1185
  }
1186
1186
  const col = resolveColumnName(ctx.meta, ref.col);
1187
1187
  if (col === undefined) {
1188
- throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
1188
+ throw new ValidationError(`Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
1189
1189
  `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
1190
1190
  }
1191
1191
  return col;
@@ -1197,7 +1197,7 @@ export function resolveColumnRef(_qi, ref, ctx, mode) {
1197
1197
  */
1198
1198
  export function columnRefSql(qi, ref, ctx, mode) {
1199
1199
  if (!ctx) {
1200
- throw new ValidationError(`[turbine] Column reference { col: "${ref.col}" } is not supported in this filter context.`);
1200
+ throw new ValidationError(`Column reference { col: "${ref.col}" } is not supported in this filter context.`);
1201
1201
  }
1202
1202
  return `${ctx.prefix}${qi.q(resolveColumnRef(qi, ref, ctx, mode))}`;
1203
1203
  }
@@ -1366,7 +1366,7 @@ export function vectorOperator(qi, field, rawColumn, metric) {
1366
1366
  }
1367
1367
  const colType = getColumnPgType(qi, rawColumn);
1368
1368
  if (colType !== 'vector') {
1369
- throw new ValidationError(`[turbine] Column "${field}" on table "${qi.table}" is not a vector column ` +
1369
+ throw new ValidationError(`Column "${field}" on table "${qi.table}" is not a vector column ` +
1370
1370
  `(actual type: ${colType}); cannot apply a vector distance operation.`);
1371
1371
  }
1372
1372
  // ownLookup, not a bare index: an inherited Object.prototype member
@@ -1374,7 +1374,7 @@ export function vectorOperator(qi, field, rawColumn, metric) {
1374
1374
  // and be spliced into the ORDER BY / WHERE clause as its source text.
1375
1375
  const op = ownLookup(VECTOR_METRIC_OPERATORS, metric);
1376
1376
  if (!op) {
1377
- throw new ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
1377
+ throw new ValidationError(`Unknown vector metric "${metric}" for column "${field}". ` +
1378
1378
  `Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
1379
1379
  }
1380
1380
  return op;
@@ -1391,11 +1391,11 @@ export function pushVectorParam(qi, field, _rawColumn, to, params) {
1391
1391
  throw new UnsupportedFeatureError('pgvector distance operations', qi.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
1392
1392
  }
1393
1393
  if (!Array.isArray(to) || to.length === 0) {
1394
- throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
1394
+ throw new ValidationError(`Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
1395
1395
  }
1396
1396
  for (const el of to) {
1397
1397
  if (typeof el !== 'number' || !Number.isFinite(el)) {
1398
- throw new ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` + `got ${JSON.stringify(el)}.`);
1398
+ throw new ValidationError(`Vector "to" for column "${field}" must contain only finite numbers; ` + `got ${JSON.stringify(el)}.`);
1399
1399
  }
1400
1400
  }
1401
1401
  // Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
@@ -1501,14 +1501,14 @@ export function assertJsonFilterKeys(filter, column) {
1501
1501
  if (JSON_FILTER_KEYS.has(key))
1502
1502
  continue;
1503
1503
  const suggestion = JSON_PRISMA_SPELLINGS[key];
1504
- throw new ValidationError(`[turbine] Unknown JSON filter operator "${key}" on ${column}.` +
1504
+ throw new ValidationError(`Unknown JSON filter operator "${key}" on ${column}.` +
1505
1505
  (suggestion ? ` Did you mean \`${suggestion}\`?` : '') +
1506
1506
  ` Supported operators: ${[...JSON_FILTER_KEYS].sort().join(', ')}.`);
1507
1507
  }
1508
1508
  // `mode` and `path` are modifiers, not comparisons: a filter made only of
1509
1509
  // those compares nothing, which is exactly the silent no-op shape above.
1510
1510
  if (present.length > 0 && present.every((k) => k === 'path' || k === 'mode')) {
1511
- throw new ValidationError(`[turbine] JSON filter on ${column} selects a \`path\` but has no comparison. ` +
1511
+ throw new ValidationError(`JSON filter on ${column} selects a \`path\` but has no comparison. ` +
1512
1512
  `Add one of: ${[...JSON_FILTER_KEYS]
1513
1513
  .filter((k) => k !== 'path' && k !== 'mode')
1514
1514
  .sort()
@@ -1540,14 +1540,13 @@ export function jsonStringEntries(filter, column) {
1540
1540
  if (value === undefined)
1541
1541
  continue;
1542
1542
  if (filter.path === undefined) {
1543
- throw new ValidationError(`[turbine] JSON operator '${op}' on ${column} requires a \`path\` ` +
1543
+ throw new ValidationError(`JSON operator '${op}' on ${column} requires a \`path\` ` +
1544
1544
  `(e.g. { path: ['meta', 'title'], ${op}: ${valueForMessage(value)} }).`);
1545
1545
  }
1546
1546
  if (typeof value !== 'string') {
1547
1547
  // `typeof` rather than the value: it says everything the reader needs
1548
1548
  // (they passed a number where a string belongs) and carries no data.
1549
- throw new ValidationError(`[turbine] JSON operator '${op}' on ${column} requires a string, got ${typeof value} ` +
1550
- `(${valueForMessage(value)}).`);
1549
+ throw new ValidationError(`JSON operator '${op}' on ${column} requires a string, got ${typeof value} ` + `(${valueForMessage(value)}).`);
1551
1550
  }
1552
1551
  entries.push({ op, pattern, value });
1553
1552
  }
@@ -1560,15 +1559,15 @@ export function jsonRangeEntries(_qi, filter, column) {
1560
1559
  if (value === undefined)
1561
1560
  continue;
1562
1561
  if (filter.path === undefined) {
1563
- throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
1562
+ throw new ValidationError(`JSON range operator '${op}' on ${column} requires a \`path\` ` +
1564
1563
  `(e.g. { path: ['meta', 'score'], ${op}: ${valueForMessage(value)} }).`);
1565
1564
  }
1566
1565
  if (typeof value !== 'number' && typeof value !== 'string') {
1567
- throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
1566
+ throw new ValidationError(`JSON range operator '${op}' on ${column} requires a number or string, ` +
1568
1567
  `got ${typeof value} (${valueForMessage(value)}).`);
1569
1568
  }
1570
1569
  if (typeof value === 'number' && !Number.isFinite(value)) {
1571
- throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
1570
+ throw new ValidationError(`JSON range operator '${op}' on ${column} requires a finite number.`);
1572
1571
  }
1573
1572
  entries.push({ sqlOp, value });
1574
1573
  }
@@ -1753,13 +1752,13 @@ export function vectorThresholdEntries(filter, field) {
1753
1752
  if (threshold === undefined)
1754
1753
  continue;
1755
1754
  if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
1756
- throw new ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
1755
+ throw new ValidationError(`Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
1757
1756
  `got ${typeof threshold} (${valueForMessage(threshold)}).`);
1758
1757
  }
1759
1758
  entries.push({ sqlOp, threshold });
1760
1759
  }
1761
1760
  if (entries.length === 0) {
1762
- throw new ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
1761
+ throw new ValidationError(`Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
1763
1762
  }
1764
1763
  return entries;
1765
1764
  }
@@ -1771,7 +1770,7 @@ export function buildTextSearchClause(qi, column, filter, params) {
1771
1770
  requireFullTextSearch(qi);
1772
1771
  const config = filter.config ?? 'english';
1773
1772
  if (!validateTextSearchConfig(config)) {
1774
- throw new ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
1773
+ throw new ValidationError(`Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
1775
1774
  }
1776
1775
  params.push(filter.search);
1777
1776
  return `to_tsvector('${config}', ${column}) @@ to_tsquery('${config}', ${qi.p(params.length)})`;
@@ -138,7 +138,7 @@ export function buildCreate(qi, args) {
138
138
  throw new NotFoundError({
139
139
  table: qi.table,
140
140
  operation: 'create',
141
- message: `[turbine] create on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
141
+ message: `create on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
142
142
  });
143
143
  }
144
144
  return parseWriteRow(qi, row);
@@ -250,7 +250,7 @@ function assertUniformCreateManyRows(qi, rows, firstKeys) {
250
250
  parts.push(`does not supply ${quoteList(missing)}`);
251
251
  if (unexpected.length > 0)
252
252
  parts.push(`supplies ${quoteList(unexpected)}, which the first row does not`);
253
- throw new ValidationError(`[turbine] createMany on "${qi.table}": row ${i} ${parts.join(' and ')}. ` +
253
+ throw new ValidationError(`createMany on "${qi.table}": row ${i} ${parts.join(' and ')}. ` +
254
254
  'Every row must supply the same fields: createMany builds ONE statement whose column list comes from the ' +
255
255
  "first row, so a field a later row omits would be written as NULL over that column's default, and a field " +
256
256
  'only a later row names would be dropped. Supply the field explicitly on every row (a field set to ' +
@@ -581,7 +581,7 @@ export function buildUpsert(qi, args) {
581
581
  table: qi.table,
582
582
  where: args.where,
583
583
  operation: 'upsert',
584
- message: `[turbine] upsert on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
584
+ message: `upsert on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
585
585
  });
586
586
  }
587
587
  return parseWriteRow(qi, row);
@@ -823,7 +823,7 @@ export function parseWriteRow(qi, row) {
823
823
  */
824
824
  export function assertWritable(qi, operation) {
825
825
  if (qi.tableMeta.isView) {
826
- throw new ValidationError(`[turbine] Cannot ${operation} "${qi.table}": it is a view (read-only). ` +
826
+ throw new ValidationError(`Cannot ${operation} "${qi.table}": it is a view (read-only). ` +
827
827
  'Views support reads (findMany/findFirst/…) but not writes.');
828
828
  }
829
829
  }
@@ -840,7 +840,7 @@ export function assertNoGeneratedColumns(qi, data, operation) {
840
840
  continue;
841
841
  const col = qi.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
842
842
  if (col?.isGeneratedStored) {
843
- throw new ValidationError(`[turbine] Cannot ${operation} "${qi.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
843
+ throw new ValidationError(`Cannot ${operation} "${qi.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
844
844
  'column whose value the database computes, remove it from your data.');
845
845
  }
846
846
  }
@@ -884,7 +884,7 @@ export function buildSetClause(qi, key, value, params) {
884
884
  }
885
885
  // Arithmetic operators: must be finite numbers
886
886
  if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
887
- throw new ValidationError(`[turbine] update operator "${op}" on "${qi.table}.${key}" requires a finite number, got ${typeof opValue}`);
887
+ throw new ValidationError(`update operator "${op}" on "${qi.table}.${key}" requires a finite number, got ${typeof opValue}`);
888
888
  }
889
889
  if (op === 'increment') {
890
890
  params.push(opValue);
@@ -938,7 +938,7 @@ export function assertBindableSetValue(qi, key, value) {
938
938
  const column = qi.toColumn(key);
939
939
  if (whereMod.isJsonColumnType(qi, whereMod.getColumnPgType(qi, column)))
940
940
  return;
941
- throw new ValidationError(`[turbine] Unknown update operator "${keys[0]}" on "${qi.table}.${key}". ` +
941
+ throw new ValidationError(`Unknown update operator "${keys[0]}" on "${qi.table}.${key}". ` +
942
942
  `Supported: ${[...UPDATE_OPERATOR_KEYS].join(', ')}. ` +
943
943
  'A plain object is only written as a value on a json/jsonb column.');
944
944
  }
package/dist/realtime.js CHANGED
@@ -48,13 +48,13 @@ const MAX_CHANNEL_LEN = 63;
48
48
  */
49
49
  export function validateChannel(channel) {
50
50
  if (typeof channel !== 'string' || channel.length === 0) {
51
- throw new ValidationError('[turbine] $listen/$notify channel must be a non-empty string');
51
+ throw new ValidationError('$listen/$notify channel must be a non-empty string');
52
52
  }
53
53
  if (channel.length > MAX_CHANNEL_LEN) {
54
- throw new ValidationError(`[turbine] $listen/$notify channel "${channel}" exceeds the ${MAX_CHANNEL_LEN}-character Postgres identifier limit`);
54
+ throw new ValidationError(`$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(`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
  }
@@ -82,7 +82,7 @@ export async function createSubscription(pool, channel, quotedChannel, handler,
82
82
  // give the connection straight back.
83
83
  if (typeof client.on !== 'function') {
84
84
  client.release?.();
85
- throw new ConnectionError('[turbine] $listen requires a persistent connection that can push notifications. ' +
85
+ throw new ConnectionError('$listen requires a persistent connection that can push notifications. ' +
86
86
  'The configured pool returned a client with no event support (stateless HTTP drivers ' +
87
87
  'like Neon HTTP / Vercel Postgres cannot LISTEN). Use a TCP pg.Pool for LISTEN/NOTIFY.');
88
88
  }
@@ -130,7 +130,7 @@ function mapIndexes(tableDef, declared) {
130
130
  // index instead of emitting malformed PowQL later.
131
131
  for (const seg of idx.path) {
132
132
  if (typeof seg === 'number' && (!Number.isInteger(seg) || seg < 0)) {
133
- throw new ValidationError(`[turbine] Doc-field index "${name}" on "${tableDef.name}": array-index path segment ${seg} must be a ` +
133
+ throw new ValidationError(`Doc-field index "${name}" on "${tableDef.name}": array-index path segment ${seg} must be a ` +
134
134
  'non-negative integer (a JSON array index). Use a string for an object key.');
135
135
  }
136
136
  }
@@ -71,7 +71,7 @@ function resolveDdlType(config, dialect, columnName) {
71
71
  const dims = config.vectorDimensions;
72
72
  // pgvector caps a `vector` column at 16000 dimensions.
73
73
  if (typeof dims !== 'number' || !Number.isInteger(dims) || dims < 1 || dims > 16000) {
74
- throw new ValidationError(`[turbine] Column "${columnName}": vector dimensions must be an integer between 1 and 16000, got ${String(dims)}.`);
74
+ throw new ValidationError(`Column "${columnName}": vector dimensions must be an integer between 1 and 16000, got ${String(dims)}.`);
75
75
  }
76
76
  base = `vector(${dims})`;
77
77
  }
@@ -1526,7 +1526,7 @@ async function pinSearchPath(client, pgSchema) {
1526
1526
  pgSchema,
1527
1527
  ]);
1528
1528
  if (!present.rows[0]?.present) {
1529
- throw new ValidationError(`[turbine] Schema "${pgSchema}" does not exist in this database. ` +
1529
+ throw new ValidationError(`Schema "${pgSchema}" does not exist in this database. ` +
1530
1530
  `Postgres accepts a missing namespace in search_path without complaint, so pushing anyway would ` +
1531
1531
  `create every table in whichever schema resolves next (usually public) and report success. ` +
1532
1532
  `Create the schema first (CREATE SCHEMA "${pgSchema}") or correct the configured schema name.`);
@@ -1542,7 +1542,7 @@ async function pinSearchPath(client, pgSchema) {
1542
1542
  }
1543
1543
  /** Format the destructive-push refusal message (mirrors the migrate gate copy). */
1544
1544
  function formatDestructivePushError(hits) {
1545
- const lines = ['[turbine] Refusing to apply schema changes containing DESTRUCTIVE statements:', ''];
1545
+ const lines = ['Refusing to apply schema changes containing DESTRUCTIVE statements:', ''];
1546
1546
  for (const h of hits) {
1547
1547
  lines.push(` - [${h.kind}] ${h.target}: ${DESTRUCTIVE_KIND_LABEL[h.kind]}`);
1548
1548
  }
package/dist/seed.js CHANGED
@@ -128,7 +128,7 @@ function markSeedRan() {
128
128
  async function runSeed(fn) {
129
129
  const connectionString = process.env.DATABASE_URL;
130
130
  if (!connectionString) {
131
- throw new ConnectionError('[turbine] DATABASE_URL is required to run this seed.');
131
+ throw new ConnectionError('DATABASE_URL is required to run this seed.');
132
132
  }
133
133
  const db = new TurbineClient({ connectionString }, emptySchema);
134
134
  try {
package/dist/sqlite.js CHANGED
@@ -108,11 +108,11 @@ function loadDatabaseSync() {
108
108
  ctor = req('node:sqlite').DatabaseSync;
109
109
  }
110
110
  catch (err) {
111
- throw new ConnectionError("[turbine] turbine-orm/sqlite requires Node's built-in 'node:sqlite' module (Node >= 22.5). " +
111
+ throw new ConnectionError("turbine-orm/sqlite requires Node's built-in 'node:sqlite' module (Node >= 22.5). " +
112
112
  `Upgrade Node to >= 22.5, or pass an already-open better-sqlite3-compatible handle. (${err.message})`);
113
113
  }
114
114
  if (typeof ctor !== 'function') {
115
- throw new ConnectionError("[turbine] 'node:sqlite' loaded but did not export a DatabaseSync constructor, this Node build may lack SQLite support.");
115
+ throw new ConnectionError("'node:sqlite' loaded but did not export a DatabaseSync constructor, this Node build may lack SQLite support.");
116
116
  }
117
117
  cachedDatabaseSync = ctor;
118
118
  return cachedDatabaseSync;
@@ -363,17 +363,17 @@ function augmentSqliteError(err) {
363
363
  function sqliteLogicError(message, cause) {
364
364
  const missingTable = /^no such table:\s*(\S+)/i.exec(message);
365
365
  if (missingTable) {
366
- return new ValidationError(`[turbine] SQLite has no table "${missingTable[1]}": ${message}. ` +
366
+ return new ValidationError(`SQLite has no table "${missingTable[1]}": ${message}. ` +
367
367
  'Create it first (run your migrations, or execute the CREATE TABLE statements for this schema); ' +
368
368
  'an in-memory database starts empty on every connection.', { cause });
369
369
  }
370
370
  const missingColumn = /^no such column:\s*(\S+)/i.exec(message);
371
371
  if (missingColumn) {
372
- return new ValidationError(`[turbine] SQLite has no column "${missingColumn[1]}": ${message}. ` +
372
+ return new ValidationError(`SQLite has no column "${missingColumn[1]}": ${message}. ` +
373
373
  'Check the spelling against the table as it exists in this database, ' +
374
374
  'and re-run `turbine generate` if the schema has changed.', { cause });
375
375
  }
376
- return new ValidationError(`[turbine] SQLite rejected the statement: ${message}`, { cause });
376
+ return new ValidationError(`SQLite rejected the statement: ${message}`, { cause });
377
377
  }
378
378
  function normalizeQueryArgs(arg, values) {
379
379
  if (typeof arg === 'string')
@@ -987,7 +987,7 @@ export async function introspectSqlite(options) {
987
987
  db = new DatabaseSync(options.connectionString);
988
988
  }
989
989
  catch (err) {
990
- throw new ConnectionError(`[turbine] Failed to open SQLite database "${options.connectionString}": ${err.message}`);
990
+ throw new ConnectionError(`Failed to open SQLite database "${options.connectionString}": ${err.message}`);
991
991
  }
992
992
  try {
993
993
  return introspectSqliteDatabase(db, { include: options.include, exclude: options.exclude });
@@ -1003,7 +1003,7 @@ function openSqliteDatabase(target, options) {
1003
1003
  db = new DatabaseSync(target);
1004
1004
  }
1005
1005
  catch (err) {
1006
- throw new ConnectionError(`[turbine] Failed to open SQLite database "${target}": ${err.message}`);
1006
+ throw new ConnectionError(`Failed to open SQLite database "${target}": ${err.message}`);
1007
1007
  }
1008
1008
  db.exec(`PRAGMA busy_timeout = ${Number(options.busyTimeoutMs ?? 5000)}`);
1009
1009
  if (options.foreignKeys !== false)
package/dist/typed-sql.js CHANGED
@@ -66,7 +66,7 @@ export function buildTypedSql(strings, values, dialect = postgresDialect) {
66
66
  // silently desync placeholders from params (pg would reject it, but fail
67
67
  // loudly here instead).
68
68
  if (strings.length !== values.length + 1) {
69
- throw new ValidationError(`[turbine] sql template segment/value count mismatch: ${strings.length} segments, ${values.length} values.`);
69
+ throw new ValidationError(`sql template segment/value count mismatch: ${strings.length} segments, ${values.length} values.`);
70
70
  }
71
71
  let sql = '';
72
72
  for (let i = 0; i < strings.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.76.0",
3
+ "version": "0.77.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).",
@@ -125,10 +125,14 @@
125
125
  "test:unit": "DATABASE_URL= tsx --test src/test/*.test.ts",
126
126
  "//test:coverage": "Collection and reporting are SEPARATE, and the report is not c8's. c8 merges every process's V8 coverage into one ProcessCov before converting to istanbul, and that merge is not monotonic on a run this size: measured on this suite, query/relations.ts read 3043 covered lines at 240 process files and 1333 at 359, and a 448-test SUBSET read 91.88% where the full 5,819-test superset read 41.78%. scripts/coverage-report.mjs keeps every other c8 behaviour (same .c8rc.json, same reporters, same checkCoverages gate) and only moves the merge to the istanbul level, where it is additive and cannot go backwards. Collection is a bare NODE_V8_COVERAGE, which is exactly what the c8 wrapper set; dropping the wrapper also drops the broken merge from the critical path instead of running it and discarding the result.",
127
127
  "test:coverage": "rm -rf coverage/tmp && mkdir -p coverage/tmp && NODE_V8_COVERAGE=coverage/tmp tsx --test --test-concurrency=1 src/test/*.test.ts && node scripts/coverage-report.mjs",
128
+ "//coverage:codegen": "The ratchet for the two code-generation modules, which .c8rc.json excludes from the main gate and which therefore sat in no gate at all. introspect.ts carries a stated correctness invariant (determinism) and generate.ts writes executable TypeScript from database-controlled identifiers, which is the highest-consequence untested code in the package. Both were excluded from all measurement. Same ratchet argument. Adds NO test execution: it re-reports the coverage `npm run test:coverage` already wrote to coverage/tmp, under .c8rc.codegen.json, so it must run AFTER that command. It goes through scripts/coverage-report.mjs rather than `c8 report` because the main collection is 418 process files, past the point where c8's own merge stops being monotonic (see //merge-bug in .c8rc.json). Floors, thresholds and the per-file reasoning live in .c8rc.codegen.json.",
129
+ "coverage:codegen": "node scripts/coverage-report.mjs --config .c8rc.codegen.json",
130
+ "//check:coverage-sync": "The three-way coupling between a coverage collection and the floors that hold it. A file can be added to coverage:cli:collect and then be held by NOTHING, which is not a missing gate but a slower lane enforcing a floor of zero, and it has happened twice (mcp.ts and error-catalog.ts each spent a release that way). The invariant is that every file in the collection is named by a per-file gate OR by the aggregate, NOT that the collection and the aggregate are equal: cli/index.ts is deliberately outside the aggregate, see //coverage:cli:gate:aggregate. It also refuses a gate that names a file nobody collects, which measures nothing and passes, and it fails closed on an empty parse. It runs FIRST in test:coverage:cli, ahead of the collection, because it needs no coverage data and its failure names the missing wiring instead of a number.",
131
+ "check:coverage-sync": "node scripts/check-coverage-gate-sync.mjs",
128
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.",
129
- "test:coverage:cli": "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:aggregate",
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",
130
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.",
131
- "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 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",
132
136
  "coverage:cli:gate": "c8 report --all --exclude 'src/test/**' --reporter text --check-coverage",
133
137
  "coverage:cli:gate:destructive": "npm run coverage:cli:gate -- --include src/cli/destructive.ts --lines 98 --statements 98 --branches 84 --functions 98",
134
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",
@@ -138,26 +142,30 @@
138
142
  "//coverage:cli:gate:compile-query": "compile_query's core: the CLOSED set of read operations an agent may compile, the sealed pool that makes 'never executes' structural rather than promised, and the aggregate-arg name harvest the PII refusal runs on. Every one of those is a refusal boundary, which is the same reason destructive.ts and pii-predicate-guard.ts carry their own floors. It gets a gate the SAME commit it joins coverage:cli:collect and the aggregate --include list, because a file in the collection with neither is collected and enforced by nothing, which is how mcp.ts and error-catalog.ts spent a release at a floor of zero. Floors measured 2026-08-15 at 100 lines / 100 statements / 94.21 branches / 100 functions.",
139
143
  "coverage:cli:gate:compile-query": "npm run coverage:cli:gate -- --include src/cli/compile-query.ts --lines 100 --statements 100 --branches 94 --functions 100",
140
144
  "coverage:cli:gate:error-catalog": "npm run coverage:cli:gate -- --include src/cli/error-catalog.ts --lines 100 --statements 100 --branches 90 --functions 100",
145
+ "//coverage:cli:gate:index": "cli/index.ts is the largest file in the repo (5,945 lines) and was in no coverage gate at all. The .c8rc.json //cli-gate argument for the other CLI files applies verbatim: coverage could fall to zero without anything noticing. The floors are LOW on purpose and are not a target: this file is argument parsing and command dispatch whose `process.exit` paths dominate any figure set over it, and the point of the gate is that the number cannot fall silently, not that it is high. MEASURED 2026-08-23 by `npm run coverage:cli:collect` (no database), read from the json-summary reporter rather than the text table, per the //cli-gate-measurements note: 32.62 lines / 32.62 statements / 75.62 branches / 20 functions, identical in isolation and in the nine-file collection. Floors sit about two points under. Raise as coverage improves; never lower one without recording why. It is deliberately NOT in coverage:cli:gate:aggregate, see that key's note.",
146
+ "coverage:cli:gate:index": "npm run coverage:cli:gate -- --include src/cli/index.ts --lines 30 --statements 30 --branches 73 --functions 18",
141
147
  "coverage:cli:gate:studio": "npm run coverage:cli:gate -- --include src/cli/studio.ts --lines 82 --statements 82 --branches 81 --functions 86",
142
148
  "coverage:cli:gate:migrate": "npm run coverage:cli:gate -- --include src/cli/migrate.ts --lines 69 --statements 69 --branches 91 --functions 73",
143
- "//coverage:cli:gate:aggregate": "Re-baselined 2026-08-15 when compile-query.ts joined the --include list: measured 89.28 lines / 89.28 statements / 86.77 branches / 89.18 functions, up from a 87/87/85/86 floor set over seven files (itself up from 78/78/84/82 over five). Raise as coverage improves, never lower one without recording why.",
149
+ "//coverage:cli:gate:aggregate": "Re-baselined 2026-08-15 when compile-query.ts joined the --include list: measured 89.28 lines / 89.28 statements / 86.77 branches / 89.18 functions, up from a 87/87/85/86 floor set over seven files (itself up from 78/78/84/82 over five). Raise as coverage improves, never lower one without recording why. WHY cli/index.ts IS NOT HERE, although it is in coverage:cli:collect and has its own per-file gate: it is 5,945 lines at 32.62%, so folding it in drops this aggregate from a measured 89.63 lines / 87.15 branches / 89.18 functions to 66.32 / 85.76 / 63.38, which would mean LOWERING two floors by about 25 points. That is the rule above broken, and it also empties the aggregate of its purpose. This check exists only as the backstop that catches every file sagging together inside its individual margin; with one 5,945-line file at 32% dominating the denominator, the other eight could collectively shed well over a thousand covered lines with this still green. The invariant the collection actually needs is that every collected file is held by SOME floor, and index.ts is held by coverage:cli:gate:index. That is what check:coverage-sync asserts.",
144
150
  "coverage:cli:gate:aggregate": "npm run coverage:cli:gate -- --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 --lines 89 --statements 89 --branches 86 --functions 89",
145
151
  "//lint": "`--error-on-warnings` is load-bearing, not cosmetic. Biome exits 0 on a warning, so `npm run lint` passed while reporting an unused import, and both the lint CI job and prepublishOnly read only the exit code. Every rule this repo runs is one it means, so a warning nobody has to act on is a rule that should not be enabled.",
146
152
  "lint": "biome check --error-on-warnings src/",
147
153
  "lint:fix": "biome check --write src/",
148
154
  "format": "biome format --write src/",
149
155
  "check:error-codes": "tsx scripts/check-error-codes.ts",
156
+ "//check:error-prefix": "No message reaching a TurbineError may hand-write a `[turbine] ` prefix. formatErrorMessage prepends `[TURBINE_EXXX] ` unless the message already opens with that exact tag, and a hand-written `[turbine] ` never matches one, so the two stacked on roughly 355 of the package's throw sites. A prefix rule looks unnecessary because formatErrorMessage guarantees the [CODE] tag: true, and not the failure mode, which is the INTERACTION between the automatic tag and a manual one. Diagnostics that no code tag ever touches (console calls, stream writes, Studio HTTP bodies) keep the prefix and are named in the script's DIAGNOSTIC_SINKS.",
157
+ "check:error-prefix": "node scripts/check-error-prefix.mjs",
150
158
  "check:changelog": "node scripts/check-changelog-headings.mjs",
151
159
  "check:cycles": "node scripts/check-import-cycles.mjs",
152
- "//check:skip-gates": "Guards the TURBINE_REQUIRE_ENGINE coupling, which is a regex against a human-readable skipGate reason string: rewording a reason silently disarms the guard and the engine job it protects goes green over a suite that never ran. Fails on a reason no engine pattern claims and no allowlist entry excuses, on a token no CI job sets, and on a workflow token that is not a known engine.",
160
+ "//check:skip-gates": "Guards the TURBINE_REQUIRE_ENGINE coupling, which is a regex against a human-readable skipGate reason string: rewording a reason silently disarms the guard and the engine job it protects goes green over a suite that never ran. Fails on a reason no engine pattern claims and no allowlist entry excuses, on a token no CI job sets, and on a workflow token that is not a known engine. Since 0.76.0 it also fails on a test file that calls turbineSqlite() without first probing that node:sqlite exists, because that precondition is a Node VERSION rather than an env var: it holds on every dev machine and on three of the four unit-matrix legs, so a missing gate is invisible until CI, which is where it was found, after publish.",
153
161
  "check:skip-gates": "tsx scripts/check-skip-gate-reasons.ts",
154
- "//check:private-terms": "The `--all` mode of the pre-commit private-material guard, over every TRACKED file rather than the staged diff. The hook is advisory (`git commit --no-verify` skips it) and nothing downstream re-asked, so this runs in CI's lint job. `.private-terms` is gitignored, so CI enforces the builtin provenance patterns only.",
162
+ "//check:private-terms": "The `--all` mode of the pre-commit private-material guard, over every TRACKED file rather than the staged diff. The hook is advisory (`git commit --no-verify` skips it) and nothing downstream re-asked, so this runs in CI's lint job. `.private-terms` is gitignored, so CI enforces the builtin provenance patterns only. It sits on BOTH publish paths as well: prepublishOnly for a local `npm publish`, and release.yml's test job for a tag push. A leak guard reachable only from the one workflow that does not gate publishing had a single bypassable enforcement point, which for a public repo is the same as none. Scope note, `--all` enumerates with `git ls-files`, so an UNTRACKED working-tree file is outside the scan by construction; that is the right boundary here, because package.json `files` publishes dist/, skills/, LICENSE and README.md, all of which are tracked or generated from tracked sources.",
155
163
  "check:private-terms": "node scripts/check-private-terms.mjs --all",
156
164
  "check:release-tests": "node scripts/check-release-tests.mjs",
157
165
  "check:package": "publint --strict && attw --pack . --profile node16",
158
166
  "//check:package-types": "The @types/pg tripwire, and the permanent guard on the v0.28.1 regression: no reference to the pg module family may reach a published .d.ts, or a types-only package becomes a hard requirement for every consumer compiling under strict. It lived as an inline grep in ONE ci.yml job, which left the gate proving the change safe off the publish path entirely (a tag push and a local `npm publish` both reached npm without asking). The logic is scripts/check-no-pg-types.mjs now and this script is its single entry point, called by prepublishOnly and by the `consumer-types` job in BOTH ci.yml and release.yml. Reads dist/, so it must run after `npm run build`.",
159
167
  "check:package-types": "node scripts/check-no-pg-types.mjs",
160
- "prepublishOnly": "npm run build && npm run typecheck && npm run lint && npm run test:unit && npm run test:coverage:cli && npm run check:error-codes && npm run check:changelog && npm run check:cycles && npm run check:skip-gates && npm run check:package && npm run check:package-types && npm run size && npm run check:release-tests",
168
+ "prepublishOnly": "npm run build && npm run typecheck && npm run lint && npm run test:unit && npm run test:coverage:cli && npm run check:error-codes && npm run check:error-prefix && npm run check:changelog && npm run check:cycles && npm run check:skip-gates && npm run check:private-terms && npm run check:package && npm run check:package-types && npm run size && npm run check:release-tests",
161
169
  "prepack": "node scripts/strip-prepare.mjs",
162
170
  "postpack": "node scripts/restore-prepare.mjs",
163
171
  "size": "size-limit",