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
@@ -15,6 +15,8 @@ exports.resolveImportExtension = resolveImportExtension;
15
15
  exports.detectTsconfigExtension = detectTsconfigExtension;
16
16
  exports.classifyTsconfig = classifyTsconfig;
17
17
  exports.stripJsonComments = stripJsonComments;
18
+ exports.isEmittableIdentifier = isEmittableIdentifier;
19
+ exports.assertEmittableSchema = assertEmittableSchema;
18
20
  exports.generateTypes = generateTypes;
19
21
  exports.generateZod = generateZod;
20
22
  exports.generateMetadata = generateMetadata;
@@ -22,6 +24,7 @@ exports.generateIndex = generateIndex;
22
24
  exports.generatePrismaMap = generatePrismaMap;
23
25
  const node_fs_1 = require("node:fs");
24
26
  const node_path_1 = require("node:path");
27
+ const errors_js_1 = require("./errors.js");
25
28
  const schema_js_1 = require("./schema.js");
26
29
  /** Get the TypeScript type name for a table (singularized PascalCase) */
27
30
  function entityName(tableName) {
@@ -69,9 +72,59 @@ function writeColumnTsType(col, enums) {
69
72
  const widened = isArray ? '(string | Date)[]' : 'string | Date';
70
73
  return col.nullable ? `${widened} | null` : widened;
71
74
  }
72
- /** Escape a value for embedding in a single-quoted TypeScript string literal */
75
+ /**
76
+ * Characters that must never reach generated source verbatim: the two that end
77
+ * a single-quoted literal (backslash, quote), the C0/C1 control range (a raw
78
+ * newline alone leaves the literal unterminated and starts a new source line),
79
+ * and the two Unicode line terminators.
80
+ */
81
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: neutralizing control characters is this pattern's purpose
82
+ const UNSAFE_EMIT_CHARS_GLOBAL = /[\\'\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
83
+ /**
84
+ * Escape a value for embedding in a single-quoted TypeScript string literal.
85
+ *
86
+ * Every character that can END the literal has to be neutralized here, not just
87
+ * the quote. A database identifier is attacker-controlled text (Postgres allows
88
+ * any character in a double-quoted name, up to 63 bytes) and generated code is
89
+ * `import`ed, i.e. EXECUTED, so a literal that closes early leaves the rest of
90
+ * the name in expression position. A raw newline is enough on its own.
91
+ */
73
92
  function escSQ(value) {
74
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
93
+ return value.replace(UNSAFE_EMIT_CHARS_GLOBAL, (ch) => {
94
+ switch (ch) {
95
+ case '\\':
96
+ return '\\\\';
97
+ case "'":
98
+ return "\\'";
99
+ case '\n':
100
+ return '\\n';
101
+ case '\r':
102
+ return '\\r';
103
+ case '\t':
104
+ return '\\t';
105
+ case '\b':
106
+ return '\\b';
107
+ case '\f':
108
+ return '\\f';
109
+ case '\v':
110
+ return '\\v';
111
+ default:
112
+ return `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`;
113
+ }
114
+ });
115
+ }
116
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: collapsing control characters is this pattern's purpose
117
+ const COMMENT_BREAKING_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
118
+ /**
119
+ * Neutralize a catalog string for emission inside a generated JSDoc or line
120
+ * COMMENT. Two sequences end a comment early and both are legal in a
121
+ * double-quoted Postgres identifier: an asterisk followed by a slash closes a
122
+ * block comment, putting everything after it in code position, and a newline
123
+ * ends a line comment and splits one emitted line into two. Escaping the
124
+ * asterisk breaks the terminator without changing how the name reads.
125
+ */
126
+ function docSafe(value) {
127
+ return value.replace(/\*\//g, '*\\/').replace(COMMENT_BREAKING_CHARS, ' ');
75
128
  }
76
129
  // ---------------------------------------------------------------------------
77
130
  // Main generate function
@@ -296,12 +349,75 @@ function typeSafeRelations(table, warn = true) {
296
349
  }
297
350
  return usable;
298
351
  }
352
+ // ---------------------------------------------------------------------------
353
+ // Identifier boundary
354
+ // ---------------------------------------------------------------------------
355
+ /**
356
+ * The JavaScript identifier grammar as a WHOLE-STRING match: `IdentifierStart`
357
+ * (`ID_Start`, `$`, `_`) followed by `IdentifierPart` (`ID_Continue`, `$`, ZWNJ,
358
+ * ZWJ). Deliberately Unicode-aware rather than `[A-Za-z_$][\w$]*`: a table named
359
+ * `café` yields the perfectly valid identifier `Café` and there is no reason to
360
+ * refuse it. What it does refuse is every character that could end the
361
+ * identifier token, so a name that passes interpolates as exactly one token.
362
+ */
363
+ const EMITTABLE_IDENTIFIER_RE = /^[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*$/u;
364
+ /**
365
+ * Whether `name` can be interpolated into generated TypeScript in IDENTIFIER
366
+ * position (a type name, an interface name, a class member declaration).
367
+ *
368
+ * Identifier position is the one emission site with no escaping option: a value
369
+ * position becomes a quoted literal ({@link escSQ}), a key position becomes a
370
+ * quoted key ({@link quoteIfNeeded}), a comment is neutralized
371
+ * ({@link docSafe}), but `export interface X` needs a real identifier. So the
372
+ * only sound answer for a name that is not one is to refuse.
373
+ */
374
+ function isEmittableIdentifier(name) {
375
+ return EMITTABLE_IDENTIFIER_RE.test(name);
376
+ }
377
+ function requireEmittable(derived, subject, role) {
378
+ if (isEmittableIdentifier(derived))
379
+ return;
380
+ throw new errors_js_1.ValidationError(`[turbine] Cannot generate code for ${subject}: it produces ${JSON.stringify(derived)} as ${role}, ` +
381
+ `which is not a valid TypeScript identifier. Rename the database object, or exclude it from generation ` +
382
+ `(introspect \`exclude\`).`);
383
+ }
384
+ /**
385
+ * Refuse a schema whose names cannot be emitted as TypeScript identifiers.
386
+ *
387
+ * Called by every emitter that puts a catalog-derived name in identifier
388
+ * position ({@link generateTypes}, {@link generateIndex}, {@link generateZod}).
389
+ * {@link generateMetadata} deliberately does NOT call it: metadata.ts emits no
390
+ * identifiers derived from catalog names, every name there is a quoted key or a
391
+ * quoted value, so it has no identifier rule to enforce and stays usable for a
392
+ * schema whose type layer cannot be generated.
393
+ *
394
+ * The check runs on the DERIVED identifier, not the raw name, because that is
395
+ * what actually lands in the output, but the message names the raw object so
396
+ * the reader knows what to rename.
397
+ */
398
+ function assertEmittableSchema(schema) {
399
+ for (const enumName of Object.keys(schema.enums)) {
400
+ requireEmittable((0, schema_js_1.snakeToPascal)(enumName), `enum type "${enumName}"`, 'the generated enum type name');
401
+ }
402
+ for (const table of Object.values(schema.tables)) {
403
+ requireEmittable(entityName(table.name), `table "${table.name}"`, 'the generated entity type name');
404
+ requireEmittable(snakeToCamelStr(table.name), `table "${table.name}"`, 'the generated client accessor');
405
+ // Only the relations that reach the TYPE layer: a relation shadowing a
406
+ // column field is already dropped from types.ts, so refusing on its name
407
+ // would refuse a schema that generates fine.
408
+ for (const [relName, rel] of typeSafeRelations(table, false)) {
409
+ requireEmittable((0, schema_js_1.snakeToPascal)(relName), `relation "${relName}" on table "${table.name}"`, 'part of the generated `XWithY` interface name');
410
+ requireEmittable(entityName(rel.to), `relation "${relName}" on table "${table.name}" (target "${rel.to}")`, 'the generated target entity type name');
411
+ }
412
+ }
413
+ }
299
414
  /**
300
415
  * Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
301
416
  * and *Relations brand-field interfaces). Exported so tests can pin the
302
417
  * generator output without writing files to disk.
303
418
  */
304
419
  function generateTypes(schema, options) {
420
+ assertEmittableSchema(schema);
305
421
  const lines = [...generatedFileHeader(options)];
306
422
  // We import UpdateOperatorInput so generated *Update types can express
307
423
  // atomic increment / decrement / multiply / divide / set operators on
@@ -332,7 +448,7 @@ function generateTypes(schema, options) {
332
448
  // Generate enum types
333
449
  for (const [enumName, labels] of Object.entries(schema.enums)) {
334
450
  const typeName = (0, schema_js_1.snakeToPascal)(enumName);
335
- lines.push(`/** Database enum: ${enumName} */`);
451
+ lines.push(`/** Database enum: ${docSafe(enumName)} */`);
336
452
  lines.push(`export type ${typeName} = ${labels.map((l) => `'${escSQ(l)}'`).join(' | ')};`);
337
453
  lines.push('');
338
454
  }
@@ -340,7 +456,7 @@ function generateTypes(schema, options) {
340
456
  for (const table of Object.values(schema.tables)) {
341
457
  const typeName = entityName(table.name);
342
458
  // --- Base entity interface ---
343
- lines.push(`/** Row type for the \`${table.name}\` table */`);
459
+ lines.push(`/** Row type for the \`${docSafe(table.name)}\` table */`);
344
460
  lines.push(`export interface ${typeName} {`);
345
461
  for (const col of table.columns) {
346
462
  const pkNote = table.primaryKey.includes(col.name) ? ' (primary key)' : '';
@@ -350,7 +466,7 @@ function generateTypes(schema, options) {
350
466
  // The emitted type marks it optional so it tells the truth about absence.
351
467
  const piiNote = col.pii ? ' (PII: absent unless selected or includePii)' : '';
352
468
  const optional = col.pii ? '?' : '';
353
- lines.push(` /** Column: ${col.name}, ${col.pgType}${pkNote}${nullNote}${piiNote} */`);
469
+ lines.push(` /** Column: ${docSafe(col.name)}, ${docSafe(col.pgType)}${pkNote}${nullNote}${piiNote} */`);
354
470
  lines.push(` ${quoteIfNeeded(col.field)}${optional}: ${columnTsType(col, schema.enums)};`);
355
471
  }
356
472
  lines.push('}');
@@ -358,7 +474,7 @@ function generateTypes(schema, options) {
358
474
  // --- Create input type ---
359
475
  // Required: non-nullable columns without defaults (except PK)
360
476
  // Optional: nullable columns (default to NULL) or columns with explicit defaults
361
- lines.push(`/** Input type for creating a row in \`${table.name}\` */`);
477
+ lines.push(`/** Input type for creating a row in \`${docSafe(table.name)}\` */`);
362
478
  lines.push(`export type ${typeName}Create = {`);
363
479
  for (const col of table.columns) {
364
480
  // STORED generated columns are computed by the database, never writable.
@@ -381,7 +497,7 @@ function generateTypes(schema, options) {
381
497
  // Numeric columns additionally accept `UpdateOperatorInput<number>` so
382
498
  // users can write `{ viewCount: { increment: 1 } }` without an `as any`.
383
499
  const nonPkCols = table.columns.filter((c) => !table.primaryKey.includes(c.name) && !c.isGeneratedStored);
384
- lines.push(`/** Input type for updating a row in \`${table.name}\` */`);
500
+ lines.push(`/** Input type for updating a row in \`${docSafe(table.name)}\` */`);
385
501
  lines.push(`export type ${typeName}Update = {`);
386
502
  for (const col of nonPkCols) {
387
503
  lines.push(` ${quoteIfNeeded(col.field)}?: ${updateFieldType(writeColumnTsType(col, schema.enums))};`);
@@ -399,14 +515,14 @@ function generateTypes(schema, options) {
399
515
  const safeRelations = safeRelationsByTable.get(table.name) ?? [];
400
516
  const hasRelations = safeRelations.length > 0;
401
517
  if (hasRelations) {
402
- lines.push(`/** Available relations for the \`${table.name}\` table */`);
518
+ lines.push(`/** Available relations for the \`${docSafe(table.name)}\` table */`);
403
519
  lines.push(`export interface ${typeName}Relations {`);
404
520
  for (const [relName, rel] of safeRelations) {
405
521
  const targetType = entityName(rel.to);
406
522
  // manyToMany is a collection too → 'many' cardinality (same as hasMany).
407
523
  const cardinality = rel.type === 'hasMany' || rel.type === 'manyToMany' ? "'many'" : "'one'";
408
524
  const targetRelations = tablesWithRelations.has(rel.to) ? `${targetType}Relations` : '{}';
409
- lines.push(` ${relName}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
525
+ lines.push(` ${quoteIfNeeded(relName)}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
410
526
  }
411
527
  lines.push('}');
412
528
  lines.push('');
@@ -414,15 +530,15 @@ function generateTypes(schema, options) {
414
530
  for (const [relName, rel] of safeRelations) {
415
531
  const targetType = entityName(rel.to);
416
532
  if (rel.type === 'hasMany' || rel.type === 'manyToMany') {
417
- lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
533
+ lines.push(`/** ${typeName} with \`${docSafe(relName)}\` relation loaded (${rel.type}: ${docSafe(rel.to)}) */`);
418
534
  lines.push(`export interface ${typeName}With${(0, schema_js_1.snakeToPascal)(relName)} extends ${typeName} {`);
419
- lines.push(` ${relName}: ${targetType}[];`);
535
+ lines.push(` ${quoteIfNeeded(relName)}: ${targetType}[];`);
420
536
  lines.push('}');
421
537
  }
422
538
  else {
423
- lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
539
+ lines.push(`/** ${typeName} with \`${docSafe(relName)}\` relation loaded (${rel.type}: ${docSafe(rel.to)}) */`);
424
540
  lines.push(`export interface ${typeName}With${(0, schema_js_1.snakeToPascal)(relName)} extends ${typeName} {`);
425
- lines.push(` ${relName}: ${targetType} | null;`);
541
+ lines.push(` ${quoteIfNeeded(relName)}: ${targetType} | null;`);
426
542
  lines.push('}');
427
543
  }
428
544
  lines.push('');
@@ -519,7 +635,7 @@ function generateTypes(schema, options) {
519
635
  lines.push(`export type ${typeName}CreateInput = ${typeName}Create & {`);
520
636
  for (const [relName, rel] of safeRelations) {
521
637
  const targetType = entityName(rel.to);
522
- lines.push(` ${relName}?: ${targetType}NestedCreateInput;`);
638
+ lines.push(` ${quoteIfNeeded(relName)}?: ${targetType}NestedCreateInput;`);
523
639
  }
524
640
  lines.push('};');
525
641
  lines.push('');
@@ -527,10 +643,10 @@ function generateTypes(schema, options) {
527
643
  for (const [relName, rel] of safeRelations) {
528
644
  const targetType = entityName(rel.to);
529
645
  if (rel.type === 'hasMany') {
530
- lines.push(` ${relName}?: ${targetType}NestedUpdateInput;`);
646
+ lines.push(` ${quoteIfNeeded(relName)}?: ${targetType}NestedUpdateInput;`);
531
647
  }
532
648
  else {
533
- lines.push(` ${relName}?: ${targetType}NestedCreateInput;`);
649
+ lines.push(` ${quoteIfNeeded(relName)}?: ${targetType}NestedCreateInput;`);
534
650
  }
535
651
  }
536
652
  lines.push('};');
@@ -635,6 +751,7 @@ function zodBaseType(col, enums, forWrite = false) {
635
751
  * the output without writing files.
636
752
  */
637
753
  function generateZod(schema, options) {
754
+ assertEmittableSchema(schema);
638
755
  const lines = [...generatedFileHeader(options)];
639
756
  // `zod` is a USER dependency, this generated file imports it, but the Turbine
640
757
  // library runtime never does, so Zod stays out of the package's dep graph.
@@ -643,7 +760,7 @@ function generateZod(schema, options) {
643
760
  for (const table of Object.values(schema.tables)) {
644
761
  const typeName = entityName(table.name);
645
762
  // Full-row schema.
646
- lines.push(`/** Zod schema for a \`${table.name}\` row */`);
763
+ lines.push(`/** Zod schema for a \`${docSafe(table.name)}\` row */`);
647
764
  lines.push(`export const ${typeName}Schema = z.object({`);
648
765
  for (const col of table.columns) {
649
766
  let expr = zodBaseType(col, schema.enums);
@@ -655,7 +772,7 @@ function generateZod(schema, options) {
655
772
  lines.push('');
656
773
  // Create schema, STORED generated columns can never be written; PK,
657
774
  // defaulted, and nullable columns are optional.
658
- lines.push(`/** Zod schema for creating a \`${table.name}\` row */`);
775
+ lines.push(`/** Zod schema for creating a \`${docSafe(table.name)}\` row */`);
659
776
  lines.push(`export const ${typeName}CreateSchema = z.object({`);
660
777
  for (const col of table.columns) {
661
778
  if (col.isGeneratedStored)
@@ -671,7 +788,7 @@ function generateZod(schema, options) {
671
788
  lines.push('});');
672
789
  lines.push('');
673
790
  // Update schema, PK and STORED generated columns omitted; all else optional.
674
- lines.push(`/** Zod schema for updating a \`${table.name}\` row */`);
791
+ lines.push(`/** Zod schema for updating a \`${docSafe(table.name)}\` row */`);
675
792
  lines.push(`export const ${typeName}UpdateSchema = z.object({`);
676
793
  for (const col of table.columns) {
677
794
  if (col.isGeneratedStored)
@@ -701,7 +818,7 @@ function generateMetadata(schema, options) {
701
818
  ' tables: {',
702
819
  ];
703
820
  for (const table of Object.values(schema.tables)) {
704
- lines.push(` ${table.name}: {`);
821
+ lines.push(` ${quoteIfNeeded(table.name)}: {`);
705
822
  lines.push(` name: '${escSQ(table.name)}',`);
706
823
  // columns
707
824
  lines.push(' columns: [');
@@ -762,7 +879,7 @@ function generateMetadata(schema, options) {
762
879
  `sourceKey: ${keyLiteral(rel.through.sourceKey)}, ` +
763
880
  `targetKey: ${keyLiteral(rel.through.targetKey)} }`;
764
881
  }
765
- lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral}${throughLiteral} },`);
882
+ 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} },`);
766
883
  }
767
884
  lines.push(' },');
768
885
  // indexes
@@ -795,7 +912,7 @@ function generateMetadata(schema, options) {
795
912
  // enums
796
913
  lines.push(' enums: {');
797
914
  for (const [enumName, labels] of Object.entries(schema.enums)) {
798
- lines.push(` ${enumName}: [${labels.map((l) => `'${escSQ(l)}'`).join(', ')}],`);
915
+ lines.push(` ${quoteIfNeeded(enumName)}: [${labels.map((l) => `'${escSQ(l)}'`).join(', ')}],`);
799
916
  }
800
917
  lines.push(' },');
801
918
  lines.push('};');
@@ -810,6 +927,7 @@ function generateMetadata(schema, options) {
810
927
  // index.ts generator (configured client with typed table accessors)
811
928
  // ---------------------------------------------------------------------------
812
929
  function generateIndex(schema, options) {
930
+ assertEmittableSchema(schema);
813
931
  const tableEntries = Object.values(schema.tables);
814
932
  // Must mirror generateTypes: `XRelations` only exists in types.ts when the
815
933
  // table has at least one type-safe (non-column-shadowing) relation.
@@ -856,7 +974,7 @@ function generateIndex(schema, options) {
856
974
  const accessor = snakeToCamelStr(table.name);
857
975
  const hasRelations = hasSafeRelations.get(table.name) === true;
858
976
  const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
859
- lines.push(` /** Query interface for the \`${table.name}\` table (transaction-scoped) */`);
977
+ lines.push(` /** Query interface for the \`${docSafe(table.name)}\` table (transaction-scoped) */`);
860
978
  lines.push(` declare readonly ${accessor}: ${accessorType(table, genericArgs)};`);
861
979
  }
862
980
  lines.push('}');
@@ -879,7 +997,7 @@ function generateIndex(schema, options) {
879
997
  lines.push(' *');
880
998
  lines.push(' * Tables:');
881
999
  for (const table of tableEntries) {
882
- lines.push(` * - \`${snakeToCamelStr(table.name)}\` (${table.name})`);
1000
+ lines.push(` * - \`${docSafe(snakeToCamelStr(table.name))}\` (${docSafe(table.name)})`);
883
1001
  }
884
1002
  lines.push(' *');
885
1003
  lines.push(' * @example');
@@ -888,7 +1006,7 @@ function generateIndex(schema, options) {
888
1006
  if (tableEntries.length > 0) {
889
1007
  const firstTable = tableEntries[0];
890
1008
  const accessor = snakeToCamelStr(firstTable.name);
891
- lines.push(` * const rows = await db.${accessor}.findMany();`);
1009
+ lines.push(` * const rows = await db.${docSafe(accessor)}.findMany();`);
892
1010
  }
893
1011
  lines.push(' * ```');
894
1012
  lines.push(' */');
@@ -898,7 +1016,7 @@ function generateIndex(schema, options) {
898
1016
  const accessor = snakeToCamelStr(table.name);
899
1017
  const hasRelations = hasSafeRelations.get(table.name) === true;
900
1018
  const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
901
- lines.push(` /** Query interface for the \`${table.name}\` table */`);
1019
+ lines.push(` /** Query interface for the \`${docSafe(table.name)}\` table */`);
902
1020
  lines.push(` declare readonly ${accessor}: ${accessorType(table, genericArgs)};`);
903
1021
  }
904
1022
  lines.push('');
@@ -1073,8 +1191,37 @@ function serializeColumn(col) {
1073
1191
  parts.push(`maxLength: ${col.maxLength}`);
1074
1192
  return `{ ${parts.join(', ')} }`;
1075
1193
  }
1194
+ /**
1195
+ * The subset of names emitted BARE in generated object-key position. Anchored
1196
+ * on purpose: the previous rule only tested whether a name contained a
1197
+ * character outside the set, so a name made entirely of allowed characters but
1198
+ * starting with a digit (`2fa`) was emitted bare and did not parse.
1199
+ */
1200
+ const BARE_KEY_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
1201
+ /**
1202
+ * Emit `s` in generated object-KEY position.
1203
+ *
1204
+ * A key is the one place in the output where catalog text would otherwise land
1205
+ * in EXPRESSION position: `{ [expr]: v }` is a COMPUTED key, evaluated when the
1206
+ * object is constructed, so an unquoted name carrying brackets runs on
1207
+ * `import`. The bare form is therefore gated on a WHOLE-STRING match of the
1208
+ * identifier grammar, which no bracket, parenthesis, quote, newline, or space
1209
+ * can pass; every other name becomes a fully escaped string literal, which is
1210
+ * inert in key position.
1211
+ *
1212
+ * The literal has to be ESCAPED, not merely wrapped: this function used to
1213
+ * return `'${s}'` verbatim, so a name containing a quote closed the key and
1214
+ * reopened in expression position.
1215
+ *
1216
+ * KNOWN LIMIT, deliberately not handled here: a database object named
1217
+ * `__proto__` still sets the emitted object literal's PROTOTYPE rather than
1218
+ * adding a property, because the object-literal special case applies to the
1219
+ * quoted spelling too. Quoting is not a fix for it, so there is no branch for
1220
+ * it; the consequence is a metadata map that silently omits that one entry, not
1221
+ * code execution.
1222
+ */
1076
1223
  function quoteIfNeeded(s) {
1077
- return /[^a-zA-Z0-9_$]/.test(s) ? `'${s}'` : s;
1224
+ return BARE_KEY_RE.test(s) ? s : `'${escSQ(s)}'`;
1078
1225
  }
1079
1226
  function snakeToCamelStr(s) {
1080
1227
  return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
@@ -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/cjs/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * // npx turbine generate
12
12
  *
13
13
  * // 2. Import the generated client:
14
- * import { turbine } from './generated/turbine';
14
+ * import { turbine } from './generated/turbine/index.js';
15
15
  *
16
16
  * const db = turbine({ connectionString: process.env.DATABASE_URL });
17
17
  *
@@ -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
@@ -16,6 +16,8 @@ exports.DEFAULT_EXCLUDED_TABLES = void 0;
16
16
  exports.pgConfActionToReferential = pgConfActionToReferential;
17
17
  exports.applyTableFilters = applyTableFilters;
18
18
  exports.defaultExcludedTablesPresent = defaultExcludedTablesPresent;
19
+ exports.assertSafeCatalogIdentifier = assertSafeCatalogIdentifier;
20
+ exports.assertSafeCatalogSchema = assertSafeCatalogSchema;
19
21
  exports.introspect = introspect;
20
22
  exports.applyRelationRenames = applyRelationRenames;
21
23
  exports.introspectPostgresCatalog = introspectPostgresCatalog;
@@ -326,6 +328,99 @@ function defaultExcludedTablesPresent(names, options = {}) {
326
328
  // ---------------------------------------------------------------------------
327
329
  // Main introspection function
328
330
  // ---------------------------------------------------------------------------
331
+ // ---------------------------------------------------------------------------
332
+ // Catalog identifier boundary
333
+ // ---------------------------------------------------------------------------
334
+ /**
335
+ * Characters that no legitimate SQL object name carries and that are exactly
336
+ * the primitives for breaking OUT of a generated string literal or comment: the
337
+ * C0 control range (NUL, newline, carriage return, tab), the C1 range, and the
338
+ * two Unicode line terminators.
339
+ *
340
+ * Postgres permits ANY character in a double-quoted identifier up to 63 bytes,
341
+ * so a catalog name is attacker-controlled text as soon as anyone but the DBA
342
+ * can create an object. `turbine generate` turns those names into TypeScript
343
+ * that is then `import`ed, i.e. EXECUTED, and `turbine studio` / the MCP server
344
+ * render them into HTML and JSON. Escaping at each of those sinks is the actual
345
+ * fix (see `escSQ` / `quoteIfNeeded` / `docSafe` in generate.ts); this boundary
346
+ * is the belt-and-braces refusal one layer earlier, and it names the object so
347
+ * the operator can see WHICH one is malformed instead of debugging generated
348
+ * output.
349
+ *
350
+ * Deliberately narrow. It does NOT refuse a name that merely cannot become a
351
+ * TypeScript identifier (`2fa_codes`), because `introspect()` also feeds
352
+ * Studio, the MCP server, and `doctor`, none of which emit identifiers, and
353
+ * refusing there would break tools that work today. That question belongs to
354
+ * the code generator and is answered by `assertEmittableSchema` in generate.ts.
355
+ */
356
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: detecting control characters is this pattern's purpose
357
+ const UNSAFE_CATALOG_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
358
+ /**
359
+ * Refuse one catalog identifier carrying a character from
360
+ * {@link UNSAFE_CATALOG_CHARS}. `subject` names the object in the error.
361
+ */
362
+ function assertSafeCatalogIdentifier(name, subject) {
363
+ const match = UNSAFE_CATALOG_CHARS.exec(name);
364
+ if (match === null)
365
+ return;
366
+ const code = match[0].charCodeAt(0).toString(16).padStart(4, '0');
367
+ throw new errors_js_1.ValidationError(`[turbine] Refusing to introspect ${subject}: its name contains the control character U+${code.toUpperCase()} ` +
368
+ `at position ${match.index}. Such a name cannot be safely emitted into generated code, SQL comments, or ` +
369
+ `tooling output. Rename the database object, or exclude it from introspection.`);
370
+ }
371
+ /**
372
+ * Walk a freshly introspected {@link SchemaMetadata} and refuse any catalog
373
+ * identifier that carries a control character (see
374
+ * {@link assertSafeCatalogIdentifier}).
375
+ *
376
+ * Covers every string that is a NAME: tables, columns (catalog name and derived
377
+ * field), relations (key, name, endpoints, keys, junction), enum types and their
378
+ * labels, index names and their columns, and check-constraint names. It does
379
+ * NOT cover free-text SQL, index definitions, check expressions, and column
380
+ * defaults are emitted with `JSON.stringify` and are not identifiers.
381
+ */
382
+ function assertSafeCatalogSchema(schema) {
383
+ for (const [enumName, labels] of Object.entries(schema.enums)) {
384
+ assertSafeCatalogIdentifier(enumName, `enum type "${enumName}"`);
385
+ for (const label of labels) {
386
+ assertSafeCatalogIdentifier(label, `a label of enum type "${enumName}"`);
387
+ }
388
+ }
389
+ for (const [tableKey, table] of Object.entries(schema.tables)) {
390
+ assertSafeCatalogIdentifier(tableKey, `table "${tableKey}"`);
391
+ assertSafeCatalogIdentifier(table.name, `table "${tableKey}"`);
392
+ const where = `table "${table.name}"`;
393
+ for (const col of table.columns) {
394
+ assertSafeCatalogIdentifier(col.name, `column "${col.name}" on ${where}`);
395
+ assertSafeCatalogIdentifier(col.field, `the field name derived for a column on ${where}`);
396
+ }
397
+ for (const [relKey, rel] of Object.entries(table.relations)) {
398
+ const relWhere = `relation "${relKey}" on ${where}`;
399
+ assertSafeCatalogIdentifier(relKey, relWhere);
400
+ assertSafeCatalogIdentifier(rel.name, relWhere);
401
+ assertSafeCatalogIdentifier(rel.from, `the source table of ${relWhere}`);
402
+ assertSafeCatalogIdentifier(rel.to, `the target table of ${relWhere}`);
403
+ for (const k of [rel.foreignKey, rel.referenceKey].flat()) {
404
+ assertSafeCatalogIdentifier(k, `a key column of ${relWhere}`);
405
+ }
406
+ if (rel.through) {
407
+ assertSafeCatalogIdentifier(rel.through.table, `the junction table of ${relWhere}`);
408
+ for (const k of [rel.through.sourceKey, rel.through.targetKey].flat()) {
409
+ assertSafeCatalogIdentifier(k, `a junction key column of ${relWhere}`);
410
+ }
411
+ }
412
+ }
413
+ for (const idx of table.indexes) {
414
+ assertSafeCatalogIdentifier(idx.name, `index "${idx.name}" on ${where}`);
415
+ for (const c of idx.columns) {
416
+ assertSafeCatalogIdentifier(c, `a column of index "${idx.name}" on ${where}`);
417
+ }
418
+ }
419
+ for (const chk of table.checks ?? []) {
420
+ assertSafeCatalogIdentifier(chk.name, `check constraint "${chk.name}" on ${where}`);
421
+ }
422
+ }
423
+ }
329
424
  /**
330
425
  * Introspect a database into {@link SchemaMetadata}, routing through the active
331
426
  * dialect's {@link Dialect.introspector} so each engine can override the catalog
@@ -339,7 +434,11 @@ async function introspect(options) {
339
434
  : // Dialects without an introspector fall back to the Postgres catalog reader.
340
435
  await introspectPostgresCatalog(options);
341
436
  // Applied here rather than inside each introspector so every engine gets it.
342
- return options.relationNames ? applyRelationRenames(schema, options.relationNames) : schema;
437
+ const renamed = options.relationNames ? applyRelationRenames(schema, options.relationNames) : schema;
438
+ // Same reason: one boundary for every engine, and AFTER the renames so a
439
+ // caller-supplied relation name is checked too.
440
+ assertSafeCatalogSchema(renamed);
441
+ return renamed;
343
442
  }
344
443
  /**
345
444
  * Rename introspected relations, per table, from the name turbine derived to
@@ -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/cjs/mssql.js CHANGED
@@ -104,6 +104,7 @@ const dialect_js_1 = require("./dialect.js");
104
104
  const errors_js_1 = require("./errors.js");
105
105
  const introspect_js_1 = require("./introspect.js");
106
106
  const optional_peer_import_cjs_1 = __importDefault(require("./optional-peer-import.cjs"));
107
+ const utils_js_1 = require("./query/utils.js");
107
108
  const schema_js_1 = require("./schema.js");
108
109
  // ---------------------------------------------------------------------------
109
110
  // SQL Server / connection limits
@@ -368,6 +369,24 @@ class MssqlTxClient {
368
369
  class MssqlPool {
369
370
  /** The underlying `mssql` ConnectionPool, exposed as an escape hatch (seed / DDL / advanced ops). */
370
371
  pool;
372
+ /**
373
+ * The dialect this pool speaks, published so a consumer holding only the POOL
374
+ * can find it.
375
+ *
376
+ * `executePipeline` (src/pipeline.ts) is that consumer: it receives a pool and
377
+ * nothing else, and its transaction control used to be the literal strings
378
+ * `BEGIN` / `COMMIT` / `ROLLBACK`. A bare `BEGIN` is a statement-BLOCK opener
379
+ * in T-SQL, so it missed {@link MssqlTxClient}'s `BEGIN TRAN(SACTION)` branch,
380
+ * reached the server as a block with no `END`, and was rejected; the COMMIT
381
+ * and ROLLBACK behind it then found no open transaction and no-oped. Reading
382
+ * the dialect off the pool is what lets the batch emit `BEGIN TRANSACTION`
383
+ * and land on the driver's Transaction API instead.
384
+ *
385
+ * SQL Server is the only engine whose transaction keywords differ from
386
+ * Postgres's, so it is the only pool shim that needs to publish this; the
387
+ * lookup treats an absent `dialect` as PostgreSQL.
388
+ */
389
+ dialect = exports.mssqlDialect;
371
390
  sqlNS;
372
391
  closed = false;
373
392
  constructor(pool, sqlNS) {
@@ -897,7 +916,7 @@ function buildForJsonSubquery(dialect, ctx) {
897
916
  const nestedRelDef = targetMeta.relations[nestedRelName];
898
917
  if (!nestedRelDef) {
899
918
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
900
- `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
919
+ (0, utils_js_1.availableClause)(Object.keys(targetMeta.relations), 'It has no relations.'));
901
920
  }
902
921
  const sub = ctx.recurse(nestedRelDef, nestedSpec, parentAlias, depth + 1, [...path, relDef.name]);
903
922
  cols.push(`JSON_QUERY((${sub})) AS ${q(nestedRelName)}`);