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
package/dist/generate.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
12
  import { dirname, join, relative, resolve } from 'node:path';
13
13
  import { ValidationError } from './errors.js';
14
+ import { assertDistinctColumnFields } from './introspect.js';
14
15
  import { pgTypeToTs, singularize, snakeToPascal, timeOfDayKind, withDbFieldNames, } from './schema.js';
15
16
  /** Get the TypeScript type name for a table (singularized PascalCase) */
16
17
  function entityName(tableName) {
@@ -388,6 +389,11 @@ export function assertEmittableSchema(schema) {
388
389
  for (const table of Object.values(schema.tables)) {
389
390
  requireEmittable(entityName(table.name), `table "${table.name}"`, 'the generated entity type name');
390
391
  requireEmittable(snakeToCamelStr(table.name), `table "${table.name}"`, 'the generated client accessor');
392
+ // Two columns on one field would emit a duplicate interface member (TS2300
393
+ // in the consumer's build) and a `columnMap` missing a column. The rule is
394
+ // introspect.ts's; re-asserting it here covers a hand-built or
395
+ // engine-introspected schema that never went through that path.
396
+ assertDistinctColumnFields(table.name, table.columns);
391
397
  // Only the relations that reach the TYPE layer: a relation shadowing a
392
398
  // column field is already dropped from types.ts, so refusing on its name
393
399
  // would refuse a schema that generates fine.
@@ -458,18 +464,24 @@ export function generateTypes(schema, options) {
458
464
  lines.push('}');
459
465
  lines.push('');
460
466
  // --- Create input type ---
461
- // Required: non-nullable columns without defaults (except PK)
462
- // Optional: nullable columns (default to NULL) or columns with explicit defaults
467
+ // Optional: server-generated columns (serial / identity), columns with a
468
+ // default, and nullable columns (default to NULL). Everything else is
469
+ // required, PRIMARY KEY MEMBERSHIP INCLUDED. A text natural key, a
470
+ // client-supplied uuid with no default, or a composite key of plain
471
+ // integers must be supplied, and the database rejects the row otherwise
472
+ // (E010). Marking every PK column optional let `create({ data: {} })` on
473
+ // a junction table typecheck and fail at runtime, which is the opposite of
474
+ // what a generated type is for.
463
475
  lines.push(`/** Input type for creating a row in \`${docSafe(table.name)}\` */`);
464
476
  lines.push(`export type ${typeName}Create = {`);
465
477
  for (const col of table.columns) {
466
478
  // STORED generated columns are computed by the database, never writable.
467
479
  if (col.isGeneratedStored)
468
480
  continue;
469
- const isPk = table.primaryKey.includes(col.name);
470
- const isOptional = col.hasDefault || col.nullable || isPk;
481
+ const isGenerated = col.isGenerated === true;
482
+ const isOptional = isGenerated || col.hasDefault || col.nullable;
471
483
  if (isOptional) {
472
- const reason = isPk ? 'auto-generated' : col.hasDefault ? 'has default' : 'nullable';
484
+ const reason = isGenerated ? 'auto-generated' : col.hasDefault ? 'has default' : 'nullable';
473
485
  lines.push(` /** Optional: ${reason} */`);
474
486
  lines.push(` ${quoteIfNeeded(col.field)}?: ${writeColumnTsType(col, schema.enums)};`);
475
487
  }
@@ -756,18 +768,18 @@ export function generateZod(schema, options) {
756
768
  }
757
769
  lines.push('});');
758
770
  lines.push('');
759
- // Create schema, STORED generated columns can never be written; PK,
760
- // defaulted, and nullable columns are optional.
771
+ // Create schema, STORED generated columns can never be written;
772
+ // server-generated, defaulted, and nullable columns are optional, the same
773
+ // rule as the `*Create` type (a PK with none of those is required).
761
774
  lines.push(`/** Zod schema for creating a \`${docSafe(table.name)}\` row */`);
762
775
  lines.push(`export const ${typeName}CreateSchema = z.object({`);
763
776
  for (const col of table.columns) {
764
777
  if (col.isGeneratedStored)
765
778
  continue;
766
- const isPk = table.primaryKey.includes(col.name);
767
779
  let expr = zodBaseType(col, schema.enums, true);
768
780
  if (col.nullable)
769
781
  expr += '.nullable()';
770
- if (col.hasDefault || col.nullable || isPk)
782
+ if (col.isGenerated === true || col.hasDefault || col.nullable)
771
783
  expr += '.optional()';
772
784
  lines.push(` ${quoteIfNeeded(col.field)}: ${expr},`);
773
785
  }
@@ -796,6 +808,11 @@ export function generateZod(schema, options) {
796
808
  // metadata.ts generator
797
809
  // ---------------------------------------------------------------------------
798
810
  export function generateMetadata(schema, options) {
811
+ // metadata.ts is where a field collision does its runtime damage: the
812
+ // emitted `columnMap` keeps whichever column was written last, so refuse it
813
+ // here as well as in the type emitters (see assertEmittableSchema).
814
+ for (const table of Object.values(schema.tables))
815
+ assertDistinctColumnFields(table.name, table.columns);
799
816
  const lines = [
800
817
  ...generatedFileHeader(options),
801
818
  "import type { SchemaMetadata } from 'turbine-orm';",
@@ -1011,15 +1028,21 @@ export function generateIndex(schema, options) {
1011
1028
  lines.push(' }');
1012
1029
  lines.push('}');
1013
1030
  lines.push('');
1014
- // Augment TurbineClient via interface merging with a typed $transaction
1015
- // overload. The callback parameter is narrowed to `TypedTransactionClient`
1016
- // so users get autocomplete on `tx.users`, `tx.posts`, etc.
1031
+ // Augment TurbineClient via interface merging with typed $transaction and
1032
+ // $withSession overloads. The callback parameter is narrowed to
1033
+ // `TypedTransactionClient` so users get autocomplete on `tx.users`,
1034
+ // `tx.posts`, etc. $withSession is the RLS shorthand for
1035
+ // `$transaction(fn, { sessionContext })`; it used to be left out, so its
1036
+ // callback stayed the untyped base `TransactionClient` and the documented
1037
+ // `tx.<table>.findMany()` did not compile on a generated client.
1017
1038
  //
1018
- // IMPORTANT: the merged member must be compatible with the base class's
1019
- // $transaction ON ITS OWN (TS2415), since v0.26 the base method also has a
1039
+ // IMPORTANT: each merged member must be compatible with the base class's
1040
+ // member ON ITS OWN (TS2415), since v0.26 the base $transaction also has a
1020
1041
  // batch-array overload (`$transaction([...queries])`), so the merged
1021
1042
  // interface must redeclare BOTH signatures. Emitting only the callback form
1022
- // makes every generated client fail `tsc` with "incorrectly extends".
1043
+ // makes every generated client fail `tsc` with "incorrectly extends". The
1044
+ // $withSession overload mirrors the base parameter list exactly for the same
1045
+ // reason; src/test/generate-typecheck.test.ts compiles the result.
1023
1046
  lines.push('export interface TurbineClient {');
1024
1047
  lines.push(' /**');
1025
1048
  lines.push(' * Run a callback inside a transaction. The callback receives a typed');
@@ -1036,6 +1059,15 @@ export function generateIndex(schema, options) {
1036
1059
  lines.push(' $transaction<T extends readonly DeferredQuery<unknown>[]>(');
1037
1060
  lines.push(' queries: readonly [...T],');
1038
1061
  lines.push(' ): Promise<PipelineResults<T>>;');
1062
+ lines.push(' /**');
1063
+ lines.push(' * Run a callback inside a transaction with the given session GUCs applied');
1064
+ lines.push(' * via `set_config(..., true)` (the RLS / multi-tenant shorthand). The');
1065
+ lines.push(' * callback receives a typed `TypedTransactionClient`, same as `$transaction`.');
1066
+ lines.push(' */');
1067
+ lines.push(' $withSession<R>(');
1068
+ lines.push(' context: Record<string, string | number | boolean>,');
1069
+ lines.push(' fn: (tx: TypedTransactionClient) => Promise<R>,');
1070
+ lines.push(' ): Promise<R>;');
1039
1071
  lines.push('}');
1040
1072
  lines.push('');
1041
1073
  // Factory function with JSDoc
package/dist/index.d.ts CHANGED
@@ -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
@@ -461,6 +461,50 @@ export function applyRelationRenames(schema, renames) {
461
461
  }
462
462
  return { ...schema, tables };
463
463
  }
464
+ /**
465
+ * THE field-collision rule: two columns of one table may never resolve to the
466
+ * same TypeScript field.
467
+ *
468
+ * Fields are derived with `snakeToCamel`, so a table carrying both a quoted
469
+ * `"createdAt"` and a `created_at` column (a Prisma-era column beside a
470
+ * hand-written one is the common way to get there) yields two `createdAt`
471
+ * fields. Nothing used to refuse that, and every consumer keyed by field then
472
+ * lost a column silently: `columnMap` kept whichever column was written last,
473
+ * reads folded both columns into one property, a write to the field reached
474
+ * only one of them, and `types.ts` carried a duplicate member that failed the
475
+ * consumer's `tsc` (TS2300) while `turbine generate` exited 0.
476
+ *
477
+ * A thrown error names the table, every colliding column and the fix. There is
478
+ * no per-column rename option, so the fix is `keepColumnNames` (the field is
479
+ * then the raw column name, and two distinct columns cannot collide) or a
480
+ * rename in the database. Applied once per table right after the catalog's
481
+ * columns are grouped, and again by the generate.ts emitters so a schema that
482
+ * never went through introspection is refused at the same boundary.
483
+ */
484
+ export function assertDistinctColumnFields(tableName, columns) {
485
+ const columnsByField = new Map();
486
+ for (const col of columns) {
487
+ const names = columnsByField.get(col.field);
488
+ if (names)
489
+ names.push(col.name);
490
+ else
491
+ columnsByField.set(col.field, [col.name]);
492
+ }
493
+ const collisions = [];
494
+ for (const [field, names] of columnsByField) {
495
+ if (names.length < 2)
496
+ continue;
497
+ const quoted = names.map((n) => `"${n}"`);
498
+ const list = `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
499
+ collisions.push(`columns ${list} ${names.length === 2 ? 'both' : 'all'} resolve to the field "${field}"`);
500
+ }
501
+ if (collisions.length === 0)
502
+ return;
503
+ throw new ValidationError(`Field collision on table "${tableName}": ${collisions.join('; ')}. A client addresses one column per field, ` +
504
+ 'so reads would fold the colliding columns into one property and a write to that field would reach only ' +
505
+ 'one of them. Rename one of the columns, or set `keepColumnNames: true` in turbine.config.ts ' +
506
+ '(`turbine generate --keep-column-names`) so every field is its raw column name.');
507
+ }
464
508
  /**
465
509
  * PostgreSQL catalog introspector: reads information_schema + pg_catalog and
466
510
  * produces {@link SchemaMetadata}. This is the implementation wrapped by
@@ -532,7 +576,9 @@ export async function introspectPostgresCatalog(options) {
532
576
  const arrayType = dialect.arrayType?.(baseType) ?? 'text[]';
533
577
  const col = {
534
578
  name: row.column_name,
535
- field: snakeToCamel(row.column_name),
579
+ // Under keepColumnNames the field IS the column name, which is why two
580
+ // distinct columns can never collide there (see assertDistinctColumnFields).
581
+ field: options.keepColumnNames ? row.column_name : snakeToCamel(row.column_name),
536
582
  dialectType,
537
583
  pgType: dialectType,
538
584
  tsType: dialect.typeToTypeScript?.(isArray ? dialectType : baseType, isNullable) ??
@@ -566,6 +612,12 @@ export async function introspectPostgresCatalog(options) {
566
612
  columnsByTable.set(tableName, []);
567
613
  columnsByTable.get(tableName).push(col);
568
614
  }
615
+ // Every consumer below keys the table by FIELD (columnMap, the relation
616
+ // derivation's shadow check, the generated interface), so the field set
617
+ // must be sound before any of them runs. THE one place the rule is applied
618
+ // to a live catalog; generate.ts re-asserts it for schemas built elsewhere.
619
+ for (const [tableName, cols] of columnsByTable)
620
+ assertDistinctColumnFields(tableName, cols);
569
621
  // ----- Group primary keys by table -----
570
622
  const pkByTable = new Map();
571
623
  for (const row of pkResult.rows) {
package/dist/mssql.js CHANGED
@@ -1047,6 +1047,26 @@ function buildForJsonManyToMany(dialect, ctx, h) {
1047
1047
  `FOR JSON PATH, INCLUDE_NULL_VALUES), '[]')`);
1048
1048
  }
1049
1049
  const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
1050
+ /**
1051
+ * A `bit` column, as the driver actually delivers it.
1052
+ *
1053
+ * tedious parses TDS `BITTYPE` with `!!value`, so `sys.indexes.is_unique`
1054
+ * arrives as a JavaScript BOOLEAN, never as 1. Reading it through {@link num}
1055
+ * and comparing to 1 therefore answered `false` for EVERY unique index on SQL
1056
+ * Server, which emptied `uniqueColumns` on every introspected table and set
1057
+ * `IndexMetadata.unique` to false on every unique index. The visible cost was a
1058
+ * `findUnique` (and, once it carried the same rule, an `upsert`) on a genuinely
1059
+ * unique non-PK column being refused as not identifying one row, on the one
1060
+ * engine where the metadata could not say otherwise.
1061
+ *
1062
+ * The unit test could not see it: its mock built index rows with `1` and `0`
1063
+ * because the helper's parameter was typed `number`, so the fixture chose the
1064
+ * shape the code already handled. Only the live SQL Server leg produces the
1065
+ * boolean. Written to accept all three spellings rather than the one this
1066
+ * driver happens to send, because another `mssql` transport is free to send a
1067
+ * number or the string `'1'` and this is metadata, not a hot path.
1068
+ */
1069
+ const bitIsTrue = (v) => v === true || v === 1 || v === '1' || v === 'true';
1050
1070
  /**
1051
1071
  * Derive relations from the FK list via the SHARED introspection pipeline
1052
1072
  * (`deriveEngineRelations` → `buildRelationsFromForeignKeys` +
@@ -1182,7 +1202,7 @@ export async function introspectMssqlWith(exec, schema = 'dbo', options = {}) {
1182
1202
  const key = `${t}.${name}`;
1183
1203
  let g = indexGroups.get(key);
1184
1204
  if (!g) {
1185
- g = { table: t, name, unique: num(r.IS_UNIQUE) === 1, columns: [] };
1205
+ g = { table: t, name, unique: bitIsTrue(r.IS_UNIQUE), columns: [] };
1186
1206
  indexGroups.set(key, g);
1187
1207
  }
1188
1208
  g.columns.push(String(r.COLUMN_NAME));
@@ -12,6 +12,7 @@
12
12
  * `NestedWriteContext`.
13
13
  */
14
14
  import { CircularRelationError, describeTargetForMessage, NotFoundError, RelationError, UnsupportedFeatureError, ValidationError, } from './errors.js';
15
+ import { markInternalRowSelector } from './query/compound-unique.js';
15
16
  import { markInternalCombinator, resolveColumnName } from './query/utils.js';
16
17
  import { normalizeKeyColumns } from './schema.js';
17
18
  const MAX_DEPTH = 10;
@@ -222,6 +223,65 @@ function validateOps(relationName, ops, isUpdate) {
222
223
  }
223
224
  }
224
225
  }
226
+ /**
227
+ * The `with` clause that reads back the tree a nested write just wrote.
228
+ *
229
+ * Built from the DATA, recursively: a relation whose payload itself contains
230
+ * relation writes is requested as `{ rel: { with: { ... } } }` rather than
231
+ * `{ rel: true }`. It used to be the top-level keys alone, so a depth-3 create
232
+ * wrote all three levels and returned an object whose grandchildren were
233
+ * missing entirely, which a caller reads as "there are none".
234
+ *
235
+ * Only relations that were actually WRITTEN are requested, so a create with no
236
+ * nested data still reads back exactly what it did before. The walk is capped
237
+ * at {@link MAX_DEPTH}, the cap the write walk itself uses, and the builder's
238
+ * own relation-depth guard is the backstop below that.
239
+ */
240
+ function readBackWith(schema, tableName, relations, depth = 0) {
241
+ const tableMeta = schema.tables[tableName];
242
+ if (!tableMeta || depth >= MAX_DEPTH)
243
+ return undefined;
244
+ const clause = {};
245
+ for (const [relName, ops] of Object.entries(relations)) {
246
+ const rel = tableMeta.relations[relName];
247
+ if (!rel)
248
+ continue;
249
+ // Every payload this relation was written with: `create` / `update` /
250
+ // `upsert` take objects (or arrays of them) whose own keys may name
251
+ // relations of the TARGET table. `connect` / `disconnect` / `set` name
252
+ // existing rows and write no nested data, so they contribute nothing here.
253
+ const nested = {};
254
+ for (const key of ['create', 'update', 'upsert']) {
255
+ const payload = ops[key];
256
+ if (payload === undefined)
257
+ continue;
258
+ for (const item of toArray(payload)) {
259
+ // `{ where, data }` (nested update) and `{ where, create, update }`
260
+ // (nested upsert) carry their written fields one level in.
261
+ const bodies = isPlainRecord(item) && ('data' in item || 'create' in item || 'update' in item)
262
+ ? [item.data, item.create, item.update]
263
+ : [item];
264
+ for (const body of bodies) {
265
+ if (!isPlainRecord(body))
266
+ continue;
267
+ const child = schema.tables[rel.to];
268
+ if (!child)
269
+ continue;
270
+ for (const [k, v] of Object.entries(extractRelationFields(body, child).relations)) {
271
+ nested[k] = { ...(nested[k] ?? {}), ...v };
272
+ }
273
+ }
274
+ }
275
+ }
276
+ const deeper = Object.keys(nested).length > 0 ? readBackWith(schema, rel.to, nested, depth + 1) : undefined;
277
+ clause[relName] = deeper ? { with: deeper } : true;
278
+ }
279
+ return Object.keys(clause).length > 0 ? clause : undefined;
280
+ }
281
+ /** A plain object (not an array, Date or null), the shape nested payloads take. */
282
+ function isPlainRecord(value) {
283
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date);
284
+ }
225
285
  /**
226
286
  * Build a PK-based where clause from a parent row and its table metadata.
227
287
  */
@@ -308,7 +368,20 @@ function belongsToCorrelationWhere(ctx, rel, parentRow, parentTable) {
308
368
  * field itself, the two are combined with `AND` instead, so neither predicate
309
369
  * can silently overwrite the other.
310
370
  */
311
- function scopeWhereToParent(target, correlation) {
371
+ function scopeWhereToParent(
372
+ // `true` is the to-one "the single related row" spelling, the only non-object
373
+ // value assertTargetSelectsSomething lets through.
374
+ target, correlation) {
375
+ // `disconnect: true` / `delete: true` on a to-one relation (the only place
376
+ // `true` gets past assertTargetSelectsSomething) leaves the caller with no
377
+ // selector at all: the predicate below is the correlation and nothing else,
378
+ // written entirely by the engine. Branded so the single-row write rule reads
379
+ // the relation's declared cardinality as the uniqueness source, since a
380
+ // `hasOne` FK is frequently not ALSO declared unique in metadata and the
381
+ // rule would otherwise refuse the write with "Name a unique key" against a
382
+ // call that never named one. See markInternalRowSelector.
383
+ if (target === true)
384
+ return markInternalRowSelector({ ...correlation });
312
385
  for (const key of Object.keys(correlation)) {
313
386
  // Branded as Turbine's own: this `AND` has a FIXED arity of two, chosen
314
387
  // here rather than reachable from a request body, so it must not cost the
@@ -822,15 +895,13 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
822
895
  await processManyToMany(ctx, rel, relName, ops, parentRow);
823
896
  }
824
897
  }
825
- // Build the `with` clause for the final read to return the full tree
826
- const withClause = {};
827
- for (const relName of Object.keys(relations)) {
828
- withClause[relName] = true;
829
- }
898
+ // The `with` clause for the final read: the whole tree that was written, at
899
+ // every depth (see readBackWith), not only its top level.
900
+ const withClause = readBackWith(ctx.schema, tableName, relations);
830
901
  // Final read using existing json_agg machinery
831
902
  const fullRow = await ctx.tx.table(tableName).findUnique({
832
903
  where: pkWhere(tableMeta, parentRow),
833
- with: Object.keys(withClause).length > 0 ? withClause : undefined,
904
+ with: withClause,
834
905
  });
835
906
  return (fullRow ?? parentRow);
836
907
  }
@@ -933,14 +1004,10 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
933
1004
  await processManyToMany(ctx, rel, relName, ops, parentRow);
934
1005
  }
935
1006
  }
936
- // Final read with all touched relations
937
- const withClause = {};
938
- for (const relName of Object.keys(relations)) {
939
- withClause[relName] = true;
940
- }
1007
+ // Final read with all touched relations, to the depth they were written.
941
1008
  const fullRow = await ctx.tx.table(tableName).findUnique({
942
1009
  where: pkWhere(tableMeta, parentRow),
943
- with: Object.keys(withClause).length > 0 ? withClause : undefined,
1010
+ with: readBackWith(ctx.schema, tableName, relations),
944
1011
  });
945
1012
  return (fullRow ?? parentRow);
946
1013
  }
@@ -1330,7 +1397,11 @@ async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTabl
1330
1397
  // shared correlation helper (like every sibling operation) so a NULL parent
1331
1398
  // FK reports not-found instead of compiling to `refField IS NULL` and
1332
1399
  // updating EVERY row of the related table with a null reference key.
1333
- const where = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
1400
+ // No caller `where` exists on this shape (`update: { data }`), so the
1401
+ // predicate is entirely the engine's: branded for the single-row write rule
1402
+ // exactly as the to-one disconnect/delete correlation is.
1403
+ const correlationWhere = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
1404
+ const where = correlationWhere && markInternalRowSelector(correlationWhere);
1334
1405
  if (!where) {
1335
1406
  // Parent FK is NULL: it points at nothing, so nothing is in scope to update.
1336
1407
  const nullFk = Object.fromEntries(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).
@@ -19,6 +19,22 @@
19
19
  import Result from 'pg/lib/result';
20
20
  import { prepareValue } from 'pg/lib/utils';
21
21
  import { PipelineError, TimeoutError, wrapPgError } from './errors.js';
22
+ /**
23
+ * Marks a client this module could not hand back in a reusable state, so the
24
+ * caller releases it WITH an error and the pool drops it instead of lending it
25
+ * out again.
26
+ *
27
+ * A symbol, and `Symbol.for` so an ESM and a CJS copy of this module agree on
28
+ * it (the same reason the internal-combinator brand uses one). Set only when
29
+ * the backend still reports an open or aborted transaction after this module
30
+ * has done what it can, which after {@link runPipelined}'s rollback means the
31
+ * rollback itself did not take.
32
+ */
33
+ const PIPELINE_DISCARD = Symbol.for('turbine.pipeline.discardClient');
34
+ /** Did {@link runPipelined} leave this client unusable? See {@link PIPELINE_DISCARD}. */
35
+ export function pipelineClientNeedsDiscard(client) {
36
+ return (typeof client === 'object' && client !== null && client[PIPELINE_DISCARD] === true);
37
+ }
22
38
  // ---------------------------------------------------------------------------
23
39
  // Event names we intercept
24
40
  // ---------------------------------------------------------------------------
@@ -120,6 +136,10 @@ export async function runPipelined(client, queries, options = {}) {
120
136
  let timeoutHandle;
121
137
  // Whether cleanup has already been performed
122
138
  let cleaned = false;
139
+ /** Transaction status reported by the most recent ReadyForQuery. */
140
+ let txStatus;
141
+ /** Whether the recovery ROLLBACK has already been sent (it is sent at most once). */
142
+ let rollbackSent = false;
123
143
  /**
124
144
  * Map commandComplete index to the corresponding results[] index.
125
145
  * In transactional mode: index 0 = BEGIN, 1..N = queries, N+1 = COMMIT
@@ -158,6 +178,62 @@ export async function runPipelined(client, queries, options = {}) {
158
178
  // -----------------------------------------------------------------------
159
179
  // Finalize: called on final readyForQuery
160
180
  // -----------------------------------------------------------------------
181
+ /**
182
+ * Return the connection to the pool in a state the next borrower can use.
183
+ *
184
+ * The hazard this exists for: a transactional batch is `BEGIN` + queries +
185
+ * `COMMIT` + ONE `Sync`, so a query error makes the backend skip everything
186
+ * up to that Sync, the `COMMIT` included, and the connection goes back to
187
+ * the pool `idle in transaction (aborted)`. The next borrower's first
188
+ * statement then failed with `25P02`, a `$transaction` that landed on it
189
+ * lost its write, and a non-transactional pipeline failed every slot and
190
+ * released it still aborted, so the poisoning survived indefinitely.
191
+ *
192
+ * The recovery is one `ROLLBACK` on the same connection, sent AFTER the
193
+ * ReadyForQuery that says the backend will accept a new statement, and
194
+ * waited for: its own ReadyForQuery brings us back here with `I`. That
195
+ * keeps the connection, which matters at small pool sizes where discarding
196
+ * is a reconnect on the caller's next query.
197
+ *
198
+ * Returns true when the caller should finalize now, false when a rollback
199
+ * is in flight and the next ReadyForQuery will finish the job.
200
+ */
201
+ function settleTransactionState() {
202
+ // `I` (idle) is the ordinary case and needs nothing, and neither does a
203
+ // batch that had no error: transactional mode's COMMIT closed the
204
+ // transaction, non-transactional mode never opened one. That keeps the
205
+ // happy path at exactly one round trip, which a `ROLLBACK`-on-unknown
206
+ // would have cost an extra Sync (caught by the cork/uncork test). A real
207
+ // backend always sends the status byte; an emitter without one is only
208
+ // ever a test double.
209
+ if (txStatus === 'I')
210
+ return true;
211
+ if (txStatus === undefined && !(transactional && pipelineError))
212
+ return true;
213
+ if (!transactional || rollbackSent) {
214
+ // Either nothing to roll back (non-transactional mode opens no
215
+ // transaction of its own), or the rollback already ran and did not
216
+ // clear the status. Hand the client back marked for disposal.
217
+ if (txStatus === 'E' || txStatus === 'T') {
218
+ Object.defineProperty(client, PIPELINE_DISCARD, { value: true, enumerable: false, configurable: true });
219
+ }
220
+ return true;
221
+ }
222
+ rollbackSent = true;
223
+ try {
224
+ connection.parse({ text: 'ROLLBACK', name: '' });
225
+ connection.bind({ portal: '', statement: '', values: [], valueMapper: prepareValue });
226
+ connection.execute({ portal: '', rows: 0 });
227
+ connection.sync();
228
+ return false;
229
+ }
230
+ catch {
231
+ // The socket would not take the rollback, so this connection cannot be
232
+ // repaired from here. Mark it and let the caller drop it.
233
+ Object.defineProperty(client, PIPELINE_DISCARD, { value: true, enumerable: false, configurable: true });
234
+ return true;
235
+ }
236
+ }
161
237
  function finalize() {
162
238
  cleanup();
163
239
  if (transactional && pipelineError) {
@@ -284,11 +360,19 @@ export async function runPipelined(client, queries, options = {}) {
284
360
  function onPortalSuspended() {
285
361
  // We don't use row-limited portals
286
362
  }
287
- function onReadyForQuery() {
288
- rfqCount++;
289
- if (rfqCount >= expectedRfq) {
363
+ function onReadyForQuery(msg) {
364
+ txStatus = msg?.status;
365
+ // The recovery ROLLBACK's own ReadyForQuery is not one of the batch's, so
366
+ // it must not advance the counter that decides when the batch is done.
367
+ if (rollbackSent) {
290
368
  finalize();
369
+ return;
291
370
  }
371
+ rfqCount++;
372
+ if (rfqCount < expectedRfq)
373
+ return;
374
+ if (settleTransactionState())
375
+ finalize();
292
376
  }
293
377
  // -----------------------------------------------------------------------
294
378
  // Attach our listeners
package/dist/pipeline.js CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { postgresDialect } from './dialect.js';
22
22
  import { PipelineError, TurbineError, wrapPgError } from './errors.js';
23
- import { runPipelined, supportsExtendedPipeline } from './pipeline-submittable.js';
23
+ import { pipelineClientNeedsDiscard, runPipelined, supportsExtendedPipeline, } from './pipeline-submittable.js';
24
24
  /**
25
25
  * Execute queries sequentially on an already-acquired connection.
26
26
  * This is the fallback path for clients that don't support the extended-query
@@ -227,7 +227,19 @@ export async function executePipeline(pool, queries, options) {
227
227
  throw wrapPgError(err);
228
228
  }
229
229
  finally {
230
- client.release();
230
+ // A client the pipeline could not return to a clean state is released WITH
231
+ // an error, which is how pg-pool is told to drop it rather than lend it
232
+ // out again. `runPipelined` rolls back its own aborted transaction, so this
233
+ // is the residue: a rollback that did not take, or a backend left in a
234
+ // transaction by something other than this module. Releasing such a client
235
+ // normally is what turned one failed batch into `25P02` on somebody else's
236
+ // query.
237
+ if (pipelineClientNeedsDiscard(client)) {
238
+ client.release(new Error('turbine: pipeline connection left an open transaction and was discarded'));
239
+ }
240
+ else {
241
+ client.release();
242
+ }
231
243
  }
232
244
  }
233
245
  /**
@@ -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
@@ -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