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