@intentius/chant-lexicon-sql 0.115.0 → 0.116.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 (78) hide show
  1. package/dist/codegen/registry.d.ts.map +1 -1
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/integrity.json +5 -5
  5. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  6. package/dist/lsp/postgres-template.d.ts.map +1 -1
  7. package/dist/manifest.json +1 -1
  8. package/dist/meta.json +15 -0
  9. package/dist/okf/index.md +3 -0
  10. package/dist/okf/types/PostgresFunction.md +9 -0
  11. package/dist/okf/types/PostgresProcedure.md +9 -0
  12. package/dist/okf/types/PostgresTrigger.md +9 -0
  13. package/dist/plugin.d.ts.map +1 -1
  14. package/dist/postgres/apply/apply.d.ts +6 -1
  15. package/dist/postgres/apply/apply.d.ts.map +1 -1
  16. package/dist/postgres/apply/statements.d.ts +9 -2
  17. package/dist/postgres/apply/statements.d.ts.map +1 -1
  18. package/dist/postgres/entities.d.ts +110 -3
  19. package/dist/postgres/entities.d.ts.map +1 -1
  20. package/dist/postgres/entity-types.d.ts +3 -0
  21. package/dist/postgres/entity-types.d.ts.map +1 -1
  22. package/dist/postgres/import/generator.d.ts.map +1 -1
  23. package/dist/postgres/import/live-export.d.ts.map +1 -1
  24. package/dist/postgres/live/catalog.d.ts +16 -4
  25. package/dist/postgres/live/catalog.d.ts.map +1 -1
  26. package/dist/postgres/live/describe-resources.d.ts +6 -1
  27. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  28. package/dist/postgres/parser.d.ts +82 -5
  29. package/dist/postgres/parser.d.ts.map +1 -1
  30. package/dist/postgres/plan/deep.d.ts.map +1 -1
  31. package/dist/postgres/plan/diff.d.ts +1 -1
  32. package/dist/postgres/plan/diff.d.ts.map +1 -1
  33. package/dist/postgres/plan/normalize.d.ts +21 -2
  34. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  35. package/dist/postgres/plan/rules.d.ts +6 -0
  36. package/dist/postgres/plan/rules.d.ts.map +1 -1
  37. package/dist/postgres/plan/schema.d.ts +11 -1
  38. package/dist/postgres/plan/schema.d.ts.map +1 -1
  39. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  40. package/dist/postgres.d.ts +1 -1
  41. package/dist/postgres.d.ts.map +1 -1
  42. package/dist/rules/postgres-templates.ts +2 -2
  43. package/dist/rules/sqlpg001.ts +3 -0
  44. package/dist/validate.d.ts.map +1 -1
  45. package/package.json +2 -2
  46. package/src/codegen/postgres-classifier-page.ts +1 -1
  47. package/src/codegen/registry.ts +3 -0
  48. package/src/generated/lexicon-sql.json +15 -0
  49. package/src/index.ts +1 -1
  50. package/src/lint/rules/postgres-templates.ts +2 -2
  51. package/src/lint/rules/sqlpg001.ts +3 -0
  52. package/src/lsp/postgres-template.ts +6 -0
  53. package/src/mcp/postgres.ts +1 -1
  54. package/src/plugin.ts +3 -0
  55. package/src/postgres/apply/apply.ts +21 -5
  56. package/src/postgres/apply/statements.ts +53 -11
  57. package/src/postgres/entities.ts +332 -9
  58. package/src/postgres/entity-types.ts +3 -0
  59. package/src/postgres/import/generator.ts +5 -2
  60. package/src/postgres/import/live-export.ts +4 -0
  61. package/src/postgres/live/catalog.ts +78 -4
  62. package/src/postgres/live/describe-resources.ts +17 -7
  63. package/src/postgres/live/routines.e2e.test.ts +173 -0
  64. package/src/postgres/migrate/batches.e2e.test.ts +2 -1
  65. package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
  66. package/src/postgres/parser.ts +314 -8
  67. package/src/postgres/plan/commands.ts +2 -2
  68. package/src/postgres/plan/deep.ts +57 -17
  69. package/src/postgres/plan/diff.ts +46 -6
  70. package/src/postgres/plan/normalize.ts +158 -4
  71. package/src/postgres/plan/rules.ts +36 -0
  72. package/src/postgres/plan/schema.ts +25 -6
  73. package/src/postgres/plan/server-normalize.ts +24 -0
  74. package/src/postgres/routines.test.ts +150 -0
  75. package/src/postgres.ts +11 -0
  76. package/src/skill-defs.ts +1 -1
  77. package/src/skills/chant-sql-postgres.md +18 -2
  78. package/src/validate.ts +3 -0
@@ -18,6 +18,11 @@
18
18
  * - objects an extension owns (`pg_depend.deptype = 'e'`): the extension
19
19
  * declares them, so the extension is what is imported;
20
20
  * - `plpgsql`, installed in every database;
21
+ * - aggregates, and a trigger the server makes for itself (a foreign key's)
22
+ * or clones onto a partition from its parent's;
23
+ * - a routine with a SQL-standard body (`RETURN`, `BEGIN ATOMIC`): read, but
24
+ * marked `unsupported`, since the server prints it rewritten and a
25
+ * declaration cannot hold it;
21
26
  * - a sequence an identity column owns (it is the column's), and a `serial`
22
27
  * column's sequence, since the column prints as `serial`;
23
28
  * - an index that backs a constraint (the constraint creates it), and an
@@ -76,6 +81,12 @@ export interface LivePgObject {
76
81
  statement: string;
77
82
  /** The tool whose bookkeeping this is, when it is another tool's (an ORM's revision table). */
78
83
  foreign?: string;
84
+ /** A routine's input parameter types, `(integer,text)`; a trigger's table, ` ON app.users` (`../plan/normalize.ts`). */
85
+ signature?: string;
86
+ /** What depends on a routine (a view, a trigger, a column default), as the server describes each. */
87
+ dependents?: string[];
88
+ /** Why no declaration can hold this object; it is read so import can say it left it out. */
89
+ unsupported?: string;
79
90
  }
80
91
 
81
92
  /** What {@link readLiveSchema} reads. */
@@ -181,8 +192,9 @@ const seqParams = (r: Row): SequenceParams => ({
181
192
 
182
193
  /**
183
194
  * Every object in scope, in a stable order: schemas, extensions, enums,
184
- * domains, sequences, tables, views and materialized views, indexes; each
185
- * kind by schema then name, in byte order.
195
+ * domains, sequences, tables, views and materialized views, indexes,
196
+ * functions and procedures, triggers; each kind by schema then name, in byte
197
+ * order.
186
198
  */
187
199
  export async function readLiveSchema(client: PostgresClient, scope: SchemaScope = {}): Promise<LivePgObject[]> {
188
200
  const out: LivePgObject[] = [];
@@ -556,11 +568,73 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
556
568
  }
557
569
  }
558
570
 
571
+ // Functions and procedures (not aggregates).
572
+ {
573
+ const rows = await client.query<Row>(
574
+ `SELECT p.oid::text AS oid, n.nspname AS schema, p.proname AS name, p.prokind AS kind, pg_catalog.pg_get_functiondef(p.oid) AS def,
575
+ '(' || pg_catalog.replace(pg_catalog.oidvectortypes(p.proargtypes), ', ', ',') || ')' AS signature,
576
+ p.prosqlbody IS NOT NULL AS sqlbody, pg_catalog.obj_description(p.oid, 'pg_proc') AS comment,
577
+ ARRAY(SELECT pg_catalog.pg_describe_object(d.classid, d.objid, d.objsubid) FROM pg_catalog.pg_depend d
578
+ WHERE d.refclassid = 'pg_catalog.pg_proc'::pg_catalog.regclass AND d.refobjid = p.oid AND d.deptype = 'n'
579
+ ORDER BY 1) AS dependents
580
+ FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
581
+ WHERE p.prokind IN ('f', 'p', 'w') AND ${s.sql} AND ${notExtensionOwned("pg_proc", "p.oid")}
582
+ ORDER BY n.nspname ${C}, p.proname ${C}, pg_catalog.oidvectortypes(p.proargtypes) ${C}`,
583
+ s.params,
584
+ );
585
+ for (const r of rows) {
586
+ // A migration's dual-write function, until its contract.
587
+ if (hasChantTrailerKey(str(r.comment), [MIGRATION_TRAILER_KEY])) continue;
588
+ const signature = String(r.signature);
589
+ const q = `${qname(String(r.schema), String(r.name))}${signature}`;
590
+ const procedure = r.kind === "p";
591
+ const dependents = (r.dependents as string[] | null) ?? [];
592
+ out.push({
593
+ type: procedure ? POSTGRES_ENTITY_TYPES.procedure : POSTGRES_ENTITY_TYPES.function,
594
+ schema: String(r.schema),
595
+ name: String(r.name),
596
+ oid: String(r.oid),
597
+ signature,
598
+ ...(dependents.length > 0 ? { dependents } : {}),
599
+ ...(str(r.comment) ? { comment: str(r.comment) } : {}),
600
+ ...(r.sqlbody === true ? { unsupported: "its body is SQL-standard (RETURN or BEGIN ATOMIC), which the server prints back rewritten; declare it with a string body (AS $$ ... $$)" } : {}),
601
+ statement: [String(r.def).trim(), ...commentOn(`${procedure ? "PROCEDURE" : "FUNCTION"} ${q}`, r.comment)].join(";\n"),
602
+ });
603
+ }
604
+ }
605
+
606
+ // Triggers: the ones written by hand, not a foreign key's or a partition's clone of its parent's.
607
+ {
608
+ const rows = await client.query<Row>(
609
+ `SELECT t.oid::text AS oid, n.nspname AS schema, c.relname AS table_name, t.tgname AS name, pg_catalog.pg_get_triggerdef(t.oid, true) AS def,
610
+ pg_catalog.obj_description(t.oid, 'pg_trigger') AS comment
611
+ FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
612
+ WHERE NOT t.tgisinternal AND t.tgparentid = 0 AND ${s.sql} AND ${notExtensionOwned("pg_class", "c.oid")}
613
+ ORDER BY n.nspname ${C}, c.relname ${C}, t.tgname ${C}`,
614
+ s.params,
615
+ );
616
+ for (const r of rows) {
617
+ // A migration's dual-write trigger, until its contract.
618
+ if (hasChantTrailerKey(str(r.comment), [MIGRATION_TRAILER_KEY])) continue;
619
+ const table = qname(String(r.schema), String(r.table_name));
620
+ out.push({
621
+ type: POSTGRES_ENTITY_TYPES.trigger,
622
+ schema: String(r.schema),
623
+ name: String(r.name),
624
+ oid: String(r.oid),
625
+ signature: ` ON ${table}`,
626
+ ...(str(r.comment) ? { comment: str(r.comment) } : {}),
627
+ ...(FOREIGN_TABLES[String(r.table_name)] ? { foreign: FOREIGN_TABLES[String(r.table_name)] } : {}),
628
+ statement: [String(r.def), ...commentOn(`TRIGGER ${quoteIdent(String(r.name))} ON ${table}`, r.comment)].join(";\n"),
629
+ });
630
+ }
631
+ }
632
+
559
633
  return out;
560
634
  }
561
635
 
562
- /** The key an object is found by: its type and schema-qualified name. */
563
- export const liveKey = (type: string, schema: string | undefined, name: string): string => `${type} ${schema ?? ""}.${name}`;
636
+ /** The key an object is found by: its type and schema-qualified name, and a routine's parameter types or a trigger's table. */
637
+ export const liveKey = (type: string, schema: string | undefined, name: string, signature?: string): string => `${type} ${schema ?? ""}.${name}${signature ?? ""}`;
564
638
 
565
639
  /**
566
640
  * Mark what the managed service owns (#3282) as foreign: its reserved
@@ -27,17 +27,27 @@ import { liveKey, markProviderOwned, readLiveSchema, type LivePgObject } from ".
27
27
  import { POSTGRES_ENTITY_TYPES } from "../entity-types";
28
28
  import { isChantManaged, readMarker, stripMarker } from "../../core/ownership";
29
29
  import { identValue } from "../parser";
30
+ import { canonicalPgObject } from "../plan/normalize";
30
31
 
31
32
  interface Bound {
32
33
  target: PostgresTarget;
33
34
  byKey: Map<string, LivePgObject>;
34
35
  }
35
36
 
36
- /** The schema and name a declared entity is created as; a bare name is in the default schema. */
37
- export function declaredAddress(entity: { type: string; props: Record<string, unknown> }, defaultSchema: string): { schema?: string; name: string } {
37
+ /**
38
+ * The schema and name a declared entity is created as; a bare name is in the
39
+ * default schema. A routine's parameter types and a trigger's table tell it
40
+ * from another of the same name (`signature`).
41
+ */
42
+ export function declaredAddress(entity: { type: string; props: Record<string, unknown> }, defaultSchema: string): { schema?: string; name: string; signature?: string } {
38
43
  const name = String(entity.props.name ?? "");
39
44
  if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension) return { name };
40
- return { schema: typeof entity.props.schema === "string" ? entity.props.schema : identValue(defaultSchema), name };
45
+ const schema = typeof entity.props.schema === "string" ? entity.props.schema : identValue(defaultSchema);
46
+ if (entity.type === POSTGRES_ENTITY_TYPES.function || entity.type === POSTGRES_ENTITY_TYPES.procedure || entity.type === POSTGRES_ENTITY_TYPES.trigger) {
47
+ const signature = canonicalPgObject(entity.type, entity.props, defaultSchema).signature;
48
+ return { schema, name, ...(signature !== undefined ? { signature } : {}) };
49
+ }
50
+ return { schema, name };
41
51
  }
42
52
 
43
53
  /** A schema scope for the catalog read: the profile's schemas, else every schema the declarations name and the default one. */
@@ -59,7 +69,7 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
59
69
  const { target, client } = await bindPostgres(options);
60
70
  try {
61
71
  const live = markProviderOwned(await readLiveSchema(client, { schemas: scopeFor(target, options.declared.filter((d) => d.type.startsWith("Postgres::"))) }), target.provider);
62
- return { target, byKey: new Map(live.map((o) => [liveKey(o.type, o.schema, o.name), o])) };
72
+ return { target, byKey: new Map(live.map((o) => [liveKey(o.type, o.schema, o.name, o.signature), o])) };
63
73
  } finally {
64
74
  await client.end();
65
75
  }
@@ -67,9 +77,9 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
67
77
  classifyBindFailure: (err) => classifyPostgresFailure(err),
68
78
  async read({ target, byKey }, entity): Promise<EntityObservation> {
69
79
  if (!entity.type.startsWith("Postgres::")) return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
70
- const { schema, name } = declaredAddress(entity, target.defaultSchema);
71
- const queried = `${redactUrl(target.endpoint.url)} ${schema ? `${schema}.` : ""}${name}`;
72
- const live = byKey.get(liveKey(entity.type, schema, name));
80
+ const { schema, name, signature } = declaredAddress(entity, target.defaultSchema);
81
+ const queried = `${redactUrl(target.endpoint.url)} ${schema ? `${schema}.` : ""}${name}${signature ?? ""}`;
82
+ const live = byKey.get(liveKey(entity.type, schema, name, signature));
73
83
  if (!live) return { absent: true, queried };
74
84
  const owned = !live.foreign && isChantManaged(live.comment);
75
85
  if (options.owned && !owned) {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Functions, procedures and triggers against a live server (#3680): applied
3
+ * as declared, they plan with no change and read back with no drift; a body,
4
+ * a result type and a trigger's condition changed in the declaration are
5
+ * planned with their rules and applied; a function replaced by hand is
6
+ * drift; and an import writes them back as `func`, `procedure` and
7
+ * `trigger` declarations that parse.
8
+ *
9
+ * The server: `CHANT_SQL_E2E_POSTGRES_URL` (a `postgres://user@host:port/db`
10
+ * URL, with `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running one, such as
11
+ * the one `chant emulator up --lexicon sql` starts. Without it, a throwaway
12
+ * server at the pin, skipped cleanly when Docker is not available. The test
13
+ * writes only to the schema `chant_e2e_3680`, dropped afterwards.
14
+ */
15
+
16
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
20
+ import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
21
+ import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "./client";
22
+ import { readLiveSchema } from "./catalog";
23
+ import { planPgAgainstServer } from "../plan/commands";
24
+ import { observeResourcesDeep } from "../plan/deep";
25
+ import { postgresApply } from "../../op/activities/postgres-apply";
26
+ import { objectsToIR } from "../import/ir";
27
+ import { PostgresGenerator } from "../import/generator";
28
+ import { liveProps } from "../plan/deep";
29
+ import { declaredObjects } from "../apply/apply";
30
+
31
+ const SCHEMA = "chant_e2e_3680";
32
+ const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
33
+ const enabled = given ? true : await dockerAvailable();
34
+ const dir = mkdtempSync(join(tmpdir(), "chant-pg-routines-"));
35
+ let server: TestPostgres | undefined;
36
+ let endpoint: PostgresEndpoint;
37
+ let admin: PostgresClient;
38
+
39
+ beforeAll(async () => {
40
+ if (!enabled) return;
41
+ if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
42
+ else {
43
+ server = await startTestPostgres();
44
+ endpoint = server.endpoint();
45
+ }
46
+ admin = await connectPostgres(endpoint);
47
+ await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
48
+ }, 600_000);
49
+
50
+ afterAll(async () => {
51
+ if (enabled) {
52
+ await admin?.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => undefined);
53
+ await admin?.end();
54
+ }
55
+ await server?.stop();
56
+ rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ interface Obj {
60
+ export: string;
61
+ type: string;
62
+ ddl: string;
63
+ dependsOn?: string[];
64
+ }
65
+
66
+ const MARKER = { stack: "e2e3680", env: "test" };
67
+ const profile = () => ({
68
+ config: { ownership: MARKER, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [SCHEMA] } } } },
69
+ env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
70
+ });
71
+
72
+ function writeBuild(file: string, objects: Obj[]): string {
73
+ const path = join(dir, file);
74
+ writeFileSync(path, JSON.stringify({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
75
+ return path;
76
+ }
77
+
78
+ const apply = (path: string) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(), log: () => undefined });
79
+ const plan = (path: string) => planPgAgainstServer("e2e", path, profile());
80
+
81
+ const S = SCHEMA;
82
+ const objects = (v: { body: string; totalReturns: string; when: string }): Obj[] => [
83
+ { export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` },
84
+ {
85
+ export: "orders",
86
+ type: "Postgres::Table",
87
+ dependsOn: ["app"],
88
+ ddl: `CREATE TABLE ${S}.orders (id bigint PRIMARY KEY, status text NOT NULL, amount numeric(12,2), note varchar(40), updated_at timestamptz)`,
89
+ },
90
+ {
91
+ export: "touch",
92
+ type: "Postgres::Function",
93
+ dependsOn: ["app"],
94
+ ddl: `CREATE FUNCTION ${S}.touch() RETURNS trigger LANGUAGE plpgsql SET search_path = ${S} AS $$\nBEGIN\n ${v.body}\n RETURN NEW;\nEND\n$$;\nCOMMENT ON FUNCTION ${S}.touch() IS 'Stamps updated_at'`,
95
+ },
96
+ {
97
+ export: "total",
98
+ type: "Postgres::Function",
99
+ dependsOn: ["orders"],
100
+ ddl: `CREATE FUNCTION ${S}.total(min_amount numeric DEFAULT 0, label varchar(10) = 'all') RETURNS ${v.totalReturns} LANGUAGE sql STABLE PARALLEL SAFE AS $$ SELECT coalesce(sum(amount), 0)::${v.totalReturns} FROM ${S}.orders WHERE amount >= min_amount $$`,
101
+ },
102
+ {
103
+ export: "archive",
104
+ type: "Postgres::Procedure",
105
+ dependsOn: ["orders"],
106
+ ddl: `CREATE PROCEDURE ${S}.archive(IN before_id bigint, INOUT archived int DEFAULT NULL) LANGUAGE plpgsql AS $$ BEGIN DELETE FROM ${S}.orders WHERE id < before_id; GET DIAGNOSTICS archived = ROW_COUNT; END $$`,
107
+ },
108
+ {
109
+ export: "ordersTouch",
110
+ type: "Postgres::Trigger",
111
+ dependsOn: ["orders", "touch"],
112
+ ddl: `CREATE TRIGGER orders_touch BEFORE INSERT OR UPDATE OF status, note ON ${S}.orders FOR EACH ROW WHEN (${v.when}) EXECUTE FUNCTION ${S}.touch('x', 1)`,
113
+ },
114
+ ];
115
+
116
+ const V1 = { body: "NEW.updated_at := now();", totalReturns: "numeric", when: "NEW.note IS DISTINCT FROM 'skip'" };
117
+ const V2 = { body: "NEW.updated_at := clock_timestamp();", totalReturns: "double precision", when: "NEW.note <> 'skip' AND NEW.status = 'open'" };
118
+
119
+ describe.skipIf(!enabled)("functions, procedures and triggers on a live server", () => {
120
+ test("apply, plan, drift, change and import", async () => {
121
+ const v1 = writeBuild("v1.json", objects(V1));
122
+ const first = await apply(v1);
123
+ expect(first.failed).toEqual([]);
124
+ expect(first.applied.map((a) => [a.name, a.action])).toEqual([
125
+ [S, "created"],
126
+ [`${S}.orders`, "created"],
127
+ [`${S}.touch()`, "created"],
128
+ [`${S}.total(numeric,character varying)`, "created"],
129
+ [`${S}.archive(bigint,integer)`, "created"],
130
+ [`orders_touch ON ${S}.orders`, "created"],
131
+ ]);
132
+ // The trigger fires, with its WHEN and its arguments.
133
+ await admin.query(`INSERT INTO ${S}.orders (id, status, amount) VALUES (1, 'open', 10), (2, 'open', 5)`);
134
+ expect((await admin.query<{ n: string }>(`SELECT count(*) AS n FROM ${S}.orders WHERE updated_at IS NOT NULL`))[0]!.n).toBe("2");
135
+
136
+ // As declared: no change, and no drift, the WHEN the server prints with its casts included.
137
+ expect((await plan(v1)).changes).toEqual([]);
138
+ const declared = declaredObjects(JSON.stringify({ dialect: "postgres", objects: objects(V1).map((o) => ({ dependsOn: [], ...o })) }));
139
+ const entities = new Map(declared.map((o) => [o.exportName, { entityType: o.type, props: o.props }]));
140
+ const deep = await observeResourcesDeep({ environment: "e2e", entityNames: [...entities.keys()], entities, ...profile() });
141
+ expect(deep.unobserved ?? {}).toEqual({});
142
+ const strip = (p: Record<string, unknown>) => Object.fromEntries(Object.entries(p).filter(([k]) => !["ddl", "source", "reads", "orReplace"].includes(k)));
143
+ for (const [name, e] of entities) expect(strip(deep.resources[name]!.properties), name).toEqual(strip(e.props));
144
+
145
+ // Replaced by hand: drift, in the plan and in the deep read.
146
+ await admin.query(`CREATE OR REPLACE FUNCTION ${S}.touch() RETURNS trigger LANGUAGE plpgsql SET search_path = ${S} AS $$ BEGIN RETURN NEW; END $$`);
147
+ expect((await plan(v1)).changes.map((c) => [c.object, c.field, c.rule])).toEqual([[`touch (${S}.touch())`, "body", "SQLPG280"]]);
148
+ const drifted = await observeResourcesDeep({ environment: "e2e", entityNames: ["touch"], entities, ...profile() });
149
+ expect(drifted.resources.touch!.properties.body).toBe(" BEGIN RETURN NEW; END ");
150
+
151
+ // Changed in the declaration: a body (OR REPLACE), a result type (drop and create), a trigger's condition (OR REPLACE).
152
+ const v2 = writeBuild("v2.json", objects(V2));
153
+ expect((await plan(v2)).changes.map((c) => [c.object, c.rule])).toEqual([
154
+ [`touch (${S}.touch())`, "SQLPG280"],
155
+ [`total (${S}.total(numeric,character varying))`, "SQLPG281"],
156
+ [`ordersTouch (${S}.orders_touch ON ${S}.orders)`, "SQLPG284"],
157
+ ]);
158
+ const second = await apply(v2);
159
+ expect(second.failed).toEqual([]);
160
+ expect(second.applied.filter((a) => a.action === "updated").map((a) => a.name)).toEqual([`${S}.touch()`, `${S}.total(numeric,character varying)`, `orders_touch ON ${S}.orders`]);
161
+ expect((await plan(v2)).changes).toEqual([]);
162
+ expect((await admin.query<{ c: string }>(`SELECT obj_description('${S}.total(numeric,varchar)'::regprocedure, 'pg_proc') AS c`))[0]!.c).toContain("stack=e2e3680");
163
+
164
+ // Import: the server's own printing, written back as declarations that parse.
165
+ const live = await readLiveSchema(admin, { schemas: [S] });
166
+ for (const o of live) liveProps(o);
167
+ const [file] = new PostgresGenerator().generate(objectsToIR(live.map((o) => ({ type: o.type, schema: o.schema, name: o.name, ddl: o.statement }))));
168
+ expect(file!.content).toContain("export const touch = func`");
169
+ expect(file!.content).toContain("export const archive = procedure`");
170
+ expect(file!.content).toContain("export const ordersTouch = trigger`");
171
+ expect(file!.content).toContain("EXECUTE FUNCTION ${touch}('x', '1')");
172
+ }, 600_000);
173
+ });
@@ -96,7 +96,8 @@ describe.skipIf(!enabled)("a uuid primary key", () => {
96
96
  await admin!.query("CREATE FUNCTION audit.count_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO audit.updates VALUES (NEW.id); RETURN NEW; END $$");
97
97
  await admin!.query("CREATE TRIGGER audit_update AFTER UPDATE ON uu.tokens FOR EACH ROW EXECUTE FUNCTION audit.count_update()");
98
98
  writeBuild("tokens-v2.json", [UU, tokens("integer")]);
99
- expect((await plan("tokens-v2.json")).changes.map((c) => c.rule)).toEqual(["SQLPG208"]);
99
+ // The application's own trigger is not declared, so the plan also proposes its drop (SQLPG285), which only a prune makes.
100
+ expect((await plan("tokens-v2.json")).changes.map((c) => c.rule)).toEqual(["SQLPG208", "SQLPG285"]);
100
101
 
101
102
  expect(await m.postgresMigrationPlan(args(), undefined, deps())).toMatchObject({ state: "migrate", summary: expect.stringContaining("batched by id") });
102
103
  await m.postgresMigrationExpand(args(), undefined, deps());
@@ -307,7 +307,8 @@ describe.skipIf(!enabled)("an interrupted backfill resumes from its receipts", (
307
307
  writeBuild("events-v2.json", [EV, V2]);
308
308
 
309
309
  // varchar(20) to varchar(10) is a rewrite in place (SQLPG207), which the Op makes in batches instead.
310
- expect((await plan("events-v2.json")).changes.map((c) => c.rule)).toEqual(["SQLPG207"]);
310
+ // The application's own trigger is not declared, so the plan also proposes its drop (SQLPG285), which only a prune makes.
311
+ expect((await plan("events-v2.json")).changes.map((c) => c.rule)).toEqual(["SQLPG207", "SQLPG285"]);
311
312
  expect(await m.postgresMigrationPlan(args(), undefined, deps())).toMatchObject({ state: "migrate", change: "type" });
312
313
  await m.postgresMigrationExpand(args(), undefined, deps());
313
314
  await m.postgresMigrationDualWrite(args(), undefined, deps());