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
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MAX_STDIO_BUFFER_BYTES = void 0;
6
7
  exports.startMcpServer = startMcpServer;
7
8
  exports.shortestJoinPaths = shortestJoinPaths;
8
9
  exports.buildRelations = buildRelations;
@@ -124,7 +125,7 @@ const SECRET_NAME_PATTERN = new RegExp(`(^|_)(${SECRET_WORDS.join('|')})(_|$)`,
124
125
  * meaningfully be in one. It is a liveness bound, not a policy: see the check
125
126
  * itself for why an over-long line ends the session instead of being truncated.
126
127
  */
127
- const MAX_STDIO_BUFFER_BYTES = 8 * 1024 * 1024;
128
+ exports.MAX_STDIO_BUFFER_BYTES = 8 * 1024 * 1024;
128
129
  /** True when tags could not be read, so nothing may be assumed to be non-PII. */
129
130
  function tagsUnreadable(status) {
130
131
  return status.state === 'tags-unreadable';
@@ -301,9 +302,27 @@ function startMcpServer(options, transport = {}) {
301
302
  announcePiiTags(options);
302
303
  let buffer = '';
303
304
  let disposed = false;
305
+ let settleClosed = () => { };
306
+ const closed = new Promise((resolve) => {
307
+ settleClosed = resolve;
308
+ });
304
309
  const write = (payload) => {
305
310
  output.write(`${JSON.stringify(payload)}\n`);
306
311
  };
312
+ // The ONE way a session ends, whichever side ends it. `closed` settles in a
313
+ // `finally` so a pool that fails to close still lets the process exit.
314
+ const close = async (reason) => {
315
+ if (disposed)
316
+ return;
317
+ disposed = true;
318
+ input.off('data', onData);
319
+ try {
320
+ await ctx.pool.end();
321
+ }
322
+ finally {
323
+ settleClosed(reason);
324
+ }
325
+ };
307
326
  const onData = (chunk) => {
308
327
  buffer += chunk.toString();
309
328
  // BOUND THE BUFFER. The framing is newline-delimited, so a peer that never
@@ -318,12 +337,16 @@ function startMcpServer(options, transport = {}) {
318
337
  // parse error at best and a silently different request at worst, and
319
338
  // draining to the next newline has the same problem in slower motion. There
320
339
  // is no correct way to continue a stream whose framing has been lost.
321
- if (buffer.length > MAX_STDIO_BUFFER_BYTES) {
322
- write(errorResponse(null, -32600, 'Message too large', `A single line exceeded ${MAX_STDIO_BUFFER_BYTES} bytes without a newline. ` +
340
+ if (buffer.length > exports.MAX_STDIO_BUFFER_BYTES) {
341
+ write(errorResponse(null, -32600, 'Message too large', `A single line exceeded ${exports.MAX_STDIO_BUFFER_BYTES} bytes without a newline. ` +
323
342
  'The stdio transport is newline-delimited; the session is being closed because ' +
324
343
  'the framing cannot be recovered.'));
325
344
  buffer = '';
326
- input.off('data', onData);
345
+ // END THE SESSION, not just the reader. Detaching alone left the process
346
+ // alive with nothing listening: it could neither answer nor exit.
347
+ close('framing-lost').catch((err) => {
348
+ process.stderr.write(`[turbine] mcp close error: ${(0, ui_js_1.redactUrl)(errorMessage(err))}\n`);
349
+ });
327
350
  return;
328
351
  }
329
352
  let newlineIndex = buffer.indexOf('\n');
@@ -340,13 +363,8 @@ function startMcpServer(options, transport = {}) {
340
363
  };
341
364
  input.on('data', onData);
342
365
  return {
343
- dispose: async () => {
344
- if (disposed)
345
- return;
346
- disposed = true;
347
- input.off('data', onData);
348
- await ctx.pool.end();
349
- },
366
+ dispose: () => close('disposed'),
367
+ closed,
350
368
  };
351
369
  }
352
370
  /**
@@ -647,32 +665,39 @@ function keyList(definition) {
647
665
  async function migrationStatus(ctx) {
648
666
  return withReadOnly(ctx, async (client) => {
649
667
  const files = (0, migrate_js_1.listMigrationFiles)(ctx.options.migrationsDir);
650
- // DELIBERATELY UNPINNED, unlike every other tool here.
651
- //
652
668
  // This tool's whole job is to report what `turbine migrate status` would
653
- // report, and the runner in cli/migrate.ts names `_turbine_migrations`
654
- // unqualified with no search_path of its own, so the tracking table lives
655
- // wherever the connecting role's search_path put it, which is frequently
656
- // `public` even for a project whose data schema is something else. Pinning
657
- // `search_path` to `--schema` here does not harden that, it ANSWERS A
669
+ // report, so the tracking table is resolved by the RUNNER'S rule
670
+ // (`connectionStringForSchema` in cli/migrate.ts), not by this server's
671
+ // own `search_path` pin:
672
+ //
673
+ // - a configured schema other than `public` pins the runner's connection
674
+ // to it, so its tracking table is `"<schema>"._turbine_migrations` and
675
+ // is looked up qualified here;
676
+ // - the default `public` leaves the runner's connection unpinned, so its
677
+ // tracking table lives wherever the connecting role's search_path put
678
+ // it (often `public`, not always), and the lookup here is left
679
+ // unqualified for the same reason.
680
+ //
681
+ // Pinning `search_path` to `--schema` in the second case would ANSWER A
658
682
  // DIFFERENT QUESTION: `turbine migrate status` would say "applied" while
659
683
  // `migrate_status` said the tracking table did not exist. Between agreeing
660
684
  // with the migration runner and imposing a rule the runner does not follow,
661
- // agreeing is the only one that can be right.
662
- //
663
- // The resolution is DISCLOSED instead: the reply names the schema the
664
- // tracking table actually resolved in, plus a note when that is not the
665
- // configured schema, so the divergence is visible rather than silently
666
- // decided either way. (`explain_query` and `sample_rows` still pin, because
667
- // they read the schema's own tables, not the runner's bookkeeping.)
685
+ // agreeing is the only one that can be right. The resolution is DISCLOSED
686
+ // either way: the reply names the schema the table actually resolved in,
687
+ // plus a note when that is not the configured schema. (`explain_query` and
688
+ // `sample_rows` still pin, because they read the schema's own tables, not
689
+ // the runner's bookkeeping.)
690
+ const trackingRef = ctx.options.schema === 'public'
691
+ ? (0, index_js_1.quoteIdent)(TRACKING_TABLE)
692
+ : `${(0, index_js_1.quoteIdent)(ctx.options.schema)}.${(0, index_js_1.quoteIdent)(TRACKING_TABLE)}`;
668
693
  const trackingExists = await client.query(`SELECT reg.oid IS NOT NULL AS exists, n.nspname AS table_schema
669
694
  FROM (SELECT to_regclass($1) AS oid) reg
670
695
  LEFT JOIN pg_class c ON c.oid = reg.oid
671
- LEFT JOIN pg_namespace n ON n.oid = c.relnamespace`, [TRACKING_TABLE]);
696
+ LEFT JOIN pg_namespace n ON n.oid = c.relnamespace`, [trackingRef]);
672
697
  const trackingSchema = trackingExists.rows[0]?.table_schema ?? null;
673
698
  const applied = new Map();
674
699
  if (trackingExists.rows[0]?.exists) {
675
- const result = await client.query(`SELECT name, applied_at, checksum FROM ${(0, index_js_1.quoteIdent)(TRACKING_TABLE)} ORDER BY name`);
700
+ const result = await client.query(`SELECT name, applied_at, checksum FROM ${trackingRef} ORDER BY name`);
676
701
  for (const row of result.rows) {
677
702
  applied.set(row.name, { appliedAt: row.applied_at, checksum: row.checksum });
678
703
  }
@@ -693,8 +718,8 @@ async function migrationStatus(ctx) {
693
718
  trackingTableSchema: trackingSchema,
694
719
  trackingTableNote: trackingSchema !== null && trackingSchema !== ctx.options.schema
695
720
  ? `Migrations are tracked in "${trackingSchema}", not the configured schema "${ctx.options.schema}". ` +
696
- `This is what \`turbine migrate status\` reads too: the runner resolves the tracking table through ` +
697
- `the connection's search_path rather than the --schema flag.`
721
+ `This is what \`turbine migrate status\` reads too: with the default schema the runner resolves the ` +
722
+ `tracking table through the connection's own search_path rather than pinning it.`
698
723
  : undefined,
699
724
  applied: statuses.filter((status) => status.applied).length,
700
725
  pending: statuses.filter((status) => !status.applied).length,
@@ -1629,120 +1654,126 @@ async function withReadOnly(ctx, fn) {
1629
1654
  }
1630
1655
  }
1631
1656
  async function loadSchemaMetadata(client, options) {
1632
- const [tablesResult, columnsResult, pkResult, fkResult, uniqueResult, indexResult, enumResult] = await Promise.all([
1633
- client.query(`SELECT table_name
1634
- FROM information_schema.tables
1635
- WHERE table_schema = $1 AND table_type = 'BASE TABLE'
1636
- ORDER BY table_name`, [options.schema]),
1637
- client.query(`SELECT table_name, column_name, udt_name, data_type, is_nullable, column_default, is_identity,
1638
- character_maximum_length
1639
- FROM information_schema.columns
1640
- WHERE table_schema = $1
1641
- ORDER BY table_name, ordinal_position`, [options.schema]),
1642
- client.query(
1643
- // Joined on the table as well as the constraint name. Not because two
1644
- // primary keys can share a name (they cannot: a PK is backed by an INDEX,
1645
- // and index names ARE unique per schema, so Postgres itself refuses the
1646
- // second `CREATE TABLE ... CONSTRAINT pk_shared PRIMARY KEY` with
1647
- // `relation "pk_shared" already exists`, verified on PG 16) but because
1648
- // the join reads as if the name were the identity, which is what put the
1649
- // FOREIGN KEY query below one refactor away from a silent cross product.
1650
- // A foreign key has no backing index and so genuinely can collide.
1651
- `SELECT tc.table_name, kcu.column_name
1652
- FROM information_schema.table_constraints tc
1653
- JOIN information_schema.key_column_usage kcu
1654
- ON tc.constraint_name = kcu.constraint_name
1655
- AND tc.table_schema = kcu.table_schema
1656
- AND tc.table_name = kcu.table_name
1657
- WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
1658
- ORDER BY tc.table_name, kcu.ordinal_position`, [options.schema]),
1659
- // Foreign keys come from pg_catalog, not information_schema, and the reason
1660
- // is the same one written up over `SQL_FOREIGN_KEYS` in ../introspect.ts
1661
- // (KEEP THE TWO IN LOCKSTEP: this is a second copy of that query because
1662
- // introspect.ts does not export it, and mcp reads through its own pooled
1663
- // client inside a read-only transaction rather than opening the pool
1664
- // `introspect()` owns). The information_schema formulation this replaces
1665
- // joined key_column_usage (constrained columns) to constraint_column_usage
1666
- // (referenced columns) on the constraint NAME, which is wrong twice:
1667
- //
1668
- // 1. Those two column lists have no positional link, so the join is an
1669
- // N-by-N cross product: a two-column FK came back as four rows and
1670
- // grouped into four AND-ed correlations, two of them pairing the wrong
1671
- // columns. Every read through the relation silently returned nothing.
1672
- // 2. A constraint name is unique per TABLE (conrelid, conname), not per
1673
- // schema, so two tables may both have a `shared_fk`. This is specific
1674
- // to foreign keys: a PRIMARY KEY or UNIQUE constraint is backed by an
1675
- // index and index names ARE schema-unique, so Postgres refuses that
1676
- // collision outright, while an FK has no backing index and the
1677
- // collision is legal. On the name alone the two cross: measured on PG
1678
- // 16, two tables with a `shared_fk` produced EIGHT rows instead of two,
1679
- // which grouped by name into one entry, so one table lost its relation
1680
- // entirely and the other pointed at a column its target does not have
1681
- // (42703 at query time). Which one won depended on catalog row order.
1682
- //
1683
- // conkey and confkey are parallel arrays, so unnesting BOTH `WITH
1684
- // ORDINALITY` and joining on the ordinal IS the pairing, exactly; the OID is
1685
- // the grouping key because it is unique catalog-wide.
1686
- //
1687
- // The target is constrained to the SAME schema, which is what the old query
1688
- // did implicitly (it joined on ccu.table_schema). Keeping it explicit
1689
- // matters: `buildRelations` resolves targets by bare name against the
1690
- // introspected table set, so a cross-schema reference to a same-named table
1691
- // would silently bind to the local one.
1692
- //
1693
- // `conparentid = 0` (declared constraints only) and the by-NAME ordering are
1694
- // both part of the lockstep: one FK against a partitioned table otherwise
1695
- // yields an extra phantom relation per partition, and ordering by `con.oid`
1696
- // makes relation naming a function of DDL execution order rather than of the
1697
- // schema. Both are written up at length over SQL_FOREIGN_KEYS.
1698
- client.query(`SELECT
1699
- con.oid::text AS constraint_oid,
1700
- con.conname AS constraint_name,
1701
- src.relname AS source_table,
1702
- src_att.attname AS source_column,
1703
- tgt.relname AS target_table,
1704
- tgt_att.attname AS target_column
1705
- FROM pg_catalog.pg_constraint con
1706
- JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
1707
- JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
1708
- JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
1709
- JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
1710
- JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS sk(attnum, ord) ON TRUE
1711
- JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS tk(attnum, ord) ON tk.ord = sk.ord
1712
- JOIN pg_catalog.pg_attribute src_att
1713
- ON src_att.attrelid = con.conrelid AND src_att.attnum = sk.attnum
1714
- JOIN pg_catalog.pg_attribute tgt_att
1715
- ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = tk.attnum
1716
- WHERE con.contype = 'f'
1717
- AND con.conparentid = 0
1718
- AND src_ns.nspname = $1
1719
- AND tgt_ns.nspname = src_ns.nspname
1720
- ORDER BY src.relname, con.conname, sk.ord`, [options.schema]),
1721
- client.query(
1722
- // Joined on the table too, same reasoning as the primary-key query above:
1723
- // a UNIQUE constraint is index-backed and therefore cannot collide, and
1724
- // the join says so.
1725
- `SELECT tc.table_name, tc.constraint_name, kcu.column_name
1726
- FROM information_schema.table_constraints tc
1727
- JOIN information_schema.key_column_usage kcu
1728
- ON tc.constraint_name = kcu.constraint_name
1729
- AND tc.table_schema = kcu.table_schema
1730
- AND tc.table_name = kcu.table_name
1731
- WHERE tc.constraint_type = 'UNIQUE' AND tc.table_schema = $1
1732
- ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`, [options.schema]),
1733
- client.query(
1734
- // Ordered for the same reason SQL_INDEXES is: these rows feed the
1735
- // unique-set detection that decides hasOne-versus-hasMany, and they are
1736
- // reported verbatim by the schema tools, so physical catalog order must
1737
- // not leak into either answer. Index names are unique per schema.
1738
- `SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`, [options.schema]),
1739
- client.query(`SELECT t.typname, e.enumlabel
1740
- FROM pg_type t
1741
- JOIN pg_enum e ON t.oid = e.enumtypid
1742
- JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
1743
- WHERE n.nspname = $1
1744
- ORDER BY t.typname, e.enumsortorder`, [options.schema]),
1745
- ]);
1657
+ // SEQUENTIAL, deliberately, on the one client `withReadOnly` checked out. These
1658
+ // used to run in a single `Promise.all`, which node-postgres tolerates by
1659
+ // queueing the calls behind one another with a `DeprecationWarning` on every
1660
+ // session (`Calling client.query() when the client is already executing a
1661
+ // query ... will be removed in pg@9.0`) and which pg 9 refuses outright, so
1662
+ // every schema-reading tool would throw. The queueing meant they were never
1663
+ // concurrent on the wire anyway; awaiting them in turn costs nothing and puts
1664
+ // the ordering in this file rather than in a deprecated driver behaviour.
1665
+ const tablesResult = await client.query(`SELECT table_name
1666
+ FROM information_schema.tables
1667
+ WHERE table_schema = $1 AND table_type = 'BASE TABLE'
1668
+ ORDER BY table_name`, [options.schema]);
1669
+ const columnsResult = await client.query(`SELECT table_name, column_name, udt_name, data_type, is_nullable, column_default, is_identity,
1670
+ character_maximum_length
1671
+ FROM information_schema.columns
1672
+ WHERE table_schema = $1
1673
+ ORDER BY table_name, ordinal_position`, [options.schema]);
1674
+ const pkResult = await client.query(
1675
+ // Joined on the table as well as the constraint name. Not because two
1676
+ // primary keys can share a name (they cannot: a PK is backed by an INDEX,
1677
+ // and index names ARE unique per schema, so Postgres itself refuses the
1678
+ // second `CREATE TABLE ... CONSTRAINT pk_shared PRIMARY KEY` with
1679
+ // `relation "pk_shared" already exists`, verified on PG 16) but because
1680
+ // the join reads as if the name were the identity, which is what put the
1681
+ // FOREIGN KEY query below one refactor away from a silent cross product.
1682
+ // A foreign key has no backing index and so genuinely can collide.
1683
+ `SELECT tc.table_name, kcu.column_name
1684
+ FROM information_schema.table_constraints tc
1685
+ JOIN information_schema.key_column_usage kcu
1686
+ ON tc.constraint_name = kcu.constraint_name
1687
+ AND tc.table_schema = kcu.table_schema
1688
+ AND tc.table_name = kcu.table_name
1689
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
1690
+ ORDER BY tc.table_name, kcu.ordinal_position`, [options.schema]);
1691
+ // Foreign keys come from pg_catalog, not information_schema, and the reason
1692
+ // is the same one written up over `SQL_FOREIGN_KEYS` in ../introspect.ts
1693
+ // (KEEP THE TWO IN LOCKSTEP: this is a second copy of that query because
1694
+ // introspect.ts does not export it, and mcp reads through its own pooled
1695
+ // client inside a read-only transaction rather than opening the pool
1696
+ // `introspect()` owns). The information_schema formulation this replaces
1697
+ // joined key_column_usage (constrained columns) to constraint_column_usage
1698
+ // (referenced columns) on the constraint NAME, which is wrong twice:
1699
+ //
1700
+ // 1. Those two column lists have no positional link, so the join is an
1701
+ // N-by-N cross product: a two-column FK came back as four rows and
1702
+ // grouped into four AND-ed correlations, two of them pairing the wrong
1703
+ // columns. Every read through the relation silently returned nothing.
1704
+ // 2. A constraint name is unique per TABLE (conrelid, conname), not per
1705
+ // schema, so two tables may both have a `shared_fk`. This is specific
1706
+ // to foreign keys: a PRIMARY KEY or UNIQUE constraint is backed by an
1707
+ // index and index names ARE schema-unique, so Postgres refuses that
1708
+ // collision outright, while an FK has no backing index and the
1709
+ // collision is legal. On the name alone the two cross: measured on PG
1710
+ // 16, two tables with a `shared_fk` produced EIGHT rows instead of two,
1711
+ // which grouped by name into one entry, so one table lost its relation
1712
+ // entirely and the other pointed at a column its target does not have
1713
+ // (42703 at query time). Which one won depended on catalog row order.
1714
+ //
1715
+ // conkey and confkey are parallel arrays, so unnesting BOTH `WITH
1716
+ // ORDINALITY` and joining on the ordinal IS the pairing, exactly; the OID is
1717
+ // the grouping key because it is unique catalog-wide.
1718
+ //
1719
+ // The target is constrained to the SAME schema, which is what the old query
1720
+ // did implicitly (it joined on ccu.table_schema). Keeping it explicit
1721
+ // matters: `buildRelations` resolves targets by bare name against the
1722
+ // introspected table set, so a cross-schema reference to a same-named table
1723
+ // would silently bind to the local one.
1724
+ //
1725
+ // `conparentid = 0` (declared constraints only) and the by-NAME ordering are
1726
+ // both part of the lockstep: one FK against a partitioned table otherwise
1727
+ // yields an extra phantom relation per partition, and ordering by `con.oid`
1728
+ // makes relation naming a function of DDL execution order rather than of the
1729
+ // schema. Both are written up at length over SQL_FOREIGN_KEYS.
1730
+ const fkResult = await client.query(`SELECT
1731
+ con.oid::text AS constraint_oid,
1732
+ con.conname AS constraint_name,
1733
+ src.relname AS source_table,
1734
+ src_att.attname AS source_column,
1735
+ tgt.relname AS target_table,
1736
+ tgt_att.attname AS target_column
1737
+ FROM pg_catalog.pg_constraint con
1738
+ JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
1739
+ JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
1740
+ JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
1741
+ JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
1742
+ JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS sk(attnum, ord) ON TRUE
1743
+ JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS tk(attnum, ord) ON tk.ord = sk.ord
1744
+ JOIN pg_catalog.pg_attribute src_att
1745
+ ON src_att.attrelid = con.conrelid AND src_att.attnum = sk.attnum
1746
+ JOIN pg_catalog.pg_attribute tgt_att
1747
+ ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = tk.attnum
1748
+ WHERE con.contype = 'f'
1749
+ AND con.conparentid = 0
1750
+ AND src_ns.nspname = $1
1751
+ AND tgt_ns.nspname = src_ns.nspname
1752
+ ORDER BY src.relname, con.conname, sk.ord`, [options.schema]);
1753
+ const uniqueResult = await client.query(
1754
+ // Joined on the table too, same reasoning as the primary-key query above:
1755
+ // a UNIQUE constraint is index-backed and therefore cannot collide, and
1756
+ // the join says so.
1757
+ `SELECT tc.table_name, tc.constraint_name, kcu.column_name
1758
+ FROM information_schema.table_constraints tc
1759
+ JOIN information_schema.key_column_usage kcu
1760
+ ON tc.constraint_name = kcu.constraint_name
1761
+ AND tc.table_schema = kcu.table_schema
1762
+ AND tc.table_name = kcu.table_name
1763
+ WHERE tc.constraint_type = 'UNIQUE' AND tc.table_schema = $1
1764
+ ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`, [options.schema]);
1765
+ const indexResult = await client.query(
1766
+ // Ordered for the same reason SQL_INDEXES is: these rows feed the
1767
+ // unique-set detection that decides hasOne-versus-hasMany, and they are
1768
+ // reported verbatim by the schema tools, so physical catalog order must
1769
+ // not leak into either answer. Index names are unique per schema.
1770
+ `SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`, [options.schema]);
1771
+ const enumResult = await client.query(`SELECT t.typname, e.enumlabel
1772
+ FROM pg_type t
1773
+ JOIN pg_enum e ON t.oid = e.enumtypid
1774
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
1775
+ WHERE n.nspname = $1
1776
+ ORDER BY t.typname, e.enumsortorder`, [options.schema]);
1746
1777
  let tableNames = tablesResult.rows.map((row) => row.table_name);
1747
1778
  if (options.include?.length) {
1748
1779
  const include = new Set(options.include);
@@ -2093,13 +2124,20 @@ function errorResponse(id, code, message, data) {
2093
2124
  }
2094
2125
  async function runMcpServer(options) {
2095
2126
  const handle = startMcpServer(options);
2096
- await new Promise((resolve) => {
2097
- const shutdown = async () => {
2098
- await handle.dispose();
2099
- resolve();
2100
- };
2101
- process.once('SIGINT', shutdown);
2102
- process.once('SIGTERM', shutdown);
2103
- process.stdin.once('end', shutdown);
2104
- });
2127
+ const shutdown = () => {
2128
+ handle.dispose().catch((err) => {
2129
+ process.stderr.write(`[turbine] mcp shutdown error: ${(0, ui_js_1.redactUrl)(errorMessage(err))}\n`);
2130
+ });
2131
+ };
2132
+ process.once('SIGINT', shutdown);
2133
+ process.once('SIGTERM', shutdown);
2134
+ process.stdin.once('end', shutdown);
2135
+ const reason = await handle.closed;
2136
+ if (reason === 'framing-lost') {
2137
+ // The reader is detached but a paused stdin still holds the event loop
2138
+ // open, so release it, and exit non-zero: this end was the peer's doing and
2139
+ // a supervisor should restart the server rather than record a clean stop.
2140
+ process.exitCode = 1;
2141
+ process.stdin.destroy();
2142
+ }
2105
2143
  }