@intentius/chant-lexicon-sql 0.124.48 → 0.124.49

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 (138) hide show
  1. package/dist/clickhouse/plan/statements.d.ts +29 -0
  2. package/dist/clickhouse/plan/statements.d.ts.map +1 -0
  3. package/dist/clickhouse/plan/statements.js +334 -0
  4. package/dist/codegen/classifier-page.d.ts.map +1 -1
  5. package/dist/codegen/classifier-page.js +23 -0
  6. package/dist/codegen/postgres-classifier-page.js +2 -2
  7. package/dist/codegen/registry.d.ts.map +1 -1
  8. package/dist/codegen/registry.js +5 -0
  9. package/dist/core/statement-class.d.ts +55 -0
  10. package/dist/core/statement-class.d.ts.map +1 -0
  11. package/dist/core/statement-class.js +43 -0
  12. package/dist/coverage/postgres.json +6 -6
  13. package/dist/drizzle.d.ts.map +1 -1
  14. package/dist/drizzle.js +5 -0
  15. package/dist/files/postgres.d.ts.map +1 -1
  16. package/dist/files/postgres.js +23 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/integrity.json +5 -5
  21. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  22. package/dist/lint/post-synth/postgres-names.js +3 -0
  23. package/dist/lint/rules/postgres-templates.js +2 -2
  24. package/dist/lsp/postgres-template.d.ts.map +1 -1
  25. package/dist/lsp/postgres-template.js +10 -0
  26. package/dist/manifest.json +1 -1
  27. package/dist/mcp/postgres.js +1 -1
  28. package/dist/meta.json +25 -0
  29. package/dist/migration-classify.d.ts +23 -0
  30. package/dist/migration-classify.d.ts.map +1 -0
  31. package/dist/migration-classify.js +44 -0
  32. package/dist/okf/index.md +5 -0
  33. package/dist/okf/types/PostgresForeignTable.md +9 -0
  34. package/dist/okf/types/PostgresOperatorClass.md +9 -0
  35. package/dist/okf/types/PostgresOperatorFamily.md +9 -0
  36. package/dist/okf/types/PostgresTextSearchConfiguration.md +9 -0
  37. package/dist/okf/types/PostgresTextSearchDictionary.md +9 -0
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/plugin.js +13 -0
  40. package/dist/postgres/access/acl.js +1 -1
  41. package/dist/postgres/access/import.js +1 -1
  42. package/dist/postgres/apply/apply.js +5 -0
  43. package/dist/postgres/apply/statements.d.ts.map +1 -1
  44. package/dist/postgres/apply/statements.js +156 -2
  45. package/dist/postgres/entities.d.ts +120 -2
  46. package/dist/postgres/entities.d.ts.map +1 -1
  47. package/dist/postgres/entities.js +180 -5
  48. package/dist/postgres/entity-types.d.ts +5 -0
  49. package/dist/postgres/entity-types.d.ts.map +1 -1
  50. package/dist/postgres/entity-types.js +5 -0
  51. package/dist/postgres/import/generator.d.ts.map +1 -1
  52. package/dist/postgres/import/generator.js +16 -1
  53. package/dist/postgres/live/catalog-kinds.d.ts +6 -0
  54. package/dist/postgres/live/catalog-kinds.d.ts.map +1 -1
  55. package/dist/postgres/live/catalog-kinds.js +246 -0
  56. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  57. package/dist/postgres/live/describe-resources.js +2 -0
  58. package/dist/postgres/migrate/observe.d.ts.map +1 -1
  59. package/dist/postgres/migrate/observe.js +4 -1
  60. package/dist/postgres/migrate/replication.d.ts +42 -0
  61. package/dist/postgres/migrate/replication.d.ts.map +1 -1
  62. package/dist/postgres/migrate/replication.js +53 -0
  63. package/dist/postgres/parser.d.ts +73 -1
  64. package/dist/postgres/parser.d.ts.map +1 -1
  65. package/dist/postgres/parser.js +181 -7
  66. package/dist/postgres/plan/deep.d.ts.map +1 -1
  67. package/dist/postgres/plan/deep.js +15 -4
  68. package/dist/postgres/plan/diff.d.ts.map +1 -1
  69. package/dist/postgres/plan/diff.js +120 -5
  70. package/dist/postgres/plan/normalize.d.ts +12 -1
  71. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  72. package/dist/postgres/plan/normalize.js +151 -0
  73. package/dist/postgres/plan/rules.d.ts +3 -0
  74. package/dist/postgres/plan/rules.d.ts.map +1 -1
  75. package/dist/postgres/plan/rules.js +5 -2
  76. package/dist/postgres/plan/schema.js +1 -1
  77. package/dist/postgres/plan/statements.d.ts +26 -0
  78. package/dist/postgres/plan/statements.d.ts.map +1 -0
  79. package/dist/postgres/plan/statements.js +364 -0
  80. package/dist/postgres/testing/roundtrip-schema.d.ts +3 -2
  81. package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
  82. package/dist/postgres/testing/roundtrip-schema.js +12 -1
  83. package/dist/postgres.d.ts +1 -1
  84. package/dist/postgres.d.ts.map +1 -1
  85. package/dist/postgres.js +1 -1
  86. package/dist/rules/postgres-names.ts +2 -0
  87. package/dist/rules/postgres-templates.ts +2 -2
  88. package/dist/validate.d.ts.map +1 -1
  89. package/dist/validate.js +5 -0
  90. package/package.json +2 -2
  91. package/src/clickhouse/plan/statements.test.ts +61 -0
  92. package/src/clickhouse/plan/statements.ts +306 -0
  93. package/src/codegen/classifier-page.ts +23 -0
  94. package/src/codegen/postgres-classifier-page.ts +2 -2
  95. package/src/codegen/registry.ts +5 -0
  96. package/src/core/statement-class.ts +88 -0
  97. package/src/coverage/postgres.json +6 -6
  98. package/src/drizzle.ts +5 -0
  99. package/src/files/postgres.ts +22 -1
  100. package/src/generated/lexicon-sql.json +25 -0
  101. package/src/index.ts +1 -1
  102. package/src/lint/post-synth/postgres-checks.test.ts +8 -1
  103. package/src/lint/post-synth/postgres-names.ts +2 -0
  104. package/src/lint/rules/postgres-templates.ts +2 -2
  105. package/src/lsp/postgres-template.ts +9 -0
  106. package/src/mcp/postgres.ts +1 -1
  107. package/src/migration-classify.test.ts +150 -0
  108. package/src/migration-classify.ts +45 -0
  109. package/src/op/watch-pipeline.test.ts +8 -1
  110. package/src/plugin.ts +14 -0
  111. package/src/postgres/access/acl.ts +1 -1
  112. package/src/postgres/access/import.ts +1 -1
  113. package/src/postgres/apply/apply.ts +5 -0
  114. package/src/postgres/apply/statements.ts +151 -3
  115. package/src/postgres/database-kinds.test.ts +498 -17
  116. package/src/postgres/entities.ts +276 -5
  117. package/src/postgres/entity-types.ts +5 -0
  118. package/src/postgres/import/generator.ts +13 -1
  119. package/src/postgres/live/catalog-kinds.ts +271 -0
  120. package/src/postgres/live/describe-resources.ts +2 -0
  121. package/src/postgres/migrate/observe.ts +3 -1
  122. package/src/postgres/migrate/replication.ts +66 -0
  123. package/src/postgres/migrate/subscriber.e2e.test.ts +141 -0
  124. package/src/postgres/migrate/subscriber.test.ts +59 -0
  125. package/src/postgres/parser.ts +225 -6
  126. package/src/postgres/plan/deep.ts +15 -4
  127. package/src/postgres/plan/diff.ts +107 -6
  128. package/src/postgres/plan/normalize.ts +155 -0
  129. package/src/postgres/plan/rules.ts +25 -4
  130. package/src/postgres/plan/schema.ts +1 -1
  131. package/src/postgres/plan/statements.test.ts +78 -0
  132. package/src/postgres/plan/statements.ts +299 -0
  133. package/src/postgres/testing/roundtrip-schema.ts +12 -1
  134. package/src/postgres.ts +17 -0
  135. package/src/skills/chant-sql-postgres-migration.md +1 -0
  136. package/src/skills/chant-sql-postgres.md +5 -0
  137. package/src/sql-files.test.ts +33 -0
  138. package/src/validate.ts +5 -0
@@ -459,6 +459,7 @@ export async function readDatabaseKinds(client: PostgresClient, scope: SchemaSco
459
459
  }
460
460
 
461
461
  out.push(...(await readPublications(client)));
462
+ out.push(...(await readOperatorClasses(client, scope)), ...(await readTextSearch(client, scope)), ...(await readForeignTables(client, scope)));
462
463
  return out;
463
464
  }
464
465
 
@@ -533,3 +534,273 @@ async function readPublications(client: PostgresClient): Promise<LivePgObject[]>
533
534
  }
534
535
  return out;
535
536
  }
537
+
538
+ // ── Operator classes and families, text search, foreign tables (#4406) ──
539
+
540
+ /** An object named by `schema`.`name` columns, as a clause: a `pg_catalog` one bare. */
541
+ const refName = (schema: string, name: string): string =>
542
+ `CASE WHEN ${schema} = 'pg_catalog' THEN pg_catalog.quote_ident(${name}) ELSE pg_catalog.quote_ident(${schema}) || '.' || pg_catalog.quote_ident(${name}) END`;
543
+
544
+ /** An operand type, `NONE` for the missing side of a prefix operator. */
545
+ const operand = (column: string): string => `CASE WHEN ${column} = 0 THEN 'NONE' ELSE pg_catalog.format_type(${column}, NULL) END`;
546
+
547
+ /**
548
+ * Operator classes and families. A class carries its members: the operators
549
+ * and support functions that depend on it (`pg_depend`, a member made by
550
+ * CREATE OPERATOR CLASS depends on the class; one added by ALTER OPERATOR
551
+ * FAMILY ... ADD depends on the family and is "loose"). A family named as its
552
+ * class, in the same schema, is the one CREATE OPERATOR CLASS made for it and
553
+ * is the class's; every other family is read, with a warning when it holds
554
+ * loose members, which a declaration cannot carry.
555
+ */
556
+ async function readOperatorClasses(client: PostgresClient, scope: SchemaScope): Promise<LivePgObject[]> {
557
+ const out: LivePgObject[] = [];
558
+ const s = scopeSql("n.nspname", scope);
559
+ const member = (catalog: string, alias: string) =>
560
+ `JOIN pg_catalog.pg_depend dep ON dep.classid = '${catalog}'::pg_catalog.regclass AND dep.objid = ${alias}.oid AND dep.refclassid = 'pg_catalog.pg_opclass'::pg_catalog.regclass AND dep.refobjid = c.oid`;
561
+ const classes = await client.query<Row>(
562
+ `SELECT c.oid::text AS oid, n.nspname AS schema, c.opcname AS name, am.amname AS method, c.opcdefault AS isdefault,
563
+ pg_catalog.format_type(c.opcintype, NULL) AS type,
564
+ CASE WHEN c.opckeytype <> 0 THEN pg_catalog.format_type(c.opckeytype, NULL) END AS storage,
565
+ CASE WHEN f.opfname = c.opcname AND f.opfnamespace = c.opcnamespace THEN NULL ELSE ${refName("fn.nspname", "f.opfname")} END AS family,
566
+ ARRAY(SELECT 'OPERATOR ' || o.amopstrategy || ' ' || pg_catalog.quote_ident(opn.nspname) || '.' || op.oprname ||
567
+ ' (' || ${operand("o.amoplefttype")} || ', ' || ${operand("o.amoprighttype")} || ')' ||
568
+ CASE WHEN o.amoppurpose = 'o' THEN ' FOR ORDER BY ' || (SELECT pg_catalog.quote_ident(sn.nspname) || '.' || pg_catalog.quote_ident(sf.opfname)
569
+ FROM pg_catalog.pg_opfamily sf JOIN pg_catalog.pg_namespace sn ON sn.oid = sf.opfnamespace WHERE sf.oid = o.amopsortfamily) ELSE '' END
570
+ FROM pg_catalog.pg_amop o ${member("pg_catalog.pg_amop", "o")}
571
+ JOIN pg_catalog.pg_operator op ON op.oid = o.amopopr JOIN pg_catalog.pg_namespace opn ON opn.oid = op.oprnamespace
572
+ ORDER BY o.amopstrategy, o.amoplefttype, o.amoprighttype) AS operators,
573
+ ARRAY(SELECT 'FUNCTION ' || p.amprocnum || ' (' || ${operand("p.amproclefttype")} || ', ' || ${operand("p.amprocrighttype")} || ') ' || p.amproc::pg_catalog.regprocedure::pg_catalog.text
574
+ FROM pg_catalog.pg_amproc p ${member("pg_catalog.pg_amproc", "p")}
575
+ ORDER BY p.amprocnum, p.amproclefttype, p.amprocrighttype) AS functions,
576
+ pg_catalog.obj_description(c.oid, 'pg_opclass') AS comment, ${dependentsOf("pg_opclass", "c.oid")} AS dependents
577
+ FROM pg_catalog.pg_opclass c JOIN pg_catalog.pg_namespace n ON n.oid = c.opcnamespace JOIN pg_catalog.pg_am am ON am.oid = c.opcmethod
578
+ JOIN pg_catalog.pg_opfamily f ON f.oid = c.opcfamily JOIN pg_catalog.pg_namespace fn ON fn.oid = f.opfnamespace
579
+ WHERE ${s.sql} AND c.oid >= 16384 AND ${notExtensionOwned("pg_opclass", "c.oid")}
580
+ ORDER BY n.nspname ${C}, c.opcname ${C}, am.amname ${C}`,
581
+ s.params,
582
+ );
583
+ for (const r of classes) {
584
+ const q = qname(String(r.schema), String(r.name));
585
+ const method = String(r.method);
586
+ const items = [...((r.operators as string[] | null) ?? []), ...((r.functions as string[] | null) ?? []), ...(str(r.storage) ? [`STORAGE ${r.storage}`] : [])];
587
+ out.push({
588
+ type: POSTGRES_ENTITY_TYPES.operatorClass,
589
+ schema: String(r.schema),
590
+ name: String(r.name),
591
+ oid: String(r.oid),
592
+ signature: ` USING ${method}`,
593
+ ...withComment(r),
594
+ ...dependents(r),
595
+ ...(items.length === 0 ? { unsupported: "no operator, support function or storage type of it could be read; a CREATE OPERATOR CLASS needs at least one" } : {}),
596
+ statement: [
597
+ `CREATE OPERATOR CLASS ${q}${r.isdefault === true ? " DEFAULT" : ""} FOR TYPE ${r.type} USING ${method}${str(r.family) ? ` FAMILY ${r.family}` : ""} AS\n ${items.join(",\n ")}`,
598
+ ...commentOn(`OPERATOR CLASS ${q} USING ${method}`, r.comment),
599
+ ].join(";\n"),
600
+ });
601
+ }
602
+
603
+ const families = await client.query<Row>(
604
+ `SELECT f.oid::text AS oid, n.nspname AS schema, f.opfname AS name, am.amname AS method,
605
+ (SELECT count(*) FROM (SELECT o.oid FROM pg_catalog.pg_amop o WHERE o.amopfamily = f.oid UNION ALL SELECT p.oid FROM pg_catalog.pg_amproc p WHERE p.amprocfamily = f.oid) m
606
+ WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend dep WHERE dep.classid IN ('pg_catalog.pg_amop'::pg_catalog.regclass, 'pg_catalog.pg_amproc'::pg_catalog.regclass)
607
+ AND dep.objid = m.oid AND dep.refclassid = 'pg_catalog.pg_opclass'::pg_catalog.regclass)) AS loose,
608
+ pg_catalog.obj_description(f.oid, 'pg_opfamily') AS comment, ${dependentsOf("pg_opfamily", "f.oid")} AS dependents
609
+ FROM pg_catalog.pg_opfamily f JOIN pg_catalog.pg_namespace n ON n.oid = f.opfnamespace JOIN pg_catalog.pg_am am ON am.oid = f.opfmethod
610
+ WHERE ${s.sql} AND f.oid >= 16384 AND ${notExtensionOwned("pg_opfamily", "f.oid")}
611
+ AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_opclass c WHERE c.opcfamily = f.oid AND c.opcname = f.opfname AND c.opcnamespace = f.opfnamespace)
612
+ ORDER BY n.nspname ${C}, f.opfname ${C}, am.amname ${C}`,
613
+ s.params,
614
+ );
615
+ for (const r of families) {
616
+ const q = qname(String(r.schema), String(r.name));
617
+ const method = String(r.method);
618
+ const loose = Number(r.loose ?? 0);
619
+ out.push({
620
+ type: POSTGRES_ENTITY_TYPES.operatorFamily,
621
+ schema: String(r.schema),
622
+ name: String(r.name),
623
+ oid: String(r.oid),
624
+ signature: ` USING ${method}`,
625
+ ...withComment(r),
626
+ ...dependents(r),
627
+ ...(loose > 0 ? { warnings: [`${loose} loose member${loose === 1 ? "" : "s"} added with ALTER OPERATOR FAMILY ... ADD ${loose === 1 ? "is" : "are"} left out; a declaration holds the family and its classes' members`] } : {}),
628
+ statement: [`CREATE OPERATOR FAMILY ${q} USING ${method}`, ...commentOn(`OPERATOR FAMILY ${q} USING ${method}`, r.comment)].join(";\n"),
629
+ });
630
+ }
631
+ return out;
632
+ }
633
+
634
+ /**
635
+ * A dictionary's `dictinitoption`, which the server prints as
636
+ * `name = 'value', ...` (a quote and a backslash doubled in the value), as a
637
+ * list of name and value; undefined when the text is not that form.
638
+ */
639
+ export function parseDictionaryOptions(init: string | undefined): Array<[string, string]> | undefined {
640
+ if (init === undefined || init.trim() === "") return [];
641
+ const out: Array<[string, string]> = [];
642
+ const item = /\s*("(?:[^"]|"")+"|[^\s=,]+)\s*=\s*'((?:[^']|'')*)'\s*(?:,|$)/y;
643
+ let at = 0;
644
+ while (at < init.length) {
645
+ item.lastIndex = at;
646
+ const m = item.exec(init);
647
+ if (!m) return undefined;
648
+ const name = m[1]!.startsWith('"') ? m[1]!.slice(1, -1).replace(/""/g, '"') : m[1]!;
649
+ out.push([name, m[2]!.replace(/''/g, "'").replace(/\\\\/g, "\\")]);
650
+ at = item.lastIndex;
651
+ }
652
+ return out;
653
+ }
654
+
655
+ /** Text search dictionaries and configurations; a configuration's mappings are the ALTER ... ADD MAPPING statements after its CREATE. */
656
+ async function readTextSearch(client: PostgresClient, scope: SchemaScope): Promise<LivePgObject[]> {
657
+ const out: LivePgObject[] = [];
658
+ const s = scopeSql("n.nspname", scope);
659
+ const dictionaries = await client.query<Row>(
660
+ `SELECT d.oid::text AS oid, n.nspname AS schema, d.dictname AS name, d.dictinitoption AS init,
661
+ (SELECT ${refName("tn.nspname", "t.tmplname")} FROM pg_catalog.pg_ts_template t JOIN pg_catalog.pg_namespace tn ON tn.oid = t.tmplnamespace WHERE t.oid = d.dicttemplate) AS template,
662
+ pg_catalog.obj_description(d.oid, 'pg_ts_dict') AS comment, ${dependentsOf("pg_ts_dict", "d.oid")} AS dependents
663
+ FROM pg_catalog.pg_ts_dict d JOIN pg_catalog.pg_namespace n ON n.oid = d.dictnamespace
664
+ WHERE ${s.sql} AND d.oid >= 16384 AND ${notExtensionOwned("pg_ts_dict", "d.oid")}
665
+ ORDER BY n.nspname ${C}, d.dictname ${C}`,
666
+ s.params,
667
+ );
668
+ for (const r of dictionaries) {
669
+ const q = qname(String(r.schema), String(r.name));
670
+ const options = parseDictionaryOptions(str(r.init));
671
+ out.push({
672
+ type: POSTGRES_ENTITY_TYPES.textSearchDictionary,
673
+ schema: String(r.schema),
674
+ name: String(r.name),
675
+ oid: String(r.oid),
676
+ ...withComment(r),
677
+ ...dependents(r),
678
+ ...(options === undefined ? { unsupported: `its options (${str(r.init)}) are not in the form the server writes them` } : {}),
679
+ statement: [
680
+ `CREATE TEXT SEARCH DICTIONARY ${q} (${["TEMPLATE = " + r.template, ...(options ?? []).map(([k, v]) => `${quoteIdent(k)} = ${literal(v)}`)].join(", ")})`,
681
+ ...commentOn(`TEXT SEARCH DICTIONARY ${q}`, r.comment),
682
+ ].join(";\n"),
683
+ });
684
+ }
685
+
686
+ const configurations = await client.query<Row>(
687
+ `SELECT c.oid::text AS oid, n.nspname AS schema, c.cfgname AS name,
688
+ (SELECT ${refName("pn.nspname", "p.prsname")} FROM pg_catalog.pg_ts_parser p JOIN pg_catalog.pg_namespace pn ON pn.oid = p.prsnamespace WHERE p.oid = c.cfgparser) AS parser,
689
+ ARRAY(SELECT tt.alias || E'\\t' || pg_catalog.string_agg((SELECT ${refName("dn.nspname", "d.dictname")} FROM pg_catalog.pg_ts_dict d JOIN pg_catalog.pg_namespace dn ON dn.oid = d.dictnamespace WHERE d.oid = m.mapdict), ', ' ORDER BY m.mapseqno)
690
+ FROM pg_catalog.pg_ts_config_map m, pg_catalog.ts_token_type(c.cfgparser) tt WHERE m.mapcfg = c.oid AND tt.tokid = m.maptokentype
691
+ GROUP BY tt.tokid, tt.alias ORDER BY tt.tokid) AS mappings,
692
+ pg_catalog.obj_description(c.oid, 'pg_ts_config') AS comment, ${dependentsOf("pg_ts_config", "c.oid")} AS dependents
693
+ FROM pg_catalog.pg_ts_config c JOIN pg_catalog.pg_namespace n ON n.oid = c.cfgnamespace
694
+ WHERE ${s.sql} AND c.oid >= 16384 AND ${notExtensionOwned("pg_ts_config", "c.oid")}
695
+ ORDER BY n.nspname ${C}, c.cfgname ${C}`,
696
+ s.params,
697
+ );
698
+ for (const r of configurations) {
699
+ const q = qname(String(r.schema), String(r.name));
700
+ // Token types that share their dictionaries share an ADD MAPPING, in the parser's token order.
701
+ const groups = new Map<string, string[]>();
702
+ for (const line of (r.mappings as string[] | null) ?? []) {
703
+ const [token, dictionaries] = line.split("\t") as [string, string];
704
+ groups.set(dictionaries, [...(groups.get(dictionaries) ?? []), quoteIdent(token)]);
705
+ }
706
+ out.push({
707
+ type: POSTGRES_ENTITY_TYPES.textSearchConfiguration,
708
+ schema: String(r.schema),
709
+ name: String(r.name),
710
+ oid: String(r.oid),
711
+ ...withComment(r),
712
+ ...dependents(r),
713
+ statement: [
714
+ `CREATE TEXT SEARCH CONFIGURATION ${q} (PARSER = ${r.parser})`,
715
+ ...[...groups].map(([dicts, tokens]) => `ALTER TEXT SEARCH CONFIGURATION ${q} ADD MAPPING FOR ${tokens.join(", ")} WITH ${dicts}`),
716
+ ...commentOn(`TEXT SEARCH CONFIGURATION ${q}`, r.comment),
717
+ ].join(";\n"),
718
+ });
719
+ }
720
+ return out;
721
+ }
722
+
723
+ /**
724
+ * Foreign tables: their columns (type, collation, default, NOT NULL and
725
+ * OPTIONS), CHECK constraints, server and OPTIONS. One that is a partition,
726
+ * inherits or has a generated column is read as unsupported, since a
727
+ * declaration does not hold those. An option that holds a credential is left
728
+ * out and said so.
729
+ */
730
+ async function readForeignTables(client: PostgresClient, scope: SchemaScope): Promise<LivePgObject[]> {
731
+ const s = scopeSql("n.nspname", scope);
732
+ const tables = await client.query<Row>(
733
+ `SELECT c.oid::text AS oid, n.nspname AS schema, c.relname AS name, c.relispartition AS ispartition,
734
+ EXISTS (SELECT 1 FROM pg_catalog.pg_inherits i WHERE i.inhrelid = c.oid) AS inherits,
735
+ fs.srvname AS server, ft.ftoptions AS options,
736
+ pg_catalog.obj_description(c.oid, 'pg_class') AS comment, ${dependentsOf("pg_class", "c.oid")} AS dependents
737
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
738
+ JOIN pg_catalog.pg_foreign_table ft ON ft.ftrelid = c.oid JOIN pg_catalog.pg_foreign_server fs ON fs.oid = ft.ftserver
739
+ WHERE c.relkind = 'f' AND ${s.sql} AND ${notExtensionOwned("pg_class", "c.oid")}
740
+ ORDER BY n.nspname ${C}, c.relname ${C}`,
741
+ s.params,
742
+ );
743
+ if (tables.length === 0) return [];
744
+ const oids = tables.map((t) => String(t.oid));
745
+ const columns = await client.query<Row>(
746
+ `SELECT a.attrelid::text AS rel, a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS notnull,
747
+ a.attgenerated::text AS generated, pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) AS dflt,
748
+ CASE WHEN a.attcollation <> 0 AND a.attcollation <> t.typcollation THEN ${collationName("a.attcollation")} END AS collation,
749
+ a.attfdwoptions AS options, pg_catalog.col_description(a.attrelid, a.attnum) AS comment
750
+ FROM pg_catalog.pg_attribute AS a JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
751
+ LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
752
+ WHERE a.attrelid = ANY($1::oid[]) AND a.attnum > 0 AND NOT a.attisdropped
753
+ ORDER BY a.attrelid, a.attnum`,
754
+ [oids],
755
+ );
756
+ const checks = await client.query<Row>(
757
+ `SELECT k.conrelid::text AS rel, k.conname AS name, pg_catalog.pg_get_constraintdef(k.oid, true) AS def, pg_catalog.obj_description(k.oid, 'pg_constraint') AS comment
758
+ FROM pg_catalog.pg_constraint k
759
+ WHERE k.conrelid = ANY($1::oid[]) AND k.contype = 'c' AND k.conislocal
760
+ ORDER BY k.conrelid, k.conname ${C}`,
761
+ [oids],
762
+ );
763
+ return tables.map((t): LivePgObject => {
764
+ const rel = String(t.oid);
765
+ const q = qname(String(t.schema), String(t.name));
766
+ const omitted: string[] = [];
767
+ const lines: string[] = [];
768
+ const commentLines: string[] = [];
769
+ let generated = false;
770
+ for (const c of columns.filter((x) => String(x.rel) === rel)) {
771
+ const parts = [quoteIdent(String(c.name)), String(c.type)];
772
+ const opts = optionsOf(c.options);
773
+ omitted.push(...opts.omitted);
774
+ if (opts.clause) parts.push(opts.clause.trim());
775
+ if (str(c.collation)) parts.push(`COLLATE ${c.collation}`);
776
+ if (str(c.generated)) generated = true;
777
+ else if (str(c.dflt)) parts.push(`DEFAULT ${c.dflt}`);
778
+ if (c.notnull === true) parts.push("NOT NULL");
779
+ lines.push(parts.join(" "));
780
+ commentLines.push(...commentOn(`COLUMN ${q}.${quoteIdent(String(c.name))}`, c.comment));
781
+ }
782
+ for (const k of checks.filter((x) => String(x.rel) === rel)) {
783
+ lines.push(`CONSTRAINT ${quoteIdent(String(k.name))} ${k.def}`);
784
+ commentLines.push(...commentOn(`CONSTRAINT ${quoteIdent(String(k.name))} ON ${q}`, k.comment));
785
+ }
786
+ const { clause, omitted: tableOmitted } = optionsOf(t.options);
787
+ omitted.push(...tableOmitted);
788
+ const unsupported =
789
+ t.ispartition === true ? "it is a partition of another table (CREATE FOREIGN TABLE ... PARTITION OF is not declared)" : t.inherits === true ? "it inherits from another table (INHERITS is not declared on a foreign table)" : generated ? "it has a generated column (not declared on a foreign table)" : undefined;
790
+ return {
791
+ type: POSTGRES_ENTITY_TYPES.foreignTable,
792
+ schema: String(t.schema),
793
+ name: String(t.name),
794
+ oid: rel,
795
+ ...withComment(t),
796
+ ...dependents(t),
797
+ ...omittedWarning(omitted),
798
+ ...(unsupported ? { unsupported } : {}),
799
+ statement: [
800
+ `CREATE FOREIGN TABLE ${q} (${lines.length > 0 ? `\n ${lines.join(",\n ")}\n` : ""}) SERVER ${quoteIdent(String(t.server))}${clause}`,
801
+ ...commentOn(`FOREIGN TABLE ${q}`, t.comment),
802
+ ...commentLines,
803
+ ].join(";\n"),
804
+ };
805
+ });
806
+ }
@@ -58,6 +58,8 @@ export function declaredAddress(entity: { type: string; props: Record<string, un
58
58
  entity.type === POSTGRES_ENTITY_TYPES.procedure ||
59
59
  entity.type === POSTGRES_ENTITY_TYPES.aggregate ||
60
60
  entity.type === POSTGRES_ENTITY_TYPES.operator ||
61
+ entity.type === POSTGRES_ENTITY_TYPES.operatorClass ||
62
+ entity.type === POSTGRES_ENTITY_TYPES.operatorFamily ||
61
63
  entity.type === POSTGRES_ENTITY_TYPES.trigger ||
62
64
  entity.type === POSTGRES_ENTITY_TYPES.policy
63
65
  ) {
@@ -49,7 +49,7 @@ import { POSTGRES_ENTITY_TYPES } from "../entity-types";
49
49
  import type { ColumnDef } from "../entities";
50
50
  import type { DeclaredPgObject } from "../apply/statements";
51
51
  import { col, migrationNames, MIGRATION_TRAILER_KEY, pgName, type MigrationChange, type MigrationNames } from "./names";
52
- import { publicationsOf, type PublicationHit } from "./replication";
52
+ import { publicationsOf, subscriptionRefusal, subscriptionsOf, type PublicationHit } from "./replication";
53
53
  import { keyText, type KeyColumn } from "./batches";
54
54
  import { carriedStates, carriedSubject, columnSequence, discoverDependents, replaceColumn, storedType, type CarriedObject, type CarriedState, type CarriedView, type ColumnSequence, type Dependents } from "./carry";
55
55
 
@@ -405,6 +405,8 @@ export async function observeMigration(input: ObserveInput): Promise<MigrationOb
405
405
  `A migration of the publisher and the subscriber together is chant #3332.`,
406
406
  );
407
407
  }
408
+ const subscriptions = await subscriptionsOf(client, schema, table, major);
409
+ if (subscriptions.length > 0) throw new MigrationRefusal(subscriptionRefusal(where, input.column, subscriptions));
408
410
  }
409
411
 
410
412
  // After a rename's switch the old column still holds the default or the identity the contract moves, and its sequence is read for it.
@@ -34,6 +34,32 @@
34
34
  * publication sends the migrated column of, naming each publication. A
35
35
  * publication with a column list (15 and later) that leaves the column out
36
36
  * sends neither it nor the new one, and does not stop the migration.
37
+ *
38
+ * ## Subscriptions (#4405)
39
+ *
40
+ * The other side: a table that is the target of a subscription. The
41
+ * subscription's apply worker runs with `session_replication_role = replica`
42
+ * ("Logical Replication", Architecture,
43
+ * https://www.postgresql.org/docs/18/logical-replication-architecture.html),
44
+ * and a trigger made by plain `CREATE TRIGGER` is `ENABLE ORIGIN`: it fires
45
+ * only while the setting is `origin` or `local`
46
+ * (https://www.postgresql.org/docs/18/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE).
47
+ * Only `ENABLE REPLICA` and `ENABLE ALWAYS` triggers fire for the worker
48
+ * (https://www.postgresql.org/docs/18/sql-altertable.html, the TRIGGER
49
+ * clauses). The architecture page says "by default, triggers and rules will
50
+ * not fire on a subscriber" and points to `ENABLE TRIGGER` to change it. So the dual write
51
+ * never sees a replicated row, and the new column goes stale. The initial
52
+ * table copy is the exception: it is a `COPY` and fires plain triggers.
53
+ *
54
+ * `ENABLE ALWAYS` would make the trigger fire, and the Plan phase refuses
55
+ * the table instead, because the Op's later steps assume it controls every
56
+ * writer of the old column. The publisher keeps sending the old column's name
57
+ * and the subscriber applies by name, so a rename's contract (which drops the
58
+ * old column) would stop the apply at the next row, and the switch of a
59
+ * type change would change the column under a running apply worker. Neither
60
+ * order can be made inside this Op's one connection to one server; that is
61
+ * the same limit #3332 recorded for the publisher side. The refusal names
62
+ * each subscription.
37
63
  */
38
64
 
39
65
  import type { PostgresClient } from "../live/client";
@@ -149,3 +175,43 @@ function sleepFor(ms: number, signal?: AbortSignal): Promise<void> {
149
175
  );
150
176
  });
151
177
  }
178
+
179
+ /** One subscription that has the table, or a partition of it, as a target. */
180
+ export interface SubscriptionHit {
181
+ name: string;
182
+ enabled: boolean;
183
+ }
184
+
185
+ /**
186
+ * The subscriptions of the current database with a row in `pg_subscription_rel`
187
+ * for `schema.table` (and, on a partitioned table, for any of its
188
+ * partitions, which is where a subscription to a partitioned table lists the
189
+ * tables it applies to). Read at plan time; `pg_subscription_rel` is
190
+ * per database and `pg_subscription` is shared, so the join keeps this
191
+ * database's rows (`subdbid`).
192
+ */
193
+ export async function subscriptionsOf(client: Pick<PostgresClient, "query">, schema: string, table: string, major: number): Promise<SubscriptionHit[]> {
194
+ const rel = "pg_catalog.to_regclass(pg_catalog.quote_ident($1) || '.' || pg_catalog.quote_ident($2))";
195
+ const tables = major >= 12 ? `SELECT relid FROM pg_catalog.pg_partition_tree(${rel})` : `SELECT ${rel}::oid AS relid`;
196
+ const rows = await client.query<{ subname: string; subenabled: boolean }>(
197
+ `SELECT DISTINCT s.subname, s.subenabled
198
+ FROM pg_catalog.pg_subscription_rel sr
199
+ JOIN pg_catalog.pg_subscription s ON s.oid = sr.srsubid
200
+ WHERE s.subdbid = (SELECT oid FROM pg_catalog.pg_database WHERE datname = pg_catalog.current_database())
201
+ AND sr.srrelid IN (${tables})
202
+ ORDER BY s.subname`,
203
+ [schema, table],
204
+ );
205
+ return rows.map((r) => ({ name: r.subname, enabled: r.subenabled }));
206
+ }
207
+
208
+ /** The Plan phase's refusal for a table that a subscription applies to. */
209
+ export function subscriptionRefusal(where: string, column: string, hits: SubscriptionHit[]): string {
210
+ return (
211
+ `${where} is a target of logical replication subscription(s) ${hits.map((h) => `${h.name}${h.enabled ? "" : " (disabled)"}`).join(", ")}. ` +
212
+ `A subscription's apply worker runs with session_replication_role = replica, where a trigger made by CREATE TRIGGER does not fire, so the dual write would never see a replicated row and the new column of ${column} would go stale ` +
213
+ `(https://www.postgresql.org/docs/18/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE). ` +
214
+ `ENABLE ALWAYS would fire it, but the publisher keeps sending the old column's name and the subscriber applies by name, so the contract (or the switch of a type change) would stop the apply, and one Op cannot change both servers. ` +
215
+ `Take the table out of the subscription (drop it from the publication and ALTER SUBSCRIPTION ... REFRESH PUBLICATION), or migrate the publisher and the subscriber by hand. A migration of both together is chant #3332.`
216
+ );
217
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A table that is the target of a logical replication subscription (#4405),
3
+ * against two pinned servers with `wal_level = logical`, a publisher and a
4
+ * subscriber on one Docker network:
5
+ *
6
+ * - The fact the refusal rests on: a trigger made by plain `CREATE TRIGGER`
7
+ * does not fire for the subscription's apply worker, and one set
8
+ * `ENABLE ALWAYS` does.
9
+ * - The Plan phase refuses the subscriber's table, naming the subscription,
10
+ * and stops refusing it once the table is out of the publication and the
11
+ * subscription has refreshed.
12
+ *
13
+ * Needs Docker; skips cleanly without it.
14
+ */
15
+
16
+ import { execFile } from "node:child_process";
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { promisify } from "node:util";
21
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
+ import { dockerAvailable } from "../testing/server";
23
+ import { connectPostgres, type PostgresClient } from "../live/client";
24
+ import { postgresMigrationPlan, type PostgresMigrationDeps } from "../../op/activities/postgres-migration";
25
+ import { postgresImage, POSTGRES_LATEST_MAJOR } from "../../spec/postgres-pin";
26
+
27
+ const run = promisify(execFile);
28
+ const enabled = await dockerAvailable();
29
+ const PASSWORD = "chant";
30
+ const suffix = `${process.pid}-${Date.now()}`;
31
+ const network = `chant-sub-net-${suffix}`;
32
+ const dir = mkdtempSync(join(tmpdir(), "chant-pg-subscriber-"));
33
+ const MARKER = { stack: "e2e", env: "test" };
34
+
35
+ interface Server {
36
+ name: string;
37
+ port: string;
38
+ client: PostgresClient;
39
+ }
40
+ let pub: Server | undefined;
41
+ let sub: Server | undefined;
42
+
43
+ async function start(role: string): Promise<Server> {
44
+ const name = `chant-sub-${role}-${suffix}`;
45
+ await run("docker", [
46
+ "run", "-d", "--name", name, "--network", network, "-p", "127.0.0.1::5432", "-e", `POSTGRES_PASSWORD=${PASSWORD}`,
47
+ postgresImage(POSTGRES_LATEST_MAJOR), "-c", "wal_level=logical",
48
+ ]);
49
+ const port = (await run("docker", ["port", name, "5432/tcp"])).stdout.trim().split(":").pop()!;
50
+ for (let i = 0; i < 120; i++) {
51
+ try {
52
+ const client = await connectPostgres({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, password: PASSWORD });
53
+ await client.query("SELECT 1");
54
+ return { name, port, client };
55
+ } catch {
56
+ await new Promise((r) => setTimeout(r, 1000));
57
+ }
58
+ }
59
+ throw new Error(`${name} did not accept a connection`);
60
+ }
61
+
62
+ beforeAll(async () => {
63
+ if (!enabled) return;
64
+ await run("docker", ["network", "create", network]);
65
+ [pub, sub] = await Promise.all([start("pub"), start("sub")]);
66
+ }, 600_000);
67
+
68
+ afterAll(async () => {
69
+ for (const s of [pub, sub]) {
70
+ await s?.client.end().catch(() => undefined);
71
+ if (s) await run("docker", ["rm", "-f", "-v", s.name]).catch(() => undefined);
72
+ }
73
+ if (enabled) await run("docker", ["network", "rm", network]).catch(() => undefined);
74
+ rmSync(dir, { recursive: true, force: true });
75
+ });
76
+
77
+ const waitFor = async (what: string, check: () => Promise<boolean>) => {
78
+ for (let i = 0; i < 60; i++) {
79
+ if (await check()) return;
80
+ await new Promise((r) => setTimeout(r, 500));
81
+ }
82
+ throw new Error(`timed out waiting for ${what}`);
83
+ };
84
+
85
+ const TABLE = "CREATE TABLE app.users (\n id bigint PRIMARY KEY,\n email text NOT NULL\n)";
86
+ const deps = (): PostgresMigrationDeps => ({
87
+ config: { ownership: MARKER, sql: { profiles: { e2e: { url: `postgres://postgres@127.0.0.1:${sub!.port}/postgres`, password: { env: "PG_E2E_PASSWORD" } } } } },
88
+ env: { PG_E2E_PASSWORD: PASSWORD },
89
+ log: () => undefined,
90
+ });
91
+ /** The Plan phase of a rename of email to login, on the subscriber. */
92
+ function planRename(): Promise<string> {
93
+ writeFileSync(
94
+ join(dir, "v2.json"),
95
+ JSON.stringify({
96
+ dialect: "postgres",
97
+ postgresMajor: 18,
98
+ applyOrder: ["app", "users"],
99
+ objects: [
100
+ { export: "app", type: "Postgres::Schema", ddl: "CREATE SCHEMA app", dependsOn: [] },
101
+ { export: "users", type: "Postgres::Table", dependsOn: ["app"], ddl: "CREATE TABLE app.users (\n id bigint PRIMARY KEY,\n login text NOT NULL -- previously: email\n)" },
102
+ ],
103
+ }),
104
+ );
105
+ return postgresMigrationPlan({ table: "app.users", column: "login", buildPath: "v2.json", environment: "e2e", stack: MARKER.stack, ownershipEnv: MARKER.env, cwd: dir } as never, undefined, deps()).then(
106
+ () => "",
107
+ (e: unknown) => String((e as Error).message),
108
+ );
109
+ }
110
+
111
+ describe.skipIf(!enabled)("a subscription's target table", () => {
112
+ test("the apply worker fires an ENABLE ALWAYS trigger and not a default one", async () => {
113
+ for (const s of [pub!, sub!]) await s.client.query(`CREATE SCHEMA app`).then(() => s.client.query(TABLE));
114
+ await pub!.client.query("CREATE PUBLICATION pub FOR TABLE app.users");
115
+ await sub!.client.query(`CREATE SUBSCRIPTION sub CONNECTION 'host=${pub!.name} port=5432 user=postgres password=${PASSWORD} dbname=postgres' PUBLICATION pub`);
116
+ await waitFor("the initial sync", async () => (await sub!.client.query<{ n: string }>("SELECT count(*)::text AS n FROM pg_subscription_rel WHERE srsubstate <> 'r'"))[0]!.n === "0");
117
+
118
+ await sub!.client.query("ALTER TABLE app.users ADD COLUMN seen text");
119
+ await sub!.client.query("CREATE FUNCTION app.mark() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.seen := TG_NAME; RETURN NEW; END $$");
120
+ await sub!.client.query("CREATE TRIGGER t_default BEFORE INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.mark()");
121
+ await pub!.client.query("INSERT INTO app.users VALUES (1, 'a')");
122
+ await waitFor("row 1", async () => (await sub!.client.query("SELECT 1 FROM app.users WHERE id = 1")).length === 1);
123
+ expect(await sub!.client.query("SELECT seen FROM app.users WHERE id = 1")).toEqual([{ seen: null }]);
124
+
125
+ await sub!.client.query("ALTER TABLE app.users ENABLE ALWAYS TRIGGER t_default");
126
+ await pub!.client.query("INSERT INTO app.users VALUES (2, 'b')");
127
+ await waitFor("row 2", async () => (await sub!.client.query("SELECT 1 FROM app.users WHERE id = 2")).length === 1);
128
+ expect(await sub!.client.query("SELECT seen FROM app.users WHERE id = 2")).toEqual([{ seen: "t_default" }]);
129
+ await sub!.client.query("DROP TRIGGER t_default ON app.users");
130
+ await sub!.client.query("ALTER TABLE app.users DROP COLUMN seen");
131
+ }, 300_000);
132
+
133
+ test("the Plan phase refuses it naming the subscription, and not once the table has left the publication", async () => {
134
+ expect(await planRename()).toMatch(/target of logical replication subscription\(s\) sub\b/);
135
+
136
+ await pub!.client.query("ALTER PUBLICATION pub DROP TABLE app.users");
137
+ await sub!.client.query("ALTER SUBSCRIPTION sub REFRESH PUBLICATION");
138
+ await waitFor("the table to leave the subscription", async () => (await sub!.client.query("SELECT 1 FROM pg_subscription_rel")).length === 0);
139
+ expect(await planRename()).not.toMatch(/subscription/);
140
+ }, 300_000);
141
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The Plan phase's read of the subscriptions a table is the target of
3
+ * (#4405), and its refusal. The e2e is `subscriber.e2e.test.ts`.
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import type { PostgresClient } from "../live/client";
8
+ import { subscriptionRefusal, subscriptionsOf } from "./replication";
9
+
10
+ function fake(rows: unknown[]) {
11
+ const calls: { sql: string; params?: readonly unknown[] }[] = [];
12
+ const client = {
13
+ query: async (sql: string, params?: readonly unknown[]) => {
14
+ calls.push({ sql, params });
15
+ return rows;
16
+ },
17
+ } as unknown as Pick<PostgresClient, "query">;
18
+ return { client, calls };
19
+ }
20
+
21
+ describe("subscriptionsOf", () => {
22
+ test("reads pg_subscription_rel and pg_subscription for this database, by schema and table", async () => {
23
+ const { client, calls } = fake([{ subname: "sub_a", subenabled: true }, { subname: "sub_b", subenabled: false }]);
24
+ expect(await subscriptionsOf(client, "app", "users", 18)).toEqual([{ name: "sub_a", enabled: true }, { name: "sub_b", enabled: false }]);
25
+ expect(calls).toHaveLength(1);
26
+ expect(calls[0]!.params).toEqual(["app", "users"]);
27
+ expect(calls[0]!.sql).toContain("pg_catalog.pg_subscription_rel");
28
+ expect(calls[0]!.sql).toContain("pg_catalog.pg_subscription s");
29
+ expect(calls[0]!.sql).toContain("s.subdbid");
30
+ });
31
+
32
+ test("a partitioned table is matched through its partitions; before 12 the table alone", async () => {
33
+ const now = fake([]);
34
+ await subscriptionsOf(now.client, "app", "events", 18);
35
+ expect(now.calls[0]!.sql).toContain("pg_partition_tree");
36
+ const old = fake([]);
37
+ await subscriptionsOf(old.client, "app", "events", 11);
38
+ expect(old.calls[0]!.sql).not.toContain("pg_partition_tree");
39
+ });
40
+
41
+ test("no row means no subscription", async () => {
42
+ expect(await subscriptionsOf(fake([]).client, "app", "users", 18)).toEqual([]);
43
+ });
44
+ });
45
+
46
+ describe("subscriptionRefusal", () => {
47
+ const message = subscriptionRefusal("app.users", "login", [{ name: "sub_a", enabled: true }, { name: "sub_b", enabled: false }]);
48
+
49
+ test("names each subscription, and a disabled one as such", () => {
50
+ expect(message).toContain("sub_a, sub_b (disabled)");
51
+ expect(message.startsWith("app.users is a target")).toBe(true);
52
+ });
53
+
54
+ test("gives the reason and the way out", () => {
55
+ expect(message).toContain("session_replication_role = replica");
56
+ expect(message).toContain("ENABLE ALWAYS");
57
+ expect(message).toContain("REFRESH PUBLICATION");
58
+ });
59
+ });