turbine-orm 0.77.1 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
@@ -25,6 +25,7 @@ exports.generatePrismaMap = generatePrismaMap;
25
25
  const node_fs_1 = require("node:fs");
26
26
  const node_path_1 = require("node:path");
27
27
  const errors_js_1 = require("./errors.js");
28
+ const introspect_js_1 = require("./introspect.js");
28
29
  const schema_js_1 = require("./schema.js");
29
30
  /** Get the TypeScript type name for a table (singularized PascalCase) */
30
31
  function entityName(tableName) {
@@ -402,6 +403,11 @@ function assertEmittableSchema(schema) {
402
403
  for (const table of Object.values(schema.tables)) {
403
404
  requireEmittable(entityName(table.name), `table "${table.name}"`, 'the generated entity type name');
404
405
  requireEmittable(snakeToCamelStr(table.name), `table "${table.name}"`, 'the generated client accessor');
406
+ // Two columns on one field would emit a duplicate interface member (TS2300
407
+ // in the consumer's build) and a `columnMap` missing a column. The rule is
408
+ // introspect.ts's; re-asserting it here covers a hand-built or
409
+ // engine-introspected schema that never went through that path.
410
+ (0, introspect_js_1.assertDistinctColumnFields)(table.name, table.columns);
405
411
  // Only the relations that reach the TYPE layer: a relation shadowing a
406
412
  // column field is already dropped from types.ts, so refusing on its name
407
413
  // would refuse a schema that generates fine.
@@ -472,18 +478,24 @@ function generateTypes(schema, options) {
472
478
  lines.push('}');
473
479
  lines.push('');
474
480
  // --- Create input type ---
475
- // Required: non-nullable columns without defaults (except PK)
476
- // Optional: nullable columns (default to NULL) or columns with explicit defaults
481
+ // Optional: server-generated columns (serial / identity), columns with a
482
+ // default, and nullable columns (default to NULL). Everything else is
483
+ // required, PRIMARY KEY MEMBERSHIP INCLUDED. A text natural key, a
484
+ // client-supplied uuid with no default, or a composite key of plain
485
+ // integers must be supplied, and the database rejects the row otherwise
486
+ // (E010). Marking every PK column optional let `create({ data: {} })` on
487
+ // a junction table typecheck and fail at runtime, which is the opposite of
488
+ // what a generated type is for.
477
489
  lines.push(`/** Input type for creating a row in \`${docSafe(table.name)}\` */`);
478
490
  lines.push(`export type ${typeName}Create = {`);
479
491
  for (const col of table.columns) {
480
492
  // STORED generated columns are computed by the database, never writable.
481
493
  if (col.isGeneratedStored)
482
494
  continue;
483
- const isPk = table.primaryKey.includes(col.name);
484
- const isOptional = col.hasDefault || col.nullable || isPk;
495
+ const isGenerated = col.isGenerated === true;
496
+ const isOptional = isGenerated || col.hasDefault || col.nullable;
485
497
  if (isOptional) {
486
- const reason = isPk ? 'auto-generated' : col.hasDefault ? 'has default' : 'nullable';
498
+ const reason = isGenerated ? 'auto-generated' : col.hasDefault ? 'has default' : 'nullable';
487
499
  lines.push(` /** Optional: ${reason} */`);
488
500
  lines.push(` ${quoteIfNeeded(col.field)}?: ${writeColumnTsType(col, schema.enums)};`);
489
501
  }
@@ -770,18 +782,18 @@ function generateZod(schema, options) {
770
782
  }
771
783
  lines.push('});');
772
784
  lines.push('');
773
- // Create schema, STORED generated columns can never be written; PK,
774
- // defaulted, and nullable columns are optional.
785
+ // Create schema, STORED generated columns can never be written;
786
+ // server-generated, defaulted, and nullable columns are optional, the same
787
+ // rule as the `*Create` type (a PK with none of those is required).
775
788
  lines.push(`/** Zod schema for creating a \`${docSafe(table.name)}\` row */`);
776
789
  lines.push(`export const ${typeName}CreateSchema = z.object({`);
777
790
  for (const col of table.columns) {
778
791
  if (col.isGeneratedStored)
779
792
  continue;
780
- const isPk = table.primaryKey.includes(col.name);
781
793
  let expr = zodBaseType(col, schema.enums, true);
782
794
  if (col.nullable)
783
795
  expr += '.nullable()';
784
- if (col.hasDefault || col.nullable || isPk)
796
+ if (col.isGenerated === true || col.hasDefault || col.nullable)
785
797
  expr += '.optional()';
786
798
  lines.push(` ${quoteIfNeeded(col.field)}: ${expr},`);
787
799
  }
@@ -810,6 +822,11 @@ function generateZod(schema, options) {
810
822
  // metadata.ts generator
811
823
  // ---------------------------------------------------------------------------
812
824
  function generateMetadata(schema, options) {
825
+ // metadata.ts is where a field collision does its runtime damage: the
826
+ // emitted `columnMap` keeps whichever column was written last, so refuse it
827
+ // here as well as in the type emitters (see assertEmittableSchema).
828
+ for (const table of Object.values(schema.tables))
829
+ (0, introspect_js_1.assertDistinctColumnFields)(table.name, table.columns);
813
830
  const lines = [
814
831
  ...generatedFileHeader(options),
815
832
  "import type { SchemaMetadata } from 'turbine-orm';",
@@ -1025,15 +1042,21 @@ function generateIndex(schema, options) {
1025
1042
  lines.push(' }');
1026
1043
  lines.push('}');
1027
1044
  lines.push('');
1028
- // Augment TurbineClient via interface merging with a typed $transaction
1029
- // overload. The callback parameter is narrowed to `TypedTransactionClient`
1030
- // so users get autocomplete on `tx.users`, `tx.posts`, etc.
1045
+ // Augment TurbineClient via interface merging with typed $transaction and
1046
+ // $withSession overloads. The callback parameter is narrowed to
1047
+ // `TypedTransactionClient` so users get autocomplete on `tx.users`,
1048
+ // `tx.posts`, etc. $withSession is the RLS shorthand for
1049
+ // `$transaction(fn, { sessionContext })`; it used to be left out, so its
1050
+ // callback stayed the untyped base `TransactionClient` and the documented
1051
+ // `tx.<table>.findMany()` did not compile on a generated client.
1031
1052
  //
1032
- // IMPORTANT: the merged member must be compatible with the base class's
1033
- // $transaction ON ITS OWN (TS2415), since v0.26 the base method also has a
1053
+ // IMPORTANT: each merged member must be compatible with the base class's
1054
+ // member ON ITS OWN (TS2415), since v0.26 the base $transaction also has a
1034
1055
  // batch-array overload (`$transaction([...queries])`), so the merged
1035
1056
  // interface must redeclare BOTH signatures. Emitting only the callback form
1036
- // makes every generated client fail `tsc` with "incorrectly extends".
1057
+ // makes every generated client fail `tsc` with "incorrectly extends". The
1058
+ // $withSession overload mirrors the base parameter list exactly for the same
1059
+ // reason; src/test/generate-typecheck.test.ts compiles the result.
1037
1060
  lines.push('export interface TurbineClient {');
1038
1061
  lines.push(' /**');
1039
1062
  lines.push(' * Run a callback inside a transaction. The callback receives a typed');
@@ -1050,6 +1073,15 @@ function generateIndex(schema, options) {
1050
1073
  lines.push(' $transaction<T extends readonly DeferredQuery<unknown>[]>(');
1051
1074
  lines.push(' queries: readonly [...T],');
1052
1075
  lines.push(' ): Promise<PipelineResults<T>>;');
1076
+ lines.push(' /**');
1077
+ lines.push(' * Run a callback inside a transaction with the given session GUCs applied');
1078
+ lines.push(' * via `set_config(..., true)` (the RLS / multi-tenant shorthand). The');
1079
+ lines.push(' * callback receives a typed `TypedTransactionClient`, same as `$transaction`.');
1080
+ lines.push(' */');
1081
+ lines.push(' $withSession<R>(');
1082
+ lines.push(' context: Record<string, string | number | boolean>,');
1083
+ lines.push(' fn: (tx: TypedTransactionClient) => Promise<R>,');
1084
+ lines.push(' ): Promise<R>;');
1053
1085
  lines.push('}');
1054
1086
  lines.push('');
1055
1087
  // Factory function with JSDoc
@@ -44,7 +44,7 @@ export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type Neste
44
44
  export { HttpJsonSink, type HttpJsonSinkOptions, type MetricsFlushBatch, type MetricsFlushRow, type ObserveConfig, type ObserveHandle, type ObserveSink, PgMetricsSink, type PgMetricsSinkOptions, } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
46
  export { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
47
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonEncoding, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderDirection, type PrivilegeOption, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, UNSAFE, type Unsafe, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
47
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonEncoding, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderBySpec, type OrderDirection, type PrivilegeOption, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationOrderBy, type RelationOrderByChain, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, UNSAFE, type Unsafe, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
48
48
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
49
49
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, PrismaSchemaSource, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
50
50
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, withDbFieldNames, } from './schema.js';
@@ -91,6 +91,18 @@ export interface IntrospectOptions {
91
91
  * {@link detectUniqueForeignKeySets}.
92
92
  */
93
93
  legacyToManyUniques?: boolean;
94
+ /**
95
+ * Use the raw database column name as each column's TypeScript field
96
+ * (`user_id` stays `user_id`) instead of the camelCase default (`userId`).
97
+ * The same identity mapping `withDbFieldNames` applies at generate time, so
98
+ * a schema introspected with this flag is byte-identical after that
99
+ * transform; declaring it here as well is what makes it the escape from the
100
+ * field-collision refusal (see {@link assertDistinctColumnFields}): a table
101
+ * carrying both `"createdAt"` and `created_at` has two distinct raw names and
102
+ * one shared camelCase name, and only the flag can tell introspection which
103
+ * of those two facts to build the field from.
104
+ */
105
+ keepColumnNames?: boolean;
94
106
  /**
95
107
  * Called with any {@link DEFAULT_EXCLUDED_TABLES} that were present in the
96
108
  * database but dropped from this run (F12), so the CLI can print a
@@ -145,6 +157,27 @@ export declare function introspect(options: IntrospectOptions): Promise<SchemaMe
145
157
  * means the call sites it was supposed to fix break at runtime instead.
146
158
  */
147
159
  export declare function applyRelationRenames(schema: SchemaMetadata, renames: Record<string, Record<string, string>>): SchemaMetadata;
160
+ /**
161
+ * THE field-collision rule: two columns of one table may never resolve to the
162
+ * same TypeScript field.
163
+ *
164
+ * Fields are derived with `snakeToCamel`, so a table carrying both a quoted
165
+ * `"createdAt"` and a `created_at` column (a Prisma-era column beside a
166
+ * hand-written one is the common way to get there) yields two `createdAt`
167
+ * fields. Nothing used to refuse that, and every consumer keyed by field then
168
+ * lost a column silently: `columnMap` kept whichever column was written last,
169
+ * reads folded both columns into one property, a write to the field reached
170
+ * only one of them, and `types.ts` carried a duplicate member that failed the
171
+ * consumer's `tsc` (TS2300) while `turbine generate` exited 0.
172
+ *
173
+ * A thrown error names the table, every colliding column and the fix. There is
174
+ * no per-column rename option, so the fix is `keepColumnNames` (the field is
175
+ * then the raw column name, and two distinct columns cannot collide) or a
176
+ * rename in the database. Applied once per table right after the catalog's
177
+ * columns are grouped, and again by the generate.ts emitters so a schema that
178
+ * never went through introspection is refused at the same boundary.
179
+ */
180
+ export declare function assertDistinctColumnFields(tableName: string, columns: readonly ColumnMetadata[]): void;
148
181
  /**
149
182
  * PostgreSQL catalog introspector: reads information_schema + pg_catalog and
150
183
  * produces {@link SchemaMetadata}. This is the implementation wrapped by
@@ -20,6 +20,7 @@ exports.assertSafeCatalogIdentifier = assertSafeCatalogIdentifier;
20
20
  exports.assertSafeCatalogSchema = assertSafeCatalogSchema;
21
21
  exports.introspect = introspect;
22
22
  exports.applyRelationRenames = applyRelationRenames;
23
+ exports.assertDistinctColumnFields = assertDistinctColumnFields;
23
24
  exports.introspectPostgresCatalog = introspectPostgresCatalog;
24
25
  exports.parseIndexKeyEntries = parseIndexKeyEntries;
25
26
  exports.indexKeyColumn = indexKeyColumn;
@@ -488,6 +489,50 @@ function applyRelationRenames(schema, renames) {
488
489
  }
489
490
  return { ...schema, tables };
490
491
  }
492
+ /**
493
+ * THE field-collision rule: two columns of one table may never resolve to the
494
+ * same TypeScript field.
495
+ *
496
+ * Fields are derived with `snakeToCamel`, so a table carrying both a quoted
497
+ * `"createdAt"` and a `created_at` column (a Prisma-era column beside a
498
+ * hand-written one is the common way to get there) yields two `createdAt`
499
+ * fields. Nothing used to refuse that, and every consumer keyed by field then
500
+ * lost a column silently: `columnMap` kept whichever column was written last,
501
+ * reads folded both columns into one property, a write to the field reached
502
+ * only one of them, and `types.ts` carried a duplicate member that failed the
503
+ * consumer's `tsc` (TS2300) while `turbine generate` exited 0.
504
+ *
505
+ * A thrown error names the table, every colliding column and the fix. There is
506
+ * no per-column rename option, so the fix is `keepColumnNames` (the field is
507
+ * then the raw column name, and two distinct columns cannot collide) or a
508
+ * rename in the database. Applied once per table right after the catalog's
509
+ * columns are grouped, and again by the generate.ts emitters so a schema that
510
+ * never went through introspection is refused at the same boundary.
511
+ */
512
+ function assertDistinctColumnFields(tableName, columns) {
513
+ const columnsByField = new Map();
514
+ for (const col of columns) {
515
+ const names = columnsByField.get(col.field);
516
+ if (names)
517
+ names.push(col.name);
518
+ else
519
+ columnsByField.set(col.field, [col.name]);
520
+ }
521
+ const collisions = [];
522
+ for (const [field, names] of columnsByField) {
523
+ if (names.length < 2)
524
+ continue;
525
+ const quoted = names.map((n) => `"${n}"`);
526
+ const list = `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
527
+ collisions.push(`columns ${list} ${names.length === 2 ? 'both' : 'all'} resolve to the field "${field}"`);
528
+ }
529
+ if (collisions.length === 0)
530
+ return;
531
+ throw new errors_js_1.ValidationError(`Field collision on table "${tableName}": ${collisions.join('; ')}. A client addresses one column per field, ` +
532
+ 'so reads would fold the colliding columns into one property and a write to that field would reach only ' +
533
+ 'one of them. Rename one of the columns, or set `keepColumnNames: true` in turbine.config.ts ' +
534
+ '(`turbine generate --keep-column-names`) so every field is its raw column name.');
535
+ }
491
536
  /**
492
537
  * PostgreSQL catalog introspector: reads information_schema + pg_catalog and
493
538
  * produces {@link SchemaMetadata}. This is the implementation wrapped by
@@ -559,7 +604,9 @@ async function introspectPostgresCatalog(options) {
559
604
  const arrayType = dialect.arrayType?.(baseType) ?? 'text[]';
560
605
  const col = {
561
606
  name: row.column_name,
562
- field: (0, schema_js_1.snakeToCamel)(row.column_name),
607
+ // Under keepColumnNames the field IS the column name, which is why two
608
+ // distinct columns can never collide there (see assertDistinctColumnFields).
609
+ field: options.keepColumnNames ? row.column_name : (0, schema_js_1.snakeToCamel)(row.column_name),
563
610
  dialectType,
564
611
  pgType: dialectType,
565
612
  tsType: dialect.typeToTypeScript?.(isArray ? dialectType : baseType, isNullable) ??
@@ -593,6 +640,12 @@ async function introspectPostgresCatalog(options) {
593
640
  columnsByTable.set(tableName, []);
594
641
  columnsByTable.get(tableName).push(col);
595
642
  }
643
+ // Every consumer below keys the table by FIELD (columnMap, the relation
644
+ // derivation's shadow check, the generated interface), so the field set
645
+ // must be sound before any of them runs. THE one place the rule is applied
646
+ // to a live catalog; generate.ts re-asserts it for schemas built elsewhere.
647
+ for (const [tableName, cols] of columnsByTable)
648
+ assertDistinctColumnFields(tableName, cols);
596
649
  // ----- Group primary keys by table -----
597
650
  const pkByTable = new Map();
598
651
  for (const row of pkResult.rows) {
package/dist/cjs/mssql.js CHANGED
@@ -1058,6 +1058,26 @@ function buildForJsonManyToMany(dialect, ctx, h) {
1058
1058
  `FOR JSON PATH, INCLUDE_NULL_VALUES), '[]')`);
1059
1059
  }
1060
1060
  const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
1061
+ /**
1062
+ * A `bit` column, as the driver actually delivers it.
1063
+ *
1064
+ * tedious parses TDS `BITTYPE` with `!!value`, so `sys.indexes.is_unique`
1065
+ * arrives as a JavaScript BOOLEAN, never as 1. Reading it through {@link num}
1066
+ * and comparing to 1 therefore answered `false` for EVERY unique index on SQL
1067
+ * Server, which emptied `uniqueColumns` on every introspected table and set
1068
+ * `IndexMetadata.unique` to false on every unique index. The visible cost was a
1069
+ * `findUnique` (and, once it carried the same rule, an `upsert`) on a genuinely
1070
+ * unique non-PK column being refused as not identifying one row, on the one
1071
+ * engine where the metadata could not say otherwise.
1072
+ *
1073
+ * The unit test could not see it: its mock built index rows with `1` and `0`
1074
+ * because the helper's parameter was typed `number`, so the fixture chose the
1075
+ * shape the code already handled. Only the live SQL Server leg produces the
1076
+ * boolean. Written to accept all three spellings rather than the one this
1077
+ * driver happens to send, because another `mssql` transport is free to send a
1078
+ * number or the string `'1'` and this is metadata, not a hot path.
1079
+ */
1080
+ const bitIsTrue = (v) => v === true || v === 1 || v === '1' || v === 'true';
1061
1081
  /**
1062
1082
  * Derive relations from the FK list via the SHARED introspection pipeline
1063
1083
  * (`deriveEngineRelations` → `buildRelationsFromForeignKeys` +
@@ -1193,7 +1213,7 @@ async function introspectMssqlWith(exec, schema = 'dbo', options = {}) {
1193
1213
  const key = `${t}.${name}`;
1194
1214
  let g = indexGroups.get(key);
1195
1215
  if (!g) {
1196
- g = { table: t, name, unique: num(r.IS_UNIQUE) === 1, columns: [] };
1216
+ g = { table: t, name, unique: bitIsTrue(r.IS_UNIQUE), columns: [] };
1197
1217
  indexGroups.set(key, g);
1198
1218
  }
1199
1219
  g.columns.push(String(r.COLUMN_NAME));
@@ -20,6 +20,7 @@ exports.createManyShapeRuns = createManyShapeRuns;
20
20
  exports.executeNestedCreate = executeNestedCreate;
21
21
  exports.executeNestedUpdate = executeNestedUpdate;
22
22
  const errors_js_1 = require("./errors.js");
23
+ const compound_unique_js_1 = require("./query/compound-unique.js");
23
24
  const utils_js_1 = require("./query/utils.js");
24
25
  const schema_js_1 = require("./schema.js");
25
26
  const MAX_DEPTH = 10;
@@ -230,6 +231,65 @@ function validateOps(relationName, ops, isUpdate) {
230
231
  }
231
232
  }
232
233
  }
234
+ /**
235
+ * The `with` clause that reads back the tree a nested write just wrote.
236
+ *
237
+ * Built from the DATA, recursively: a relation whose payload itself contains
238
+ * relation writes is requested as `{ rel: { with: { ... } } }` rather than
239
+ * `{ rel: true }`. It used to be the top-level keys alone, so a depth-3 create
240
+ * wrote all three levels and returned an object whose grandchildren were
241
+ * missing entirely, which a caller reads as "there are none".
242
+ *
243
+ * Only relations that were actually WRITTEN are requested, so a create with no
244
+ * nested data still reads back exactly what it did before. The walk is capped
245
+ * at {@link MAX_DEPTH}, the cap the write walk itself uses, and the builder's
246
+ * own relation-depth guard is the backstop below that.
247
+ */
248
+ function readBackWith(schema, tableName, relations, depth = 0) {
249
+ const tableMeta = schema.tables[tableName];
250
+ if (!tableMeta || depth >= MAX_DEPTH)
251
+ return undefined;
252
+ const clause = {};
253
+ for (const [relName, ops] of Object.entries(relations)) {
254
+ const rel = tableMeta.relations[relName];
255
+ if (!rel)
256
+ continue;
257
+ // Every payload this relation was written with: `create` / `update` /
258
+ // `upsert` take objects (or arrays of them) whose own keys may name
259
+ // relations of the TARGET table. `connect` / `disconnect` / `set` name
260
+ // existing rows and write no nested data, so they contribute nothing here.
261
+ const nested = {};
262
+ for (const key of ['create', 'update', 'upsert']) {
263
+ const payload = ops[key];
264
+ if (payload === undefined)
265
+ continue;
266
+ for (const item of toArray(payload)) {
267
+ // `{ where, data }` (nested update) and `{ where, create, update }`
268
+ // (nested upsert) carry their written fields one level in.
269
+ const bodies = isPlainRecord(item) && ('data' in item || 'create' in item || 'update' in item)
270
+ ? [item.data, item.create, item.update]
271
+ : [item];
272
+ for (const body of bodies) {
273
+ if (!isPlainRecord(body))
274
+ continue;
275
+ const child = schema.tables[rel.to];
276
+ if (!child)
277
+ continue;
278
+ for (const [k, v] of Object.entries(extractRelationFields(body, child).relations)) {
279
+ nested[k] = { ...(nested[k] ?? {}), ...v };
280
+ }
281
+ }
282
+ }
283
+ }
284
+ const deeper = Object.keys(nested).length > 0 ? readBackWith(schema, rel.to, nested, depth + 1) : undefined;
285
+ clause[relName] = deeper ? { with: deeper } : true;
286
+ }
287
+ return Object.keys(clause).length > 0 ? clause : undefined;
288
+ }
289
+ /** A plain object (not an array, Date or null), the shape nested payloads take. */
290
+ function isPlainRecord(value) {
291
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date);
292
+ }
233
293
  /**
234
294
  * Build a PK-based where clause from a parent row and its table metadata.
235
295
  */
@@ -316,7 +376,20 @@ function belongsToCorrelationWhere(ctx, rel, parentRow, parentTable) {
316
376
  * field itself, the two are combined with `AND` instead, so neither predicate
317
377
  * can silently overwrite the other.
318
378
  */
319
- function scopeWhereToParent(target, correlation) {
379
+ function scopeWhereToParent(
380
+ // `true` is the to-one "the single related row" spelling, the only non-object
381
+ // value assertTargetSelectsSomething lets through.
382
+ target, correlation) {
383
+ // `disconnect: true` / `delete: true` on a to-one relation (the only place
384
+ // `true` gets past assertTargetSelectsSomething) leaves the caller with no
385
+ // selector at all: the predicate below is the correlation and nothing else,
386
+ // written entirely by the engine. Branded so the single-row write rule reads
387
+ // the relation's declared cardinality as the uniqueness source, since a
388
+ // `hasOne` FK is frequently not ALSO declared unique in metadata and the
389
+ // rule would otherwise refuse the write with "Name a unique key" against a
390
+ // call that never named one. See markInternalRowSelector.
391
+ if (target === true)
392
+ return (0, compound_unique_js_1.markInternalRowSelector)({ ...correlation });
320
393
  for (const key of Object.keys(correlation)) {
321
394
  // Branded as Turbine's own: this `AND` has a FIXED arity of two, chosen
322
395
  // here rather than reachable from a request body, so it must not cost the
@@ -830,15 +903,13 @@ async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
830
903
  await processManyToMany(ctx, rel, relName, ops, parentRow);
831
904
  }
832
905
  }
833
- // Build the `with` clause for the final read to return the full tree
834
- const withClause = {};
835
- for (const relName of Object.keys(relations)) {
836
- withClause[relName] = true;
837
- }
906
+ // The `with` clause for the final read: the whole tree that was written, at
907
+ // every depth (see readBackWith), not only its top level.
908
+ const withClause = readBackWith(ctx.schema, tableName, relations);
838
909
  // Final read using existing json_agg machinery
839
910
  const fullRow = await ctx.tx.table(tableName).findUnique({
840
911
  where: pkWhere(tableMeta, parentRow),
841
- with: Object.keys(withClause).length > 0 ? withClause : undefined,
912
+ with: withClause,
842
913
  });
843
914
  return (fullRow ?? parentRow);
844
915
  }
@@ -941,14 +1012,10 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
941
1012
  await processManyToMany(ctx, rel, relName, ops, parentRow);
942
1013
  }
943
1014
  }
944
- // Final read with all touched relations
945
- const withClause = {};
946
- for (const relName of Object.keys(relations)) {
947
- withClause[relName] = true;
948
- }
1015
+ // Final read with all touched relations, to the depth they were written.
949
1016
  const fullRow = await ctx.tx.table(tableName).findUnique({
950
1017
  where: pkWhere(tableMeta, parentRow),
951
- with: Object.keys(withClause).length > 0 ? withClause : undefined,
1018
+ with: readBackWith(ctx.schema, tableName, relations),
952
1019
  });
953
1020
  return (fullRow ?? parentRow);
954
1021
  }
@@ -1338,7 +1405,11 @@ async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTabl
1338
1405
  // shared correlation helper (like every sibling operation) so a NULL parent
1339
1406
  // FK reports not-found instead of compiling to `refField IS NULL` and
1340
1407
  // updating EVERY row of the related table with a null reference key.
1341
- const where = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
1408
+ // No caller `where` exists on this shape (`update: { data }`), so the
1409
+ // predicate is entirely the engine's: branded for the single-row write rule
1410
+ // exactly as the to-one disconnect/delete correlation is.
1411
+ const correlationWhere = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
1412
+ const where = correlationWhere && (0, compound_unique_js_1.markInternalRowSelector)(correlationWhere);
1342
1413
  if (!where) {
1343
1414
  // Parent FK is NULL: it points at nothing, so nothing is in scope to update.
1344
1415
  const nullFk = Object.fromEntries((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).map((c) => [c, null]));
@@ -58,6 +58,8 @@ export interface PgPoolClient {
58
58
  _types?: unknown;
59
59
  release(err?: Error | boolean): void;
60
60
  }
61
+ /** Did {@link runPipelined} leave this client unusable? See {@link PIPELINE_DISCARD}. */
62
+ export declare function pipelineClientNeedsDiscard(client: unknown): boolean;
61
63
  export interface PipelineRunOptions {
62
64
  /**
63
65
  * Whether to wrap the pipeline in BEGIN/COMMIT (default: true).
@@ -21,11 +21,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
21
21
  return (mod && mod.__esModule) ? mod : { "default": mod };
22
22
  };
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.pipelineClientNeedsDiscard = pipelineClientNeedsDiscard;
24
25
  exports.runPipelined = runPipelined;
25
26
  exports.supportsExtendedPipeline = supportsExtendedPipeline;
26
27
  const result_1 = __importDefault(require("pg/lib/result"));
27
28
  const utils_1 = require("pg/lib/utils");
28
29
  const errors_js_1 = require("./errors.js");
30
+ /**
31
+ * Marks a client this module could not hand back in a reusable state, so the
32
+ * caller releases it WITH an error and the pool drops it instead of lending it
33
+ * out again.
34
+ *
35
+ * A symbol, and `Symbol.for` so an ESM and a CJS copy of this module agree on
36
+ * it (the same reason the internal-combinator brand uses one). Set only when
37
+ * the backend still reports an open or aborted transaction after this module
38
+ * has done what it can, which after {@link runPipelined}'s rollback means the
39
+ * rollback itself did not take.
40
+ */
41
+ const PIPELINE_DISCARD = Symbol.for('turbine.pipeline.discardClient');
42
+ /** Did {@link runPipelined} leave this client unusable? See {@link PIPELINE_DISCARD}. */
43
+ function pipelineClientNeedsDiscard(client) {
44
+ return (typeof client === 'object' && client !== null && client[PIPELINE_DISCARD] === true);
45
+ }
29
46
  // ---------------------------------------------------------------------------
30
47
  // Event names we intercept
31
48
  // ---------------------------------------------------------------------------
@@ -127,6 +144,10 @@ async function runPipelined(client, queries, options = {}) {
127
144
  let timeoutHandle;
128
145
  // Whether cleanup has already been performed
129
146
  let cleaned = false;
147
+ /** Transaction status reported by the most recent ReadyForQuery. */
148
+ let txStatus;
149
+ /** Whether the recovery ROLLBACK has already been sent (it is sent at most once). */
150
+ let rollbackSent = false;
130
151
  /**
131
152
  * Map commandComplete index to the corresponding results[] index.
132
153
  * In transactional mode: index 0 = BEGIN, 1..N = queries, N+1 = COMMIT
@@ -165,6 +186,62 @@ async function runPipelined(client, queries, options = {}) {
165
186
  // -----------------------------------------------------------------------
166
187
  // Finalize: called on final readyForQuery
167
188
  // -----------------------------------------------------------------------
189
+ /**
190
+ * Return the connection to the pool in a state the next borrower can use.
191
+ *
192
+ * The hazard this exists for: a transactional batch is `BEGIN` + queries +
193
+ * `COMMIT` + ONE `Sync`, so a query error makes the backend skip everything
194
+ * up to that Sync, the `COMMIT` included, and the connection goes back to
195
+ * the pool `idle in transaction (aborted)`. The next borrower's first
196
+ * statement then failed with `25P02`, a `$transaction` that landed on it
197
+ * lost its write, and a non-transactional pipeline failed every slot and
198
+ * released it still aborted, so the poisoning survived indefinitely.
199
+ *
200
+ * The recovery is one `ROLLBACK` on the same connection, sent AFTER the
201
+ * ReadyForQuery that says the backend will accept a new statement, and
202
+ * waited for: its own ReadyForQuery brings us back here with `I`. That
203
+ * keeps the connection, which matters at small pool sizes where discarding
204
+ * is a reconnect on the caller's next query.
205
+ *
206
+ * Returns true when the caller should finalize now, false when a rollback
207
+ * is in flight and the next ReadyForQuery will finish the job.
208
+ */
209
+ function settleTransactionState() {
210
+ // `I` (idle) is the ordinary case and needs nothing, and neither does a
211
+ // batch that had no error: transactional mode's COMMIT closed the
212
+ // transaction, non-transactional mode never opened one. That keeps the
213
+ // happy path at exactly one round trip, which a `ROLLBACK`-on-unknown
214
+ // would have cost an extra Sync (caught by the cork/uncork test). A real
215
+ // backend always sends the status byte; an emitter without one is only
216
+ // ever a test double.
217
+ if (txStatus === 'I')
218
+ return true;
219
+ if (txStatus === undefined && !(transactional && pipelineError))
220
+ return true;
221
+ if (!transactional || rollbackSent) {
222
+ // Either nothing to roll back (non-transactional mode opens no
223
+ // transaction of its own), or the rollback already ran and did not
224
+ // clear the status. Hand the client back marked for disposal.
225
+ if (txStatus === 'E' || txStatus === 'T') {
226
+ Object.defineProperty(client, PIPELINE_DISCARD, { value: true, enumerable: false, configurable: true });
227
+ }
228
+ return true;
229
+ }
230
+ rollbackSent = true;
231
+ try {
232
+ connection.parse({ text: 'ROLLBACK', name: '' });
233
+ connection.bind({ portal: '', statement: '', values: [], valueMapper: utils_1.prepareValue });
234
+ connection.execute({ portal: '', rows: 0 });
235
+ connection.sync();
236
+ return false;
237
+ }
238
+ catch {
239
+ // The socket would not take the rollback, so this connection cannot be
240
+ // repaired from here. Mark it and let the caller drop it.
241
+ Object.defineProperty(client, PIPELINE_DISCARD, { value: true, enumerable: false, configurable: true });
242
+ return true;
243
+ }
244
+ }
168
245
  function finalize() {
169
246
  cleanup();
170
247
  if (transactional && pipelineError) {
@@ -291,11 +368,19 @@ async function runPipelined(client, queries, options = {}) {
291
368
  function onPortalSuspended() {
292
369
  // We don't use row-limited portals
293
370
  }
294
- function onReadyForQuery() {
295
- rfqCount++;
296
- if (rfqCount >= expectedRfq) {
371
+ function onReadyForQuery(msg) {
372
+ txStatus = msg?.status;
373
+ // The recovery ROLLBACK's own ReadyForQuery is not one of the batch's, so
374
+ // it must not advance the counter that decides when the batch is done.
375
+ if (rollbackSent) {
297
376
  finalize();
377
+ return;
298
378
  }
379
+ rfqCount++;
380
+ if (rfqCount < expectedRfq)
381
+ return;
382
+ if (settleTransactionState())
383
+ finalize();
299
384
  }
300
385
  // -----------------------------------------------------------------------
301
386
  // Attach our listeners
@@ -231,7 +231,19 @@ async function executePipeline(pool, queries, options) {
231
231
  throw (0, errors_js_1.wrapPgError)(err);
232
232
  }
233
233
  finally {
234
- client.release();
234
+ // A client the pipeline could not return to a clean state is released WITH
235
+ // an error, which is how pg-pool is told to drop it rather than lend it
236
+ // out again. `runPipelined` rolls back its own aborted transaction, so this
237
+ // is the residue: a rollback that did not take, or a backend left in a
238
+ // transaction by something other than this module. Releasing such a client
239
+ // normally is what turned one failed batch into `25P02` on somebody else's
240
+ // query.
241
+ if ((0, pipeline_submittable_js_1.pipelineClientNeedsDiscard)(client)) {
242
+ client.release(new Error('turbine: pipeline connection left an open transaction and was discarded'));
243
+ }
244
+ else {
245
+ client.release();
246
+ }
235
247
  }
236
248
  }
237
249
  /**
@@ -40,7 +40,11 @@
40
40
  * yields `primaryKey: []` and a warning; single-row ops on it fail loudly.
41
41
  * - `isGenerated` is always `false`: `describe` does not expose PowDB's `auto`
42
42
  * modifier, so an introspected int PK is treated as client-supplied unless
43
- * the caller hand-edits the metadata.
43
+ * the caller hand-edits the metadata. Since 0.78.0 that costs more than it
44
+ * did: the generated `*Create` type marks a primary key optional only when
45
+ * it is server-generated, defaulted or nullable, so codegen driven from
46
+ * THIS metadata makes an `auto` id REQUIRED on create. `defineSchema` is
47
+ * the path that knows, and remains the recommended one.
44
48
  * - Doc-field expression indexes are INVISIBLE to `describe`, so they never
45
49
  * round-trip; only plain `unique`/`index` columns appear in `indexes`.
46
50
  * - `datetime` / `uuid` / `bytes` columns map to read-oriented TS types
@@ -41,7 +41,11 @@
41
41
  * yields `primaryKey: []` and a warning; single-row ops on it fail loudly.
42
42
  * - `isGenerated` is always `false`: `describe` does not expose PowDB's `auto`
43
43
  * modifier, so an introspected int PK is treated as client-supplied unless
44
- * the caller hand-edits the metadata.
44
+ * the caller hand-edits the metadata. Since 0.78.0 that costs more than it
45
+ * did: the generated `*Create` type marks a primary key optional only when
46
+ * it is server-generated, defaulted or nullable, so codegen driven from
47
+ * THIS metadata makes an `auto` id REQUIRED on create. `defineSchema` is
48
+ * the path that knows, and remains the recommended one.
45
49
  * - Doc-field expression indexes are INVISIBLE to `describe`, so they never
46
50
  * round-trip; only plain `unique`/`index` columns appear in `indexes`.
47
51
  * - `datetime` / `uuid` / `bytes` columns map to read-oriented TS types