@intentius/chant-lexicon-sql 0.113.0 → 0.114.1

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 (70) hide show
  1. package/dist/clickhouse/apply/apply.d.ts +6 -1
  2. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  3. package/dist/clickhouse/apply/statements.d.ts +8 -0
  4. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  5. package/dist/clickhouse/live/bind.d.ts +11 -1
  6. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/commands.d.ts +7 -1
  8. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  9. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  10. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  11. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  12. package/dist/clickhouse/plan/schema.d.ts +4 -3
  13. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/backfill.d.ts +17 -0
  15. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/observe.d.ts +4 -3
  17. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  18. package/dist/clickhouse/rebuild/receipts.d.ts +5 -0
  19. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  20. package/dist/clickhouse/rebuild/steps.d.ts +5 -3
  21. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/verify.d.ts +41 -18
  23. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  24. package/dist/clickhouse/topology.d.ts +12 -0
  25. package/dist/clickhouse/topology.d.ts.map +1 -1
  26. package/dist/clickhouse.d.ts +1 -0
  27. package/dist/clickhouse.d.ts.map +1 -1
  28. package/dist/codegen/classifier-page.d.ts.map +1 -1
  29. package/dist/config.d.ts +46 -0
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/core/statements.d.ts +2 -0
  32. package/dist/core/statements.d.ts.map +1 -1
  33. package/dist/integrity.json +2 -2
  34. package/dist/manifest.json +1 -1
  35. package/dist/migration-statements.d.ts +8 -0
  36. package/dist/migration-statements.d.ts.map +1 -1
  37. package/dist/postgres/plan/deep.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/clickhouse/apply/apply.ts +10 -3
  40. package/src/clickhouse/apply/statements.ts +19 -0
  41. package/src/clickhouse/live/bind.ts +13 -1
  42. package/src/clickhouse/live/live.test.ts +10 -0
  43. package/src/clickhouse/plan/commands.ts +42 -12
  44. package/src/clickhouse/plan/deep.ts +2 -1
  45. package/src/clickhouse/plan/diff.ts +3 -1
  46. package/src/clickhouse/plan/normalize.ts +11 -2
  47. package/src/clickhouse/plan/schema.ts +6 -5
  48. package/src/clickhouse/rebuild/backfill.ts +127 -5
  49. package/src/clickhouse/rebuild/observe.ts +4 -3
  50. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +106 -1
  51. package/src/clickhouse/rebuild/receipts.ts +14 -4
  52. package/src/clickhouse/rebuild/steps.ts +22 -9
  53. package/src/clickhouse/rebuild/verify.ts +131 -36
  54. package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
  55. package/src/clickhouse/topology.e2e.test.ts +84 -1
  56. package/src/clickhouse/topology.test.ts +33 -0
  57. package/src/clickhouse/topology.ts +26 -0
  58. package/src/clickhouse.ts +16 -0
  59. package/src/codegen/classifier-page.ts +2 -0
  60. package/src/config.ts +33 -0
  61. package/src/core/statements.ts +2 -0
  62. package/src/migration-statements.test.ts +120 -1
  63. package/src/migration-statements.ts +13 -3
  64. package/src/op/activities/clickhouse-apply.ts +1 -1
  65. package/src/op/activities/clickhouse-rebuild.ts +1 -1
  66. package/src/op/apply-gate-create.e2e.test.ts +280 -0
  67. package/src/plugin.test.ts +9 -0
  68. package/src/postgres/plan/deep.test.ts +76 -0
  69. package/src/postgres/plan/deep.ts +13 -2
  70. package/src/postgres/plan/plan.e2e.test.ts +26 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * An approval for a plan that creates an object does not survive an edit to
3
+ * that object (#3652), end to end on both dialects.
4
+ *
5
+ * For each dialect, a project in a temporary directory declares a table that
6
+ * does not exist yet and a gated `ApplyOp`. `chant run` stops at the gate and
7
+ * prints the `chant approve ... --plan <digest>` that answers it; the test
8
+ * approves that digest, adds a column to the declared table, and runs again.
9
+ * The gate refuses with "is approved, but not for this plan" and nothing is
10
+ * created. Approving the new plan then applies the edited table.
11
+ *
12
+ * Before #3652 the plan digest was taken over a diff that listed the table by
13
+ * name only, so the second run applied the edited table under the first
14
+ * approval.
15
+ *
16
+ * Everything is written to the database (ClickHouse) or schema (Postgres)
17
+ * `yodel_3652`.
18
+ *
19
+ * The servers: `CHANT_SQL_GATE_CLICKHOUSE_URL` and
20
+ * `CHANT_SQL_GATE_POSTGRES_URL` (a `postgres://user@host:port/db` URL, with
21
+ * `CHANT_SQL_GATE_POSTGRES_PASSWORD`) name running ones, such as the servers
22
+ * `chant emulator up --lexicon sql` starts. Without them, each dialect starts
23
+ * a throwaway server at the same pin, and skips cleanly when Docker is not
24
+ * available.
25
+ */
26
+
27
+ import { execFile } from "node:child_process";
28
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { promisify } from "node:util";
32
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
33
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "../clickhouse/container";
34
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../clickhouse/http";
35
+ import { clickhouseImage } from "../spec/pin";
36
+ import { startTestPostgres, type TestPostgres } from "../postgres/testing/server";
37
+ import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../postgres/live/client";
38
+
39
+ const exec = promisify(execFile);
40
+
41
+ const REPO = join(import.meta.dirname, "../../../..");
42
+ const BIN = join(REPO, "node_modules/.bin");
43
+ const NAME = "yodel_3652";
44
+ const OP = "schema-apply";
45
+
46
+ const givenClickHouse = process.env.CHANT_SQL_GATE_CLICKHOUSE_URL;
47
+ const givenPostgres = process.env.CHANT_SQL_GATE_POSTGRES_URL;
48
+ const docker = givenClickHouse && givenPostgres ? false : await dockerAvailable();
49
+
50
+ interface Run {
51
+ code: number;
52
+ output: string;
53
+ }
54
+
55
+ const strip = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, "");
56
+
57
+ /**
58
+ * A project directory chant can run in: the repository's `node_modules`
59
+ * linked in, so `@intentius/chant` and the sql lexicon resolve, and a git
60
+ * repository, which the gate ledger and snapshots are written to.
61
+ */
62
+ async function project(files: Record<string, string>, env: Record<string, string>) {
63
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-gate-"));
64
+ symlinkSync(join(REPO, "node_modules"), join(dir, "node_modules"), "dir");
65
+ const write = (all: Record<string, string>) => {
66
+ for (const [path, text] of Object.entries(all)) {
67
+ mkdirSync(join(dir, path, ".."), { recursive: true });
68
+ writeFileSync(join(dir, path), text);
69
+ }
70
+ };
71
+ write({
72
+ "package.json": JSON.stringify({ name: "sql-gate-e2e", private: true, type: "module", scripts: { build: "chant build src --lexicon sql -o dist/schema.json" } }),
73
+ ".gitignore": "node_modules\ndist\n.chant\n",
74
+ ...files,
75
+ });
76
+ const git = (...args: string[]) => exec("git", ["-c", "user.email=e2e@chant.invalid", "-c", "user.name=e2e", ...args], { cwd: dir });
77
+ await git("init", "-q");
78
+ await git("add", "-A");
79
+ await git("commit", "-qm", "the schema");
80
+ const chant = async (...args: string[]): Promise<Run> => {
81
+ try {
82
+ const r = await exec(join(BIN, "chant"), args, {
83
+ cwd: dir,
84
+ env: { ...process.env, ...env, PATH: `${BIN}:${process.env.PATH ?? ""}` },
85
+ maxBuffer: 16 * 1024 * 1024,
86
+ });
87
+ return { code: 0, output: strip(`${r.stdout}${r.stderr}`) };
88
+ } catch (err) {
89
+ const e = err as { code?: number; stdout?: string; stderr?: string };
90
+ return { code: typeof e.code === "number" ? e.code : 1, output: strip(`${e.stdout ?? ""}${e.stderr ?? ""}`) };
91
+ }
92
+ };
93
+ return {
94
+ dir,
95
+ chant,
96
+ async edit(path: string, text: string) {
97
+ write({ [path]: text });
98
+ await git("commit", "-qam", `edit ${path}`);
99
+ },
100
+ };
101
+ }
102
+
103
+ /** The `--plan <digest>` the stopped run printed in its approve command. */
104
+ function approvedPlan(run: Run): string {
105
+ const m = /chant approve \S+ \S+ .*?--plan (jcs1-sha256:[0-9a-f]{64})/.exec(run.output);
106
+ if (!m) throw new Error(`the run printed no approve command with a plan:\n${run.output}`);
107
+ return m[1];
108
+ }
109
+
110
+ function gateOf(run: Run): string {
111
+ const m = /chant approve \S+ (\S+)/.exec(run.output);
112
+ if (!m) throw new Error(`the run printed no approve command:\n${run.output}`);
113
+ return m[1];
114
+ }
115
+
116
+ const OPS = (target: string) => ({
117
+ "ops/apply.op.ts": `import { ApplyOp } from "@intentius/chant/op";
118
+ const { op } = ApplyOp({ name: "${OP}", env: "admin", target: "${target}", gate: { gate: "approve-schema" } });
119
+ export default op;
120
+ `,
121
+ });
122
+
123
+ const config = (sql: Record<string, unknown>) => `import type { ChantConfig } from "@intentius/chant";
124
+ import "@intentius/chant-lexicon-sql";
125
+
126
+ export default {
127
+ lexicons: ["sql"],
128
+ ownership: { stack: "yodel-3652", env: "admin" },
129
+ sql: ${JSON.stringify(sql, null, 2)},
130
+ } satisfies ChantConfig;
131
+ `;
132
+
133
+ /**
134
+ * The shared scenario: approve a create, edit the declared object, rerun.
135
+ * `columns` reads the live table's column names.
136
+ */
137
+ async function approveEditRerun(
138
+ p: Awaited<ReturnType<typeof project>>,
139
+ edited: { path: string; text: string },
140
+ columns: () => Promise<string[]>,
141
+ ): Promise<void> {
142
+ const first = await p.chant("run", OP);
143
+ expect(first.output).toMatch(/MISSING \(declared, provider reports not in cloud\):[\s\S]*- events .*\[definition [0-9a-f]{64}\]/);
144
+ const gate = gateOf(first);
145
+ const digest = approvedPlan(first);
146
+
147
+ const approve = await p.chant("approve", OP, gate, "--plan", digest);
148
+ expect(approve.code, approve.output).toBe(0);
149
+
150
+ await p.edit(edited.path, edited.text);
151
+
152
+ const second = await p.chant("run", OP);
153
+ expect(second.output).toContain(`Gate "${gate}" is approved, but not for this plan.`);
154
+ expect(second.output).toContain(`approved: ${digest}`);
155
+ expect(second.output).not.toMatch(/Op "schema-apply" completed/);
156
+ expect(await columns()).toEqual([]);
157
+
158
+ // Approving the plan the second run produced applies the edited table.
159
+ const fresh = approvedPlan(second);
160
+ expect(fresh).not.toBe(digest);
161
+ const reapprove = await p.chant("approve", OP, gate, "--plan", fresh);
162
+ expect(reapprove.code, reapprove.output).toBe(0);
163
+ const third = await p.chant("run", OP);
164
+ expect(third.output).toMatch(/Op "schema-apply" completed/);
165
+ expect(await columns()).toContain("note");
166
+ }
167
+
168
+ // ── ClickHouse ──────────────────────────────────────────────────────────────
169
+
170
+ const CH_SCHEMA = (extra: string) => `import { database, table } from "@intentius/chant-lexicon-sql/clickhouse";
171
+
172
+ export const db = database\`CREATE DATABASE ${NAME} ENGINE = Atomic COMMENT 'gate binds creates'\`;
173
+
174
+ export const events = table\`
175
+ CREATE TABLE \${db}.events (
176
+ user_id UInt64,
177
+ ts DateTime${extra}
178
+ )
179
+ ENGINE = MergeTree
180
+ ORDER BY (user_id, ts)
181
+ COMMENT 'Raw events'\`;
182
+ `;
183
+
184
+ describe.skipIf(!givenClickHouse && !docker)("a gated ApplyOp over ClickHouse refuses an edited create (#3652)", () => {
185
+ let server: ScratchServer | undefined;
186
+ let admin: ClickHouseEndpoint;
187
+ let p: Awaited<ReturnType<typeof project>>;
188
+ const q = (sql: string) => clickhouseQuery(admin, sql);
189
+
190
+ beforeAll(async () => {
191
+ if (givenClickHouse) admin = { url: givenClickHouse };
192
+ else {
193
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-gate" });
194
+ admin = server.endpoint;
195
+ }
196
+ await q(`DROP DATABASE IF EXISTS ${NAME} SYNC`);
197
+ p = await project(
198
+ {
199
+ ...OPS("clickhouse"),
200
+ "chant.config.ts": config({ dialect: "clickhouse", profiles: { admin: { url: admin.url, databases: [NAME] } } }),
201
+ "src/schema.ts": CH_SCHEMA(""),
202
+ },
203
+ {},
204
+ );
205
+ }, 600_000);
206
+
207
+ afterAll(async () => {
208
+ if (admin) await q(`DROP DATABASE IF EXISTS ${NAME} SYNC`).catch(() => undefined);
209
+ await server?.stop();
210
+ if (p) rmSync(p.dir, { recursive: true, force: true });
211
+ });
212
+
213
+ test("approve a create, add a column, rerun: the gate refuses", async () => {
214
+ await approveEditRerun(p, { path: "src/schema.ts", text: CH_SCHEMA(",\n note String") }, async () => {
215
+ const rows = await clickhouseQuery<{ name: string }>(admin, `SELECT name FROM system.columns WHERE database = '${NAME}' AND table = 'events' ORDER BY position`);
216
+ return rows.map((r) => r.name);
217
+ });
218
+ }, 600_000);
219
+ });
220
+
221
+ // ── Postgres ────────────────────────────────────────────────────────────────
222
+
223
+ const PG_SCHEMA = (extra: string) => `import { schema, table } from "@intentius/chant-lexicon-sql/postgres";
224
+
225
+ export const app = schema\`CREATE SCHEMA ${NAME};
226
+ COMMENT ON SCHEMA ${NAME} IS 'gate binds creates'\`;
227
+
228
+ export const events = table\`
229
+ CREATE TABLE \${app}.events (
230
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
231
+ kind text NOT NULL${extra}
232
+ )\`;
233
+ `;
234
+
235
+ describe.skipIf(!givenPostgres && !docker)("a gated ApplyOp over Postgres refuses an edited create (#3652)", () => {
236
+ let server: TestPostgres | undefined;
237
+ let endpoint: PostgresEndpoint;
238
+ let admin: PostgresClient;
239
+ let p: Awaited<ReturnType<typeof project>>;
240
+ const q = (sql: string) => admin.query(sql);
241
+
242
+ beforeAll(async () => {
243
+ if (givenPostgres) endpoint = { url: givenPostgres, password: process.env.CHANT_SQL_GATE_POSTGRES_PASSWORD };
244
+ else {
245
+ server = await startTestPostgres();
246
+ endpoint = server.endpoint();
247
+ }
248
+ admin = await connectPostgres(endpoint);
249
+ await q(`DROP SCHEMA IF EXISTS ${NAME} CASCADE`);
250
+ p = await project(
251
+ {
252
+ ...OPS("postgres"),
253
+ "chant.config.ts": config({
254
+ dialect: "postgres",
255
+ profiles: { admin: { url: endpoint.url, password: { env: "ADMIN_PASSWORD" }, schemas: [NAME] } },
256
+ }),
257
+ "src/schema.ts": PG_SCHEMA(""),
258
+ },
259
+ { ADMIN_PASSWORD: endpoint.password ?? "" },
260
+ );
261
+ }, 600_000);
262
+
263
+ afterAll(async () => {
264
+ if (admin) {
265
+ await q(`DROP SCHEMA IF EXISTS ${NAME} CASCADE`).catch(() => undefined);
266
+ await admin.end();
267
+ }
268
+ await server?.stop();
269
+ if (p) rmSync(p.dir, { recursive: true, force: true });
270
+ });
271
+
272
+ test("approve a create, add a column, rerun: the gate refuses", async () => {
273
+ await approveEditRerun(p, { path: "src/schema.ts", text: PG_SCHEMA(",\n note text") }, async () => {
274
+ const rows = await admin.query<{ column_name: string }>(
275
+ `SELECT column_name FROM information_schema.columns WHERE table_schema = '${NAME}' AND table_name = 'events' ORDER BY ordinal_position`,
276
+ );
277
+ return rows.map((r) => r.column_name);
278
+ });
279
+ }, 600_000);
280
+ });
@@ -39,6 +39,15 @@ describe("the sql config namespace", () => {
39
39
  expect(sqlConfigSchema.safeParse({ provider: "heroku" }).success).toBe(false);
40
40
  });
41
41
 
42
+ test("a profile takes a ClickHouse topology in its string or object form (#3645)", () => {
43
+ for (const topology of ["single", "cluster:main", "cluster:{cluster}", "replicated", "replicated:all", "cloud", { kind: "cluster", cluster: "main", replicaPath: "/ch/{shard}/{table}" }, { kind: "replicated" }]) {
44
+ expect(sqlConfigSchema.safeParse({ profiles: { prod: { url: "http://x:8123", topology } } }).success, JSON.stringify(topology)).toBe(true);
45
+ }
46
+ for (const topology of ["cluster", "sharded", { kind: "cluster" }, { kind: "single", cluster: "x" }]) {
47
+ expect(sqlConfigSchema.safeParse({ profiles: { prod: { url: "http://x:8123", topology } } }).success, JSON.stringify(topology)).toBe(false);
48
+ }
49
+ });
50
+
42
51
  test("accepts a list of dialects", () => {
43
52
  expect(sqlConfigSchema.safeParse({ dialect: ["clickhouse"] }).success).toBe(true);
44
53
  expect(sqlConfigSchema.safeParse({ dialect: ["clickhouse", "postgres"] }).success).toBe(true);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The Postgres deep reader's columns (INTENTIUS/sql-yodeler#47): matched by
3
+ * name, not by position, because Postgres keeps a table's columns in the
4
+ * order they were added and a column renamed by expand and contract is last
5
+ * on the server.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import * as pg from "../entities";
10
+ import { POSTGRES_ENTITY_TYPES } from "../entity-types";
11
+ import type { LivePgObject } from "../live/catalog";
12
+ import { inDeclaredVocabulary, liveProps } from "./deep";
13
+ import { canonicalPgObject } from "./normalize";
14
+
15
+ type Props = Record<string, unknown>;
16
+
17
+ const declared = (pg.table`
18
+ CREATE TABLE shop.orders (
19
+ id bigint GENERATED ALWAYS AS IDENTITY,
20
+ email text NOT NULL, -- previously: customer_email
21
+ status text DEFAULT 'placed'::text NOT NULL,
22
+ amount numeric(12,2) NOT NULL,
23
+ refunded_at timestamp with time zone,
24
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
25
+ )` as unknown as { props: Props }).props;
26
+
27
+ /** What the deep reader makes of the server's definition of the table. */
28
+ function observe(statement: string): Props {
29
+ const o = { type: POSTGRES_ENTITY_TYPES.table, schema: "shop", name: "orders", oid: "1", statement } as unknown as LivePgObject;
30
+ const live = liveProps(o);
31
+ return inDeclaredVocabulary(declared, live, canonicalPgObject(POSTGRES_ENTITY_TYPES.table, declared), canonicalPgObject(POSTGRES_ENTITY_TYPES.table, live));
32
+ }
33
+
34
+ const names = (p: Props) => (p.columns as Array<{ name: string }>).map((c) => c.name);
35
+
36
+ describe("Postgres deep observation of a table's columns", () => {
37
+ test("a column the server holds last and the declaration earlier is not drift", () => {
38
+ const props = observe(`CREATE TABLE shop.orders (
39
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
40
+ status text DEFAULT 'placed'::text NOT NULL,
41
+ amount numeric(12,2) NOT NULL,
42
+ refunded_at timestamp with time zone,
43
+ email text NOT NULL,
44
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
45
+ )`);
46
+ expect(props.columns).toEqual(declared.columns);
47
+ });
48
+
49
+ test("a column that differs is reported at its declared place, and an undeclared one comes after the declared ones", () => {
50
+ const props = observe(`CREATE TABLE shop.orders (
51
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
52
+ note text,
53
+ status text DEFAULT 'placed'::text NOT NULL,
54
+ amount numeric(12,2) NOT NULL,
55
+ refunded_at timestamp with time zone,
56
+ email character varying(200) NOT NULL,
57
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
58
+ )`);
59
+ expect(names(props)).toEqual(["id", "email", "status", "amount", "refunded_at", "note"]);
60
+ const columns = props.columns as Props[];
61
+ const declaredColumns = declared.columns as Props[];
62
+ expect(columns[1]).not.toEqual(declaredColumns[1]);
63
+ expect(columns.slice(2, 5)).toEqual(declaredColumns.slice(2, 5));
64
+ });
65
+
66
+ test("a declared column the server does not have leaves the others matched by name", () => {
67
+ const props = observe(`CREATE TABLE shop.orders (
68
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
69
+ status text DEFAULT 'placed'::text NOT NULL,
70
+ amount numeric(12,2) NOT NULL,
71
+ email text NOT NULL,
72
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
73
+ )`);
74
+ expect(names(props)).toEqual(["id", "email", "status", "amount"]);
75
+ });
76
+ });
@@ -108,11 +108,22 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalP
108
108
  if (d.kind === "table") {
109
109
  const declaredColumns = (declared.columns as unknown[] | undefined) ?? [];
110
110
  const liveColumns = (live.columns as unknown[] | undefined) ?? [];
111
- out.columns = liveColumns.map((c, i) => {
111
+ // Columns are matched by name and listed in the declaration's order, the
112
+ // live columns the declaration does not name after them. Postgres keeps a
113
+ // table's columns in the order they were added, and no ALTER moves one: a
114
+ // column renamed by expand and contract is last on the server and where
115
+ // the old one was in the declaration (INTENTIUS/sql-yodeler#47). The plan
116
+ // compares columns by name too, so an order the server cannot be brought
117
+ // to is not drift.
118
+ const read = liveColumns.map((c, i) => {
112
119
  const lc = l.columns[i];
113
120
  const dc = lc ? d.columns.find((x) => x.name === lc.name) : undefined;
114
- return lc && dc && columnShape(lc) === columnShape(dc) ? declaredColumns[dc.position] : c;
121
+ return { at: dc?.position, value: lc && dc && columnShape(lc) === columnShape(dc) ? declaredColumns[dc.position] : c };
115
122
  });
123
+ out.columns = [
124
+ ...read.filter((c) => c.at !== undefined).sort((a, b) => a.at! - b.at!),
125
+ ...read.filter((c) => c.at === undefined),
126
+ ].map((c) => c.value);
116
127
  }
117
128
  if (d.kind === "table" || d.kind === "domain") {
118
129
  // A constraint the declaration says the same as the server is the declaration's (its name included or left out).
@@ -91,6 +91,32 @@ describe.skipIf(!enabled)("planning the getting-started example against a server
91
91
  expect((await planPgAgainstServer("e2e", file, profile("star"))).changes.map((c) => `${c.field}: ${c.before} -> ${c.after}`)).toEqual([]);
92
92
  }, 600_000);
93
93
 
94
+ test("a column renamed by expand and contract, last on the server, plans and reads back with no drift (sql-yodeler#47)", async () => {
95
+ const admin = await server!.connect();
96
+ await admin.query("CREATE DATABASE renamed");
97
+ await admin.end();
98
+ const orders = pg.table`CREATE TABLE public.orders (
99
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
100
+ email text NOT NULL, -- previously: customer_email
101
+ status text DEFAULT 'placed'::text NOT NULL,
102
+ refunded_at timestamp with time zone
103
+ )`;
104
+ const out = sqlSerializer.serialize(new Map<string, unknown>([["orders", orders]]) as never) as SerializerResult;
105
+ const file = join(dir, "renamed.json");
106
+ writeFileSync(file, out.primary);
107
+ const client = await server!.connect("renamed");
108
+ await client.query(
109
+ "CREATE TABLE public.orders (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_email text NOT NULL, status text DEFAULT 'placed'::text NOT NULL, refunded_at timestamp with time zone);" +
110
+ "ALTER TABLE public.orders ADD COLUMN email text; UPDATE public.orders SET email = customer_email; ALTER TABLE public.orders ALTER COLUMN email SET NOT NULL; ALTER TABLE public.orders DROP COLUMN customer_email",
111
+ );
112
+ expect((await planPgAgainstServer("e2e", file, profile("renamed"))).changes).toEqual([]);
113
+
114
+ const props = (orders as unknown as { props: Record<string, unknown> }).props;
115
+ const deep = await observeResourcesDeep({ environment: "e2e", entityNames: ["orders"], entities: new Map([["orders", { entityType: "Postgres::Table", props }]]), ...profile("renamed") });
116
+ expect(deep.resources.orders!.properties.columns).toEqual(props.columns);
117
+ await client.end();
118
+ }, 600_000);
119
+
94
120
  test("a serial table plans with no changes after apply; serial to bigserial is integer to bigint, unqualified", async () => {
95
121
  const admin = await server!.connect();
96
122
  await admin.query("CREATE DATABASE serials");