turbine-orm 0.75.0 → 0.76.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 (78) hide show
  1. package/README.md +48 -7
  2. package/dist/cjs/cli/compile-query.d.ts +22 -2
  3. package/dist/cjs/cli/compile-query.js +49 -5
  4. package/dist/cjs/cli/config.d.ts +2 -0
  5. package/dist/cjs/cli/config.js +1 -1
  6. package/dist/cjs/cli/destructive.js +78 -43
  7. package/dist/cjs/cli/index.d.ts +95 -1
  8. package/dist/cjs/cli/index.js +609 -145
  9. package/dist/cjs/cli/mcp.js +30 -1
  10. package/dist/cjs/cli/pii-predicate-guard.d.ts +25 -0
  11. package/dist/cjs/cli/pii-predicate-guard.js +72 -12
  12. package/dist/cjs/cli/rate-limit.js +38 -1
  13. package/dist/cjs/cli/studio.js +26 -5
  14. package/dist/cjs/cli/ui.d.ts +33 -0
  15. package/dist/cjs/cli/ui.js +53 -7
  16. package/dist/cjs/client.d.ts +13 -1
  17. package/dist/cjs/client.js +1 -1
  18. package/dist/cjs/errors.d.ts +12 -1
  19. package/dist/cjs/errors.js +11 -2
  20. package/dist/cjs/generate.d.ts +26 -0
  21. package/dist/cjs/generate.js +174 -27
  22. package/dist/cjs/index.d.ts +1 -1
  23. package/dist/cjs/index.js +1 -1
  24. package/dist/cjs/introspect.d.ts +17 -0
  25. package/dist/cjs/introspect.js +100 -1
  26. package/dist/cjs/mssql.d.ts +18 -0
  27. package/dist/cjs/mssql.js +20 -1
  28. package/dist/cjs/pipeline.js +44 -6
  29. package/dist/cjs/powql.js +51 -17
  30. package/dist/cjs/query/batched-loader.js +3 -3
  31. package/dist/cjs/query/builder.js +1 -1
  32. package/dist/cjs/query/relations.d.ts +5 -0
  33. package/dist/cjs/query/relations.js +141 -69
  34. package/dist/cjs/query/utils.d.ts +13 -0
  35. package/dist/cjs/query/utils.js +16 -0
  36. package/dist/cjs/serverless.d.ts +1 -1
  37. package/dist/cjs/serverless.js +1 -1
  38. package/dist/cjs/sqlite.d.ts +33 -1
  39. package/dist/cjs/sqlite.js +84 -3
  40. package/dist/cli/compile-query.d.ts +22 -2
  41. package/dist/cli/compile-query.js +50 -6
  42. package/dist/cli/config.d.ts +2 -0
  43. package/dist/cli/config.js +1 -1
  44. package/dist/cli/destructive.js +78 -43
  45. package/dist/cli/index.d.ts +95 -1
  46. package/dist/cli/index.js +604 -147
  47. package/dist/cli/mcp.js +30 -1
  48. package/dist/cli/pii-predicate-guard.d.ts +25 -0
  49. package/dist/cli/pii-predicate-guard.js +73 -13
  50. package/dist/cli/rate-limit.js +38 -1
  51. package/dist/cli/studio.js +27 -6
  52. package/dist/cli/ui.d.ts +33 -0
  53. package/dist/cli/ui.js +51 -7
  54. package/dist/client.d.ts +13 -1
  55. package/dist/client.js +1 -1
  56. package/dist/errors.d.ts +12 -1
  57. package/dist/errors.js +11 -2
  58. package/dist/generate.d.ts +26 -0
  59. package/dist/generate.js +172 -27
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/introspect.d.ts +17 -0
  63. package/dist/introspect.js +98 -1
  64. package/dist/mssql.d.ts +18 -0
  65. package/dist/mssql.js +20 -1
  66. package/dist/pipeline.js +44 -6
  67. package/dist/powql.js +53 -19
  68. package/dist/query/batched-loader.js +4 -4
  69. package/dist/query/builder.js +2 -2
  70. package/dist/query/relations.d.ts +5 -0
  71. package/dist/query/relations.js +141 -70
  72. package/dist/query/utils.d.ts +13 -0
  73. package/dist/query/utils.js +15 -0
  74. package/dist/serverless.d.ts +1 -1
  75. package/dist/serverless.js +1 -1
  76. package/dist/sqlite.d.ts +33 -1
  77. package/dist/sqlite.js +85 -4
  78. package/package.json +2 -2
package/dist/generate.js CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
12
  import { dirname, join, relative, resolve } from 'node:path';
13
+ import { ValidationError } from './errors.js';
13
14
  import { pgTypeToTs, singularize, snakeToPascal, timeOfDayKind, withDbFieldNames, } from './schema.js';
14
15
  /** Get the TypeScript type name for a table (singularized PascalCase) */
15
16
  function entityName(tableName) {
@@ -57,9 +58,59 @@ function writeColumnTsType(col, enums) {
57
58
  const widened = isArray ? '(string | Date)[]' : 'string | Date';
58
59
  return col.nullable ? `${widened} | null` : widened;
59
60
  }
60
- /** Escape a value for embedding in a single-quoted TypeScript string literal */
61
+ /**
62
+ * Characters that must never reach generated source verbatim: the two that end
63
+ * a single-quoted literal (backslash, quote), the C0/C1 control range (a raw
64
+ * newline alone leaves the literal unterminated and starts a new source line),
65
+ * and the two Unicode line terminators.
66
+ */
67
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: neutralizing control characters is this pattern's purpose
68
+ const UNSAFE_EMIT_CHARS_GLOBAL = /[\\'\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
69
+ /**
70
+ * Escape a value for embedding in a single-quoted TypeScript string literal.
71
+ *
72
+ * Every character that can END the literal has to be neutralized here, not just
73
+ * the quote. A database identifier is attacker-controlled text (Postgres allows
74
+ * any character in a double-quoted name, up to 63 bytes) and generated code is
75
+ * `import`ed, i.e. EXECUTED, so a literal that closes early leaves the rest of
76
+ * the name in expression position. A raw newline is enough on its own.
77
+ */
61
78
  function escSQ(value) {
62
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
79
+ return value.replace(UNSAFE_EMIT_CHARS_GLOBAL, (ch) => {
80
+ switch (ch) {
81
+ case '\\':
82
+ return '\\\\';
83
+ case "'":
84
+ return "\\'";
85
+ case '\n':
86
+ return '\\n';
87
+ case '\r':
88
+ return '\\r';
89
+ case '\t':
90
+ return '\\t';
91
+ case '\b':
92
+ return '\\b';
93
+ case '\f':
94
+ return '\\f';
95
+ case '\v':
96
+ return '\\v';
97
+ default:
98
+ return `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`;
99
+ }
100
+ });
101
+ }
102
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: collapsing control characters is this pattern's purpose
103
+ const COMMENT_BREAKING_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
104
+ /**
105
+ * Neutralize a catalog string for emission inside a generated JSDoc or line
106
+ * COMMENT. Two sequences end a comment early and both are legal in a
107
+ * double-quoted Postgres identifier: an asterisk followed by a slash closes a
108
+ * block comment, putting everything after it in code position, and a newline
109
+ * ends a line comment and splits one emitted line into two. Escaping the
110
+ * asterisk breaks the terminator without changing how the name reads.
111
+ */
112
+ function docSafe(value) {
113
+ return value.replace(/\*\//g, '*\\/').replace(COMMENT_BREAKING_CHARS, ' ');
63
114
  }
64
115
  // ---------------------------------------------------------------------------
65
116
  // Main generate function
@@ -284,12 +335,75 @@ function typeSafeRelations(table, warn = true) {
284
335
  }
285
336
  return usable;
286
337
  }
338
+ // ---------------------------------------------------------------------------
339
+ // Identifier boundary
340
+ // ---------------------------------------------------------------------------
341
+ /**
342
+ * The JavaScript identifier grammar as a WHOLE-STRING match: `IdentifierStart`
343
+ * (`ID_Start`, `$`, `_`) followed by `IdentifierPart` (`ID_Continue`, `$`, ZWNJ,
344
+ * ZWJ). Deliberately Unicode-aware rather than `[A-Za-z_$][\w$]*`: a table named
345
+ * `café` yields the perfectly valid identifier `Café` and there is no reason to
346
+ * refuse it. What it does refuse is every character that could end the
347
+ * identifier token, so a name that passes interpolates as exactly one token.
348
+ */
349
+ const EMITTABLE_IDENTIFIER_RE = /^[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*$/u;
350
+ /**
351
+ * Whether `name` can be interpolated into generated TypeScript in IDENTIFIER
352
+ * position (a type name, an interface name, a class member declaration).
353
+ *
354
+ * Identifier position is the one emission site with no escaping option: a value
355
+ * position becomes a quoted literal ({@link escSQ}), a key position becomes a
356
+ * quoted key ({@link quoteIfNeeded}), a comment is neutralized
357
+ * ({@link docSafe}), but `export interface X` needs a real identifier. So the
358
+ * only sound answer for a name that is not one is to refuse.
359
+ */
360
+ export function isEmittableIdentifier(name) {
361
+ return EMITTABLE_IDENTIFIER_RE.test(name);
362
+ }
363
+ function requireEmittable(derived, subject, role) {
364
+ if (isEmittableIdentifier(derived))
365
+ return;
366
+ throw new ValidationError(`[turbine] Cannot generate code for ${subject}: it produces ${JSON.stringify(derived)} as ${role}, ` +
367
+ `which is not a valid TypeScript identifier. Rename the database object, or exclude it from generation ` +
368
+ `(introspect \`exclude\`).`);
369
+ }
370
+ /**
371
+ * Refuse a schema whose names cannot be emitted as TypeScript identifiers.
372
+ *
373
+ * Called by every emitter that puts a catalog-derived name in identifier
374
+ * position ({@link generateTypes}, {@link generateIndex}, {@link generateZod}).
375
+ * {@link generateMetadata} deliberately does NOT call it: metadata.ts emits no
376
+ * identifiers derived from catalog names, every name there is a quoted key or a
377
+ * quoted value, so it has no identifier rule to enforce and stays usable for a
378
+ * schema whose type layer cannot be generated.
379
+ *
380
+ * The check runs on the DERIVED identifier, not the raw name, because that is
381
+ * what actually lands in the output, but the message names the raw object so
382
+ * the reader knows what to rename.
383
+ */
384
+ export function assertEmittableSchema(schema) {
385
+ for (const enumName of Object.keys(schema.enums)) {
386
+ requireEmittable(snakeToPascal(enumName), `enum type "${enumName}"`, 'the generated enum type name');
387
+ }
388
+ for (const table of Object.values(schema.tables)) {
389
+ requireEmittable(entityName(table.name), `table "${table.name}"`, 'the generated entity type name');
390
+ requireEmittable(snakeToCamelStr(table.name), `table "${table.name}"`, 'the generated client accessor');
391
+ // Only the relations that reach the TYPE layer: a relation shadowing a
392
+ // column field is already dropped from types.ts, so refusing on its name
393
+ // would refuse a schema that generates fine.
394
+ for (const [relName, rel] of typeSafeRelations(table, false)) {
395
+ requireEmittable(snakeToPascal(relName), `relation "${relName}" on table "${table.name}"`, 'part of the generated `XWithY` interface name');
396
+ requireEmittable(entityName(rel.to), `relation "${relName}" on table "${table.name}" (target "${rel.to}")`, 'the generated target entity type name');
397
+ }
398
+ }
399
+ }
287
400
  /**
288
401
  * Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
289
402
  * and *Relations brand-field interfaces). Exported so tests can pin the
290
403
  * generator output without writing files to disk.
291
404
  */
292
405
  export function generateTypes(schema, options) {
406
+ assertEmittableSchema(schema);
293
407
  const lines = [...generatedFileHeader(options)];
294
408
  // We import UpdateOperatorInput so generated *Update types can express
295
409
  // atomic increment / decrement / multiply / divide / set operators on
@@ -320,7 +434,7 @@ export function generateTypes(schema, options) {
320
434
  // Generate enum types
321
435
  for (const [enumName, labels] of Object.entries(schema.enums)) {
322
436
  const typeName = snakeToPascal(enumName);
323
- lines.push(`/** Database enum: ${enumName} */`);
437
+ lines.push(`/** Database enum: ${docSafe(enumName)} */`);
324
438
  lines.push(`export type ${typeName} = ${labels.map((l) => `'${escSQ(l)}'`).join(' | ')};`);
325
439
  lines.push('');
326
440
  }
@@ -328,7 +442,7 @@ export function generateTypes(schema, options) {
328
442
  for (const table of Object.values(schema.tables)) {
329
443
  const typeName = entityName(table.name);
330
444
  // --- Base entity interface ---
331
- lines.push(`/** Row type for the \`${table.name}\` table */`);
445
+ lines.push(`/** Row type for the \`${docSafe(table.name)}\` table */`);
332
446
  lines.push(`export interface ${typeName} {`);
333
447
  for (const col of table.columns) {
334
448
  const pkNote = table.primaryKey.includes(col.name) ? ' (primary key)' : '';
@@ -338,7 +452,7 @@ export function generateTypes(schema, options) {
338
452
  // The emitted type marks it optional so it tells the truth about absence.
339
453
  const piiNote = col.pii ? ' (PII: absent unless selected or includePii)' : '';
340
454
  const optional = col.pii ? '?' : '';
341
- lines.push(` /** Column: ${col.name}, ${col.pgType}${pkNote}${nullNote}${piiNote} */`);
455
+ lines.push(` /** Column: ${docSafe(col.name)}, ${docSafe(col.pgType)}${pkNote}${nullNote}${piiNote} */`);
342
456
  lines.push(` ${quoteIfNeeded(col.field)}${optional}: ${columnTsType(col, schema.enums)};`);
343
457
  }
344
458
  lines.push('}');
@@ -346,7 +460,7 @@ export function generateTypes(schema, options) {
346
460
  // --- Create input type ---
347
461
  // Required: non-nullable columns without defaults (except PK)
348
462
  // Optional: nullable columns (default to NULL) or columns with explicit defaults
349
- lines.push(`/** Input type for creating a row in \`${table.name}\` */`);
463
+ lines.push(`/** Input type for creating a row in \`${docSafe(table.name)}\` */`);
350
464
  lines.push(`export type ${typeName}Create = {`);
351
465
  for (const col of table.columns) {
352
466
  // STORED generated columns are computed by the database, never writable.
@@ -369,7 +483,7 @@ export function generateTypes(schema, options) {
369
483
  // Numeric columns additionally accept `UpdateOperatorInput<number>` so
370
484
  // users can write `{ viewCount: { increment: 1 } }` without an `as any`.
371
485
  const nonPkCols = table.columns.filter((c) => !table.primaryKey.includes(c.name) && !c.isGeneratedStored);
372
- lines.push(`/** Input type for updating a row in \`${table.name}\` */`);
486
+ lines.push(`/** Input type for updating a row in \`${docSafe(table.name)}\` */`);
373
487
  lines.push(`export type ${typeName}Update = {`);
374
488
  for (const col of nonPkCols) {
375
489
  lines.push(` ${quoteIfNeeded(col.field)}?: ${updateFieldType(writeColumnTsType(col, schema.enums))};`);
@@ -387,14 +501,14 @@ export function generateTypes(schema, options) {
387
501
  const safeRelations = safeRelationsByTable.get(table.name) ?? [];
388
502
  const hasRelations = safeRelations.length > 0;
389
503
  if (hasRelations) {
390
- lines.push(`/** Available relations for the \`${table.name}\` table */`);
504
+ lines.push(`/** Available relations for the \`${docSafe(table.name)}\` table */`);
391
505
  lines.push(`export interface ${typeName}Relations {`);
392
506
  for (const [relName, rel] of safeRelations) {
393
507
  const targetType = entityName(rel.to);
394
508
  // manyToMany is a collection too → 'many' cardinality (same as hasMany).
395
509
  const cardinality = rel.type === 'hasMany' || rel.type === 'manyToMany' ? "'many'" : "'one'";
396
510
  const targetRelations = tablesWithRelations.has(rel.to) ? `${targetType}Relations` : '{}';
397
- lines.push(` ${relName}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
511
+ lines.push(` ${quoteIfNeeded(relName)}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
398
512
  }
399
513
  lines.push('}');
400
514
  lines.push('');
@@ -402,15 +516,15 @@ export function generateTypes(schema, options) {
402
516
  for (const [relName, rel] of safeRelations) {
403
517
  const targetType = entityName(rel.to);
404
518
  if (rel.type === 'hasMany' || rel.type === 'manyToMany') {
405
- lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
519
+ lines.push(`/** ${typeName} with \`${docSafe(relName)}\` relation loaded (${rel.type}: ${docSafe(rel.to)}) */`);
406
520
  lines.push(`export interface ${typeName}With${snakeToPascal(relName)} extends ${typeName} {`);
407
- lines.push(` ${relName}: ${targetType}[];`);
521
+ lines.push(` ${quoteIfNeeded(relName)}: ${targetType}[];`);
408
522
  lines.push('}');
409
523
  }
410
524
  else {
411
- lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
525
+ lines.push(`/** ${typeName} with \`${docSafe(relName)}\` relation loaded (${rel.type}: ${docSafe(rel.to)}) */`);
412
526
  lines.push(`export interface ${typeName}With${snakeToPascal(relName)} extends ${typeName} {`);
413
- lines.push(` ${relName}: ${targetType} | null;`);
527
+ lines.push(` ${quoteIfNeeded(relName)}: ${targetType} | null;`);
414
528
  lines.push('}');
415
529
  }
416
530
  lines.push('');
@@ -507,7 +621,7 @@ export function generateTypes(schema, options) {
507
621
  lines.push(`export type ${typeName}CreateInput = ${typeName}Create & {`);
508
622
  for (const [relName, rel] of safeRelations) {
509
623
  const targetType = entityName(rel.to);
510
- lines.push(` ${relName}?: ${targetType}NestedCreateInput;`);
624
+ lines.push(` ${quoteIfNeeded(relName)}?: ${targetType}NestedCreateInput;`);
511
625
  }
512
626
  lines.push('};');
513
627
  lines.push('');
@@ -515,10 +629,10 @@ export function generateTypes(schema, options) {
515
629
  for (const [relName, rel] of safeRelations) {
516
630
  const targetType = entityName(rel.to);
517
631
  if (rel.type === 'hasMany') {
518
- lines.push(` ${relName}?: ${targetType}NestedUpdateInput;`);
632
+ lines.push(` ${quoteIfNeeded(relName)}?: ${targetType}NestedUpdateInput;`);
519
633
  }
520
634
  else {
521
- lines.push(` ${relName}?: ${targetType}NestedCreateInput;`);
635
+ lines.push(` ${quoteIfNeeded(relName)}?: ${targetType}NestedCreateInput;`);
522
636
  }
523
637
  }
524
638
  lines.push('};');
@@ -623,6 +737,7 @@ function zodBaseType(col, enums, forWrite = false) {
623
737
  * the output without writing files.
624
738
  */
625
739
  export function generateZod(schema, options) {
740
+ assertEmittableSchema(schema);
626
741
  const lines = [...generatedFileHeader(options)];
627
742
  // `zod` is a USER dependency, this generated file imports it, but the Turbine
628
743
  // library runtime never does, so Zod stays out of the package's dep graph.
@@ -631,7 +746,7 @@ export function generateZod(schema, options) {
631
746
  for (const table of Object.values(schema.tables)) {
632
747
  const typeName = entityName(table.name);
633
748
  // Full-row schema.
634
- lines.push(`/** Zod schema for a \`${table.name}\` row */`);
749
+ lines.push(`/** Zod schema for a \`${docSafe(table.name)}\` row */`);
635
750
  lines.push(`export const ${typeName}Schema = z.object({`);
636
751
  for (const col of table.columns) {
637
752
  let expr = zodBaseType(col, schema.enums);
@@ -643,7 +758,7 @@ export function generateZod(schema, options) {
643
758
  lines.push('');
644
759
  // Create schema, STORED generated columns can never be written; PK,
645
760
  // defaulted, and nullable columns are optional.
646
- lines.push(`/** Zod schema for creating a \`${table.name}\` row */`);
761
+ lines.push(`/** Zod schema for creating a \`${docSafe(table.name)}\` row */`);
647
762
  lines.push(`export const ${typeName}CreateSchema = z.object({`);
648
763
  for (const col of table.columns) {
649
764
  if (col.isGeneratedStored)
@@ -659,7 +774,7 @@ export function generateZod(schema, options) {
659
774
  lines.push('});');
660
775
  lines.push('');
661
776
  // Update schema, PK and STORED generated columns omitted; all else optional.
662
- lines.push(`/** Zod schema for updating a \`${table.name}\` row */`);
777
+ lines.push(`/** Zod schema for updating a \`${docSafe(table.name)}\` row */`);
663
778
  lines.push(`export const ${typeName}UpdateSchema = z.object({`);
664
779
  for (const col of table.columns) {
665
780
  if (col.isGeneratedStored)
@@ -689,7 +804,7 @@ export function generateMetadata(schema, options) {
689
804
  ' tables: {',
690
805
  ];
691
806
  for (const table of Object.values(schema.tables)) {
692
- lines.push(` ${table.name}: {`);
807
+ lines.push(` ${quoteIfNeeded(table.name)}: {`);
693
808
  lines.push(` name: '${escSQ(table.name)}',`);
694
809
  // columns
695
810
  lines.push(' columns: [');
@@ -750,7 +865,7 @@ export function generateMetadata(schema, options) {
750
865
  `sourceKey: ${keyLiteral(rel.through.sourceKey)}, ` +
751
866
  `targetKey: ${keyLiteral(rel.through.targetKey)} }`;
752
867
  }
753
- lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral}${throughLiteral} },`);
868
+ lines.push(` ${quoteIfNeeded(relName)}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral}${throughLiteral} },`);
754
869
  }
755
870
  lines.push(' },');
756
871
  // indexes
@@ -783,7 +898,7 @@ export function generateMetadata(schema, options) {
783
898
  // enums
784
899
  lines.push(' enums: {');
785
900
  for (const [enumName, labels] of Object.entries(schema.enums)) {
786
- lines.push(` ${enumName}: [${labels.map((l) => `'${escSQ(l)}'`).join(', ')}],`);
901
+ lines.push(` ${quoteIfNeeded(enumName)}: [${labels.map((l) => `'${escSQ(l)}'`).join(', ')}],`);
787
902
  }
788
903
  lines.push(' },');
789
904
  lines.push('};');
@@ -798,6 +913,7 @@ export function generateMetadata(schema, options) {
798
913
  // index.ts generator (configured client with typed table accessors)
799
914
  // ---------------------------------------------------------------------------
800
915
  export function generateIndex(schema, options) {
916
+ assertEmittableSchema(schema);
801
917
  const tableEntries = Object.values(schema.tables);
802
918
  // Must mirror generateTypes: `XRelations` only exists in types.ts when the
803
919
  // table has at least one type-safe (non-column-shadowing) relation.
@@ -844,7 +960,7 @@ export function generateIndex(schema, options) {
844
960
  const accessor = snakeToCamelStr(table.name);
845
961
  const hasRelations = hasSafeRelations.get(table.name) === true;
846
962
  const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
847
- lines.push(` /** Query interface for the \`${table.name}\` table (transaction-scoped) */`);
963
+ lines.push(` /** Query interface for the \`${docSafe(table.name)}\` table (transaction-scoped) */`);
848
964
  lines.push(` declare readonly ${accessor}: ${accessorType(table, genericArgs)};`);
849
965
  }
850
966
  lines.push('}');
@@ -867,7 +983,7 @@ export function generateIndex(schema, options) {
867
983
  lines.push(' *');
868
984
  lines.push(' * Tables:');
869
985
  for (const table of tableEntries) {
870
- lines.push(` * - \`${snakeToCamelStr(table.name)}\` (${table.name})`);
986
+ lines.push(` * - \`${docSafe(snakeToCamelStr(table.name))}\` (${docSafe(table.name)})`);
871
987
  }
872
988
  lines.push(' *');
873
989
  lines.push(' * @example');
@@ -876,7 +992,7 @@ export function generateIndex(schema, options) {
876
992
  if (tableEntries.length > 0) {
877
993
  const firstTable = tableEntries[0];
878
994
  const accessor = snakeToCamelStr(firstTable.name);
879
- lines.push(` * const rows = await db.${accessor}.findMany();`);
995
+ lines.push(` * const rows = await db.${docSafe(accessor)}.findMany();`);
880
996
  }
881
997
  lines.push(' * ```');
882
998
  lines.push(' */');
@@ -886,7 +1002,7 @@ export function generateIndex(schema, options) {
886
1002
  const accessor = snakeToCamelStr(table.name);
887
1003
  const hasRelations = hasSafeRelations.get(table.name) === true;
888
1004
  const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
889
- lines.push(` /** Query interface for the \`${table.name}\` table */`);
1005
+ lines.push(` /** Query interface for the \`${docSafe(table.name)}\` table */`);
890
1006
  lines.push(` declare readonly ${accessor}: ${accessorType(table, genericArgs)};`);
891
1007
  }
892
1008
  lines.push('');
@@ -1061,8 +1177,37 @@ function serializeColumn(col) {
1061
1177
  parts.push(`maxLength: ${col.maxLength}`);
1062
1178
  return `{ ${parts.join(', ')} }`;
1063
1179
  }
1180
+ /**
1181
+ * The subset of names emitted BARE in generated object-key position. Anchored
1182
+ * on purpose: the previous rule only tested whether a name contained a
1183
+ * character outside the set, so a name made entirely of allowed characters but
1184
+ * starting with a digit (`2fa`) was emitted bare and did not parse.
1185
+ */
1186
+ const BARE_KEY_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
1187
+ /**
1188
+ * Emit `s` in generated object-KEY position.
1189
+ *
1190
+ * A key is the one place in the output where catalog text would otherwise land
1191
+ * in EXPRESSION position: `{ [expr]: v }` is a COMPUTED key, evaluated when the
1192
+ * object is constructed, so an unquoted name carrying brackets runs on
1193
+ * `import`. The bare form is therefore gated on a WHOLE-STRING match of the
1194
+ * identifier grammar, which no bracket, parenthesis, quote, newline, or space
1195
+ * can pass; every other name becomes a fully escaped string literal, which is
1196
+ * inert in key position.
1197
+ *
1198
+ * The literal has to be ESCAPED, not merely wrapped: this function used to
1199
+ * return `'${s}'` verbatim, so a name containing a quote closed the key and
1200
+ * reopened in expression position.
1201
+ *
1202
+ * KNOWN LIMIT, deliberately not handled here: a database object named
1203
+ * `__proto__` still sets the emitted object literal's PROTOTYPE rather than
1204
+ * adding a property, because the object-literal special case applies to the
1205
+ * quoted spelling too. Quoting is not a fix for it, so there is no branch for
1206
+ * it; the consequence is a metadata map that silently omits that one entry, not
1207
+ * code execution.
1208
+ */
1064
1209
  function quoteIfNeeded(s) {
1065
- return /[^a-zA-Z0-9_$]/.test(s) ? `'${s}'` : s;
1210
+ return BARE_KEY_RE.test(s) ? s : `'${escSQ(s)}'`;
1066
1211
  }
1067
1212
  function snakeToCamelStr(s) {
1068
1213
  return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * // npx turbine generate
11
11
  *
12
12
  * // 2. Import the generated client:
13
- * import { turbine } from './generated/turbine';
13
+ * import { turbine } from './generated/turbine/index.js';
14
14
  *
15
15
  * const db = turbine({ connectionString: process.env.DATABASE_URL });
16
16
  *
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * // npx turbine generate
11
11
  *
12
12
  * // 2. Import the generated client:
13
- * import { turbine } from './generated/turbine';
13
+ * import { turbine } from './generated/turbine/index.js';
14
14
  *
15
15
  * const db = turbine({ connectionString: process.env.DATABASE_URL });
16
16
  *
@@ -105,6 +105,23 @@ export interface IntrospectOptions {
105
105
  */
106
106
  dialect?: Dialect;
107
107
  }
108
+ /**
109
+ * Refuse one catalog identifier carrying a character from
110
+ * {@link UNSAFE_CATALOG_CHARS}. `subject` names the object in the error.
111
+ */
112
+ export declare function assertSafeCatalogIdentifier(name: string, subject: string): void;
113
+ /**
114
+ * Walk a freshly introspected {@link SchemaMetadata} and refuse any catalog
115
+ * identifier that carries a control character (see
116
+ * {@link assertSafeCatalogIdentifier}).
117
+ *
118
+ * Covers every string that is a NAME: tables, columns (catalog name and derived
119
+ * field), relations (key, name, endpoints, keys, junction), enum types and their
120
+ * labels, index names and their columns, and check-constraint names. It does
121
+ * NOT cover free-text SQL, index definitions, check expressions, and column
122
+ * defaults are emitted with `JSON.stringify` and are not identifiers.
123
+ */
124
+ export declare function assertSafeCatalogSchema(schema: SchemaMetadata): void;
108
125
  /**
109
126
  * Introspect a database into {@link SchemaMetadata}, routing through the active
110
127
  * dialect's {@link Dialect.introspector} so each engine can override the catalog
@@ -301,6 +301,99 @@ export function defaultExcludedTablesPresent(names, options = {}) {
301
301
  // ---------------------------------------------------------------------------
302
302
  // Main introspection function
303
303
  // ---------------------------------------------------------------------------
304
+ // ---------------------------------------------------------------------------
305
+ // Catalog identifier boundary
306
+ // ---------------------------------------------------------------------------
307
+ /**
308
+ * Characters that no legitimate SQL object name carries and that are exactly
309
+ * the primitives for breaking OUT of a generated string literal or comment: the
310
+ * C0 control range (NUL, newline, carriage return, tab), the C1 range, and the
311
+ * two Unicode line terminators.
312
+ *
313
+ * Postgres permits ANY character in a double-quoted identifier up to 63 bytes,
314
+ * so a catalog name is attacker-controlled text as soon as anyone but the DBA
315
+ * can create an object. `turbine generate` turns those names into TypeScript
316
+ * that is then `import`ed, i.e. EXECUTED, and `turbine studio` / the MCP server
317
+ * render them into HTML and JSON. Escaping at each of those sinks is the actual
318
+ * fix (see `escSQ` / `quoteIfNeeded` / `docSafe` in generate.ts); this boundary
319
+ * is the belt-and-braces refusal one layer earlier, and it names the object so
320
+ * the operator can see WHICH one is malformed instead of debugging generated
321
+ * output.
322
+ *
323
+ * Deliberately narrow. It does NOT refuse a name that merely cannot become a
324
+ * TypeScript identifier (`2fa_codes`), because `introspect()` also feeds
325
+ * Studio, the MCP server, and `doctor`, none of which emit identifiers, and
326
+ * refusing there would break tools that work today. That question belongs to
327
+ * the code generator and is answered by `assertEmittableSchema` in generate.ts.
328
+ */
329
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: detecting control characters is this pattern's purpose
330
+ const UNSAFE_CATALOG_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
331
+ /**
332
+ * Refuse one catalog identifier carrying a character from
333
+ * {@link UNSAFE_CATALOG_CHARS}. `subject` names the object in the error.
334
+ */
335
+ export function assertSafeCatalogIdentifier(name, subject) {
336
+ const match = UNSAFE_CATALOG_CHARS.exec(name);
337
+ if (match === null)
338
+ return;
339
+ const code = match[0].charCodeAt(0).toString(16).padStart(4, '0');
340
+ throw new ValidationError(`[turbine] Refusing to introspect ${subject}: its name contains the control character U+${code.toUpperCase()} ` +
341
+ `at position ${match.index}. Such a name cannot be safely emitted into generated code, SQL comments, or ` +
342
+ `tooling output. Rename the database object, or exclude it from introspection.`);
343
+ }
344
+ /**
345
+ * Walk a freshly introspected {@link SchemaMetadata} and refuse any catalog
346
+ * identifier that carries a control character (see
347
+ * {@link assertSafeCatalogIdentifier}).
348
+ *
349
+ * Covers every string that is a NAME: tables, columns (catalog name and derived
350
+ * field), relations (key, name, endpoints, keys, junction), enum types and their
351
+ * labels, index names and their columns, and check-constraint names. It does
352
+ * NOT cover free-text SQL, index definitions, check expressions, and column
353
+ * defaults are emitted with `JSON.stringify` and are not identifiers.
354
+ */
355
+ export function assertSafeCatalogSchema(schema) {
356
+ for (const [enumName, labels] of Object.entries(schema.enums)) {
357
+ assertSafeCatalogIdentifier(enumName, `enum type "${enumName}"`);
358
+ for (const label of labels) {
359
+ assertSafeCatalogIdentifier(label, `a label of enum type "${enumName}"`);
360
+ }
361
+ }
362
+ for (const [tableKey, table] of Object.entries(schema.tables)) {
363
+ assertSafeCatalogIdentifier(tableKey, `table "${tableKey}"`);
364
+ assertSafeCatalogIdentifier(table.name, `table "${tableKey}"`);
365
+ const where = `table "${table.name}"`;
366
+ for (const col of table.columns) {
367
+ assertSafeCatalogIdentifier(col.name, `column "${col.name}" on ${where}`);
368
+ assertSafeCatalogIdentifier(col.field, `the field name derived for a column on ${where}`);
369
+ }
370
+ for (const [relKey, rel] of Object.entries(table.relations)) {
371
+ const relWhere = `relation "${relKey}" on ${where}`;
372
+ assertSafeCatalogIdentifier(relKey, relWhere);
373
+ assertSafeCatalogIdentifier(rel.name, relWhere);
374
+ assertSafeCatalogIdentifier(rel.from, `the source table of ${relWhere}`);
375
+ assertSafeCatalogIdentifier(rel.to, `the target table of ${relWhere}`);
376
+ for (const k of [rel.foreignKey, rel.referenceKey].flat()) {
377
+ assertSafeCatalogIdentifier(k, `a key column of ${relWhere}`);
378
+ }
379
+ if (rel.through) {
380
+ assertSafeCatalogIdentifier(rel.through.table, `the junction table of ${relWhere}`);
381
+ for (const k of [rel.through.sourceKey, rel.through.targetKey].flat()) {
382
+ assertSafeCatalogIdentifier(k, `a junction key column of ${relWhere}`);
383
+ }
384
+ }
385
+ }
386
+ for (const idx of table.indexes) {
387
+ assertSafeCatalogIdentifier(idx.name, `index "${idx.name}" on ${where}`);
388
+ for (const c of idx.columns) {
389
+ assertSafeCatalogIdentifier(c, `a column of index "${idx.name}" on ${where}`);
390
+ }
391
+ }
392
+ for (const chk of table.checks ?? []) {
393
+ assertSafeCatalogIdentifier(chk.name, `check constraint "${chk.name}" on ${where}`);
394
+ }
395
+ }
396
+ }
304
397
  /**
305
398
  * Introspect a database into {@link SchemaMetadata}, routing through the active
306
399
  * dialect's {@link Dialect.introspector} so each engine can override the catalog
@@ -314,7 +407,11 @@ export async function introspect(options) {
314
407
  : // Dialects without an introspector fall back to the Postgres catalog reader.
315
408
  await introspectPostgresCatalog(options);
316
409
  // Applied here rather than inside each introspector so every engine gets it.
317
- return options.relationNames ? applyRelationRenames(schema, options.relationNames) : schema;
410
+ const renamed = options.relationNames ? applyRelationRenames(schema, options.relationNames) : schema;
411
+ // Same reason: one boundary for every engine, and AFTER the renames so a
412
+ // caller-supplied relation name is checked too.
413
+ assertSafeCatalogSchema(renamed);
414
+ return renamed;
318
415
  }
319
416
  /**
320
417
  * Rename introspected relations, per table, from the name turbine derived to
package/dist/mssql.d.ts CHANGED
@@ -149,6 +149,24 @@ type QueryArg = string | {
149
149
  export declare class MssqlPool implements PgCompatPool {
150
150
  /** The underlying `mssql` ConnectionPool, exposed as an escape hatch (seed / DDL / advanced ops). */
151
151
  readonly pool: MssqlConnectionPool;
152
+ /**
153
+ * The dialect this pool speaks, published so a consumer holding only the POOL
154
+ * can find it.
155
+ *
156
+ * `executePipeline` (src/pipeline.ts) is that consumer: it receives a pool and
157
+ * nothing else, and its transaction control used to be the literal strings
158
+ * `BEGIN` / `COMMIT` / `ROLLBACK`. A bare `BEGIN` is a statement-BLOCK opener
159
+ * in T-SQL, so it missed {@link MssqlTxClient}'s `BEGIN TRAN(SACTION)` branch,
160
+ * reached the server as a block with no `END`, and was rejected; the COMMIT
161
+ * and ROLLBACK behind it then found no open transaction and no-oped. Reading
162
+ * the dialect off the pool is what lets the batch emit `BEGIN TRANSACTION`
163
+ * and land on the driver's Transaction API instead.
164
+ *
165
+ * SQL Server is the only engine whose transaction keywords differ from
166
+ * Postgres's, so it is the only pool shim that needs to publish this; the
167
+ * lookup treats an absent `dialect` as PostgreSQL.
168
+ */
169
+ readonly dialect: Dialect;
152
170
  private readonly sqlNS;
153
171
  private closed;
154
172
  constructor(pool: MssqlConnectionPool, sqlNS: MssqlModule);
package/dist/mssql.js CHANGED
@@ -94,6 +94,7 @@ import { postgresDialect, } from './dialect.js';
94
94
  import { ConnectionError, malformedConnectionStringMessage, markValueBearingMessage, RelationError, UnsupportedFeatureError, ValidationError, } from './errors.js';
95
95
  import { applyTableFilters, deriveEngineRelations } from './introspect.js';
96
96
  import importOptionalPeer from './optional-peer-import.cjs';
97
+ import { availableClause } from './query/utils.js';
97
98
  import { camelToSnake, isDateType, normalizeKeyColumns, snakeToCamel, } from './schema.js';
98
99
  // ---------------------------------------------------------------------------
99
100
  // SQL Server / connection limits
@@ -358,6 +359,24 @@ class MssqlTxClient {
358
359
  export class MssqlPool {
359
360
  /** The underlying `mssql` ConnectionPool, exposed as an escape hatch (seed / DDL / advanced ops). */
360
361
  pool;
362
+ /**
363
+ * The dialect this pool speaks, published so a consumer holding only the POOL
364
+ * can find it.
365
+ *
366
+ * `executePipeline` (src/pipeline.ts) is that consumer: it receives a pool and
367
+ * nothing else, and its transaction control used to be the literal strings
368
+ * `BEGIN` / `COMMIT` / `ROLLBACK`. A bare `BEGIN` is a statement-BLOCK opener
369
+ * in T-SQL, so it missed {@link MssqlTxClient}'s `BEGIN TRAN(SACTION)` branch,
370
+ * reached the server as a block with no `END`, and was rejected; the COMMIT
371
+ * and ROLLBACK behind it then found no open transaction and no-oped. Reading
372
+ * the dialect off the pool is what lets the batch emit `BEGIN TRANSACTION`
373
+ * and land on the driver's Transaction API instead.
374
+ *
375
+ * SQL Server is the only engine whose transaction keywords differ from
376
+ * Postgres's, so it is the only pool shim that needs to publish this; the
377
+ * lookup treats an absent `dialect` as PostgreSQL.
378
+ */
379
+ dialect = mssqlDialect;
361
380
  sqlNS;
362
381
  closed = false;
363
382
  constructor(pool, sqlNS) {
@@ -886,7 +905,7 @@ function buildForJsonSubquery(dialect, ctx) {
886
905
  const nestedRelDef = targetMeta.relations[nestedRelName];
887
906
  if (!nestedRelDef) {
888
907
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
889
- `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
908
+ availableClause(Object.keys(targetMeta.relations), 'It has no relations.'));
890
909
  }
891
910
  const sub = ctx.recurse(nestedRelDef, nestedSpec, parentAlias, depth + 1, [...path, relDef.name]);
892
911
  cols.push(`JSON_QUERY((${sub})) AS ${q(nestedRelName)}`);