@intentius/chant-lexicon-sql 0.117.0 → 0.118.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.
- package/dist/clickhouse/access.d.ts +132 -0
- package/dist/clickhouse/access.d.ts.map +1 -0
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +2 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +171 -2
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/http.d.ts +3 -0
- package/dist/clickhouse/http.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +28 -0
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/ir.d.ts.map +1 -1
- package/dist/clickhouse/import/parser.d.ts +3 -3
- package/dist/clickhouse/import/parser.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +41 -6
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -2
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +54 -3
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +3 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts +12 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +61 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +7 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +8 -4
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +7 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +8 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +48 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +50 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/core/references.d.ts +4 -2
- package/dist/core/references.d.ts.map +1 -1
- package/dist/core/statements.d.ts +11 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/files/build-root.d.ts +34 -0
- package/dist/files/build-root.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +19 -0
- package/dist/files/clickhouse.d.ts.map +1 -0
- package/dist/files/common.d.ts +25 -0
- package/dist/files/common.d.ts.map +1 -0
- package/dist/files/postgres.d.ts +36 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/import-parser.d.ts +25 -0
- package/dist/import-parser.d.ts.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +6 -6
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlch002.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +30 -0
- package/dist/okf/index.md +7 -1
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/types/Dictionary.md +9 -0
- package/dist/okf/types/Function.md +9 -0
- package/dist/okf/types/Grant.md +9 -0
- package/dist/okf/types/Role.md +9 -0
- package/dist/okf/types/RowPolicy.md +9 -0
- package/dist/okf/types/User.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +9 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +68 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +33 -0
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +3 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/templates.ts +2 -2
- package/dist/serializer.d.ts +2 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sql-files.d.ts +79 -0
- package/dist/sql-files.d.ts.map +1 -0
- package/dist/token-source.d.ts +88 -0
- package/dist/token-source.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.test.ts +203 -0
- package/src/clickhouse/access.ts +595 -0
- package/src/clickhouse/apply/apply.ts +29 -20
- package/src/clickhouse/apply/statements.ts +105 -8
- package/src/clickhouse/dictionary.test.ts +177 -0
- package/src/clickhouse/entities.ts +356 -6
- package/src/clickhouse/function.test.ts +91 -0
- package/src/clickhouse/http.ts +11 -2
- package/src/clickhouse/import/generator.ts +81 -46
- package/src/clickhouse/import/import.test.ts +9 -3
- package/src/clickhouse/import/ir.ts +6 -2
- package/src/clickhouse/import/parser.ts +10 -18
- package/src/clickhouse/live/access.e2e.test.ts +218 -0
- package/src/clickhouse/live/bind.ts +17 -6
- package/src/clickhouse/live/catalog.ts +106 -10
- package/src/clickhouse/live/describe-resources.ts +23 -10
- package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
- package/src/clickhouse/live/functions.e2e.test.ts +155 -0
- package/src/clickhouse/live/live.test.ts +2 -2
- package/src/clickhouse/parser.ts +136 -4
- package/src/clickhouse/plan/commands.ts +18 -7
- package/src/clickhouse/plan/deep.ts +71 -11
- package/src/clickhouse/plan/diff.ts +65 -2
- package/src/clickhouse/plan/normalize.ts +200 -6
- package/src/clickhouse/plan/report.ts +14 -3
- package/src/clickhouse/plan/rules.ts +44 -1
- package/src/clickhouse/plan/schema.ts +40 -17
- package/src/clickhouse/plan/server-format.ts +1 -1
- package/src/clickhouse/rebuild/backfill.ts +5 -25
- package/src/clickhouse/rebuild/observe.ts +7 -3
- package/src/clickhouse/rebuild/op.ts +9 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
- package/src/clickhouse/rebuild/steps.ts +136 -1
- package/src/clickhouse/testing/fake-server.ts +2 -0
- package/src/clickhouse/testing/writable-server.ts +2 -0
- package/src/clickhouse/topology.ts +17 -2
- package/src/clickhouse.ts +19 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/codegen/registry.ts +6 -0
- package/src/config.ts +12 -3
- package/src/core/references.ts +5 -3
- package/src/core/statements.ts +11 -1
- package/src/files/build-root.ts +74 -0
- package/src/files/clickhouse.ts +52 -0
- package/src/files/common.ts +54 -0
- package/src/files/postgres.ts +371 -0
- package/src/generated/lexicon-sql.json +30 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +15 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +1 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activity-contracts.ts +1 -0
- package/src/plugin.ts +29 -8
- package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
- package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
- package/src/postgres/apply/statements.test.ts +138 -6
- package/src/postgres/apply/statements.ts +156 -10
- package/src/postgres/import/generator.ts +118 -45
- package/src/postgres/live/bind.ts +17 -6
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +5 -5
- package/src/serializer.ts +14 -9
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-rebuild.md +3 -2
- package/src/skills/chant-sql.md +7 -1
- package/src/sql-files.test.ts +220 -0
- package/src/sql-files.ts +218 -0
- package/src/token-source.e2e.test.ts +142 -0
- package/src/token-source.test.ts +119 -0
- package/src/token-source.ts +189 -0
- package/src/validate.ts +6 -0
|
@@ -150,6 +150,31 @@ const PG_AFTER = build("postgres", [APP, USERS_V2, EMAIL_IDX, ORDERS_RENAMED], {
|
|
|
150
150
|
describe("Postgres", () => {
|
|
151
151
|
const doc = diffStatements(PG_BEFORE, PG_AFTER, { marker: MARKER, env: "prod" });
|
|
152
152
|
|
|
153
|
+
test("a unique index on a table that exists carries its duplicate count, one on a new table none (#3686)", () => {
|
|
154
|
+
const unique: Obj = { export: "usersNick", type: "Postgres::Index", dependsOn: ["users"], ddl: "CREATE UNIQUE INDEX CONCURRENTLY users_nick_key ON app.users (nick) WHERE nick <> ''" };
|
|
155
|
+
const d = diffStatements(build("postgres", [APP, USERS]), build("postgres", [APP, USERS, unique]));
|
|
156
|
+
const built = d.steps.find((s): s is StatementStep => s.kind === "statement" && s.sql.startsWith("CREATE UNIQUE INDEX"));
|
|
157
|
+
expect(built?.precheck).toEqual({
|
|
158
|
+
sql: "SELECT (SELECT count(*) FROM (SELECT 1 FROM app.users WHERE nick IS NOT NULL AND (nick <> '') GROUP BY nick HAVING count(*) > 1) AS duplicates) AS n",
|
|
159
|
+
detail: "values of (nick) held by more than one row, which a unique index fails on",
|
|
160
|
+
});
|
|
161
|
+
expect(renderStatements(d)).toContain(`-- pre-check, must return 0 (values of (nick) held by more than one row, which a unique index fails on): ${built!.precheck!.sql}`);
|
|
162
|
+
const fresh = diffStatements(build("postgres", [APP]), build("postgres", [APP, USERS, unique]));
|
|
163
|
+
expect(fresh.steps.some((s) => s.kind === "statement" && s.precheck !== undefined)).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("SET NOT NULL renders as its four statements, the first with the NULL count (#3686)", () => {
|
|
167
|
+
const d = diffStatements(build("postgres", [APP, USERS]), build("postgres", [APP, { ...USERS, ddl: USERS.ddl.replace("nick text", "nick text NOT NULL") }]));
|
|
168
|
+
const st = d.steps.filter((s): s is StatementStep => s.kind === "statement");
|
|
169
|
+
expect(st.map((s) => [s.sql, s.rule, s.class])).toEqual([
|
|
170
|
+
["ALTER TABLE app.users ADD CONSTRAINT nick__chant_nn CHECK (nick IS NOT NULL) NOT VALID", "SQLPG217", "metadata"],
|
|
171
|
+
["ALTER TABLE app.users VALIDATE CONSTRAINT nick__chant_nn", "SQLPG220", "validate"],
|
|
172
|
+
["ALTER TABLE app.users ALTER COLUMN nick SET NOT NULL", "SQLPG210", "metadata"],
|
|
173
|
+
["ALTER TABLE app.users DROP CONSTRAINT nick__chant_nn", "SQLPG223", "metadata"],
|
|
174
|
+
]);
|
|
175
|
+
expect(st[0]!.precheck?.sql).toBe("SELECT count(*) AS n FROM app.users WHERE nick IS NULL");
|
|
176
|
+
});
|
|
177
|
+
|
|
153
178
|
test("the statements are ordered, each with its change's rule and class and whether it may run in a transaction", () => {
|
|
154
179
|
expect(doc).toMatchObject({ dialect: "postgres", defaultSchema: "public", major: 18 });
|
|
155
180
|
expect(doc.steps.filter((s): s is StatementStep => s.kind === "statement").map((s) => [s.object, s.sql, s.rule, s.class, s.transactional])).toEqual([
|
|
@@ -234,6 +234,7 @@ function postgresStatements(beforeJson: string, afterJson: string, options: Diff
|
|
|
234
234
|
class: s.class,
|
|
235
235
|
transactional: s.transactional,
|
|
236
236
|
...(s.rule === "SQLPG204" || isDestructive(entry.changes, s.rule) ? { destructive: true } : {}),
|
|
237
|
+
...(s.precheck ? { precheck: { sql: s.precheck.sql, detail: s.precheck.detail } } : {}),
|
|
237
238
|
});
|
|
238
239
|
}
|
|
239
240
|
}
|
|
@@ -71,6 +71,7 @@ export async function rebuildRun(args: ClickHouseRebuildArgs, signal: AbortSigna
|
|
|
71
71
|
retainMs: parseDuration(args.retain ?? "7d"),
|
|
72
72
|
...(args.mutationTimeout ? { mutationTimeoutMs: parseDuration(args.mutationTimeout) } : {}),
|
|
73
73
|
...(args.replicaTimeout ? { replicaTimeoutMs: parseDuration(args.replicaTimeout) } : {}),
|
|
74
|
+
...(args.cutoverTimeout ? { cutoverTimeoutMs: parseDuration(args.cutoverTimeout) } : {}),
|
|
74
75
|
log: deps.log ?? ((line: string) => console.log(line)),
|
|
75
76
|
...(signal ? { signal } : {}),
|
|
76
77
|
...(runId ? { runId } : {}),
|
|
@@ -31,6 +31,7 @@ const rebuildArgs = z.strictObject({
|
|
|
31
31
|
retain: z.string().optional(),
|
|
32
32
|
mutationTimeout: z.string().optional(),
|
|
33
33
|
replicaTimeout: z.string().optional(),
|
|
34
|
+
cutoverTimeout: z.string().optional(),
|
|
34
35
|
stack: z.string().optional(),
|
|
35
36
|
ownershipEnv: z.string().optional(),
|
|
36
37
|
cwd: z.string().optional(),
|
package/src/plugin.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { completions } from "./lsp/completions";
|
|
|
18
18
|
import { hover } from "./lsp/hover";
|
|
19
19
|
import { sqlMcpResources, sqlMcpTools } from "./mcp";
|
|
20
20
|
import { sqlConfigSchema } from "./config";
|
|
21
|
-
import {
|
|
21
|
+
import { SqlFileParser, dialectOption } from "./import-parser";
|
|
22
22
|
import { sqlTemplateGenerator } from "./import-generator";
|
|
23
23
|
import { sqlCommands } from "./clickhouse/plan/commands";
|
|
24
24
|
import { sqlDeepNormalizationHooks } from "./clickhouse/plan/deep";
|
|
@@ -97,18 +97,20 @@ export const sqlPlugin: LexiconPlugin = {
|
|
|
97
97
|
isTag: true,
|
|
98
98
|
description: "A CREATE VIEW or CREATE MATERIALIZED VIEW, parsed into a view entity with its lineage",
|
|
99
99
|
},
|
|
100
|
+
{ name: "dictionary", isTag: true, description: "A ClickHouse CREATE DICTIONARY, parsed into a dictionary entity with its attributes, key, source and layout" },
|
|
101
|
+
{ name: "user", isTag: true, description: "A ClickHouse CREATE USER, parsed into a user entity; its password is the environment's" },
|
|
100
102
|
{ name: "schema", isTag: true, description: "A Postgres CREATE SCHEMA, parsed into a schema entity" },
|
|
101
103
|
{ name: "index", isTag: true, description: "A Postgres CREATE INDEX, parsed into an index entity" },
|
|
102
104
|
{ name: "sequence", isTag: true, description: "A Postgres CREATE SEQUENCE, parsed into a sequence entity" },
|
|
103
105
|
{ name: "type", isTag: true, description: "A Postgres CREATE TYPE ... AS ENUM, parsed into an enum entity" },
|
|
104
106
|
{ name: "domain", isTag: true, description: "A Postgres CREATE DOMAIN, parsed into a domain entity" },
|
|
105
107
|
{ name: "extension", isTag: true, description: "A Postgres CREATE EXTENSION, parsed into an extension entity" },
|
|
106
|
-
{ name: "func", isTag: true, description: "A Postgres CREATE FUNCTION, parsed into a function entity with its body kept verbatim" },
|
|
108
|
+
{ name: "func", isTag: true, description: "A Postgres CREATE FUNCTION, parsed into a function entity with its body kept verbatim, or a ClickHouse CREATE FUNCTION (a named lambda)" },
|
|
107
109
|
{ name: "procedure", isTag: true, description: "A Postgres CREATE PROCEDURE, parsed into a procedure entity with its body kept verbatim" },
|
|
108
110
|
{ name: "trigger", isTag: true, description: "A Postgres CREATE TRIGGER, parsed into a trigger entity on its table" },
|
|
109
|
-
{ name: "policy", isTag: true, description: "A Postgres CREATE POLICY, parsed into a row-level security policy on its table" },
|
|
110
|
-
{ name: "role", isTag: true, description: "A Postgres CREATE ROLE without a password or memberships, parsed into a role entity" },
|
|
111
|
-
{ name: "grant", isTag: true, description: "A Postgres GRANT, REVOKE or ALTER DEFAULT PRIVILEGES, parsed into the privileges it declares" },
|
|
111
|
+
{ name: "policy", isTag: true, description: "A Postgres CREATE POLICY, parsed into a row-level security policy on its table, or a ClickHouse CREATE ROW POLICY" },
|
|
112
|
+
{ name: "role", isTag: true, description: "A Postgres CREATE ROLE without a password or memberships, parsed into a role entity, or a ClickHouse CREATE ROLE" },
|
|
113
|
+
{ name: "grant", isTag: true, description: "A Postgres GRANT, REVOKE or ALTER DEFAULT PRIVILEGES, parsed into the privileges it declares, or a ClickHouse GRANT of privileges or roles" },
|
|
112
114
|
{
|
|
113
115
|
name: "literal",
|
|
114
116
|
isTag: false,
|
|
@@ -155,9 +157,28 @@ export const sqlPlugin: LexiconPlugin = {
|
|
|
155
157
|
return sqlMcpResources();
|
|
156
158
|
},
|
|
157
159
|
|
|
158
|
-
/** `chant import schema.sql`: a file of ClickHouse
|
|
159
|
-
templateParser() {
|
|
160
|
-
return new
|
|
160
|
+
/** `chant import schema.sql`: a file of Postgres or ClickHouse DDL (`./import-parser.ts`). */
|
|
161
|
+
templateParser(options) {
|
|
162
|
+
return new SqlFileParser(dialectOption(options));
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
parserOptions() {
|
|
166
|
+
return [
|
|
167
|
+
{
|
|
168
|
+
name: "dialect",
|
|
169
|
+
type: "string" as const,
|
|
170
|
+
description: "the DDL's dialect, clickhouse or postgres; read off its statements when omitted",
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The `.sql` files in the source directory, read into declarations beside
|
|
177
|
+
* the tagged templates (`./files/build-root.ts`, #3646).
|
|
178
|
+
*/
|
|
179
|
+
async buildRoots(ctx) {
|
|
180
|
+
const { sqlFilesBuildRoot } = await import("./files/build-root");
|
|
181
|
+
return sqlFilesBuildRoot(ctx);
|
|
161
182
|
},
|
|
162
183
|
|
|
163
184
|
/** One generator for both dialects: each IR resource's type says whose declarations it is. */
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CHECK or a foreign key added to a table that exists, against a live
|
|
3
|
+
* server (#3703).
|
|
4
|
+
*
|
|
5
|
+
* - The applier adds each one NOT VALID, then validates it in a transaction
|
|
6
|
+
* of its own, and both end valid.
|
|
7
|
+
* - Step by step, each statement's transaction held open: a concurrent
|
|
8
|
+
* writer is not blocked while VALIDATE reads the 200,000 rows, and the
|
|
9
|
+
* probe is shown to detect locks by being blocked during the NOT VALID add.
|
|
10
|
+
* - A run that stopped after the NOT VALID add resumes at VALIDATE.
|
|
11
|
+
*
|
|
12
|
+
* The server: `CHANT_SQL_E2E_POSTGRES_URL` (with
|
|
13
|
+
* `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running one, else a throwaway
|
|
14
|
+
* server at the pin, skipped when Docker is not available. The test writes
|
|
15
|
+
* only to the schema `chant_e2e_3703`, dropped afterwards.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import pg from "pg";
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
23
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
24
|
+
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../live/client";
|
|
25
|
+
import { postgresApply } from "../../op/activities/postgres-apply";
|
|
26
|
+
import { diffStatements, type StatementStep } from "../../migration-statements";
|
|
27
|
+
|
|
28
|
+
const S = "chant_e2e_3703";
|
|
29
|
+
const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
30
|
+
const enabled = given ? true : await dockerAvailable();
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pg-constraints-"));
|
|
32
|
+
let server: TestPostgres | undefined;
|
|
33
|
+
let endpoint: PostgresEndpoint;
|
|
34
|
+
let admin: PostgresClient;
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
if (!enabled) return;
|
|
38
|
+
if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
39
|
+
else {
|
|
40
|
+
server = await startTestPostgres();
|
|
41
|
+
endpoint = server.endpoint();
|
|
42
|
+
}
|
|
43
|
+
admin = await connectPostgres(endpoint);
|
|
44
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`);
|
|
45
|
+
}, 600_000);
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
if (enabled) {
|
|
49
|
+
await admin?.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`).catch(() => undefined);
|
|
50
|
+
await admin?.end();
|
|
51
|
+
}
|
|
52
|
+
await server?.stop();
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
interface Obj {
|
|
57
|
+
export: string;
|
|
58
|
+
type: string;
|
|
59
|
+
ddl: string;
|
|
60
|
+
dependsOn?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const doc = (objects: Obj[]) => ({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) });
|
|
64
|
+
const schema: Obj = { export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` };
|
|
65
|
+
const parents: Obj = { export: "parents", type: "Postgres::Table", dependsOn: ["app"], ddl: `CREATE TABLE ${S}.parents (id bigint PRIMARY KEY)` };
|
|
66
|
+
/** `items` with the constraints named in `with` declared. */
|
|
67
|
+
const items = (...with_: Array<"pos" | "fk" | "small">): Obj => ({
|
|
68
|
+
export: "items",
|
|
69
|
+
type: "Postgres::Table",
|
|
70
|
+
dependsOn: ["app", "parents"],
|
|
71
|
+
ddl: [
|
|
72
|
+
`CREATE TABLE ${S}.items (id bigint PRIMARY KEY, n int, parent bigint`,
|
|
73
|
+
with_.includes("pos") ? ", CONSTRAINT items_n_pos CHECK (n > 0)" : "",
|
|
74
|
+
with_.includes("fk") ? `, CONSTRAINT items_parent_fkey FOREIGN KEY (parent) REFERENCES ${S}.parents (id)` : "",
|
|
75
|
+
with_.includes("small") ? ", CONSTRAINT items_n_small CHECK (n < 1000000)" : "",
|
|
76
|
+
")",
|
|
77
|
+
].join(""),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function writeBuild(file: string, objects: Obj[]): string {
|
|
81
|
+
const path = join(dir, file);
|
|
82
|
+
writeFileSync(path, JSON.stringify(doc(objects)));
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const profile = () => ({
|
|
87
|
+
config: { ownership: { stack: "e2e3703", env: "test" }, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [S] } } } },
|
|
88
|
+
env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
|
|
89
|
+
});
|
|
90
|
+
const apply = (path: string) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(), log: () => undefined });
|
|
91
|
+
const validated = async (name: string) =>
|
|
92
|
+
(await admin.query<{ v: boolean }>(`SELECT convalidated AS v FROM pg_constraint WHERE conrelid = '${S}.items'::regclass AND conname = '${name}'`))[0]?.v;
|
|
93
|
+
|
|
94
|
+
/** A raw node-postgres session, for holding a transaction open. */
|
|
95
|
+
async function session(): Promise<pg.Client> {
|
|
96
|
+
const url = new URL(endpoint.url);
|
|
97
|
+
if (endpoint.password !== undefined) url.password = encodeURIComponent(endpoint.password);
|
|
98
|
+
const c = new pg.Client({ connectionString: url.toString() });
|
|
99
|
+
c.on("error", () => undefined);
|
|
100
|
+
await c.connect();
|
|
101
|
+
return c;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe.skipIf(!enabled)("a CHECK and a foreign key added NOT VALID, then validated, on a live server", () => {
|
|
105
|
+
beforeAll(async () => {
|
|
106
|
+
const first = await apply(writeBuild("v1.json", [schema, parents, items()]));
|
|
107
|
+
expect(first.failed).toEqual([]);
|
|
108
|
+
await admin.query(`INSERT INTO ${S}.parents SELECT g FROM generate_series(1, 200000) g`);
|
|
109
|
+
await admin.query(`INSERT INTO ${S}.items (id, n, parent) SELECT g, g, g FROM generate_series(1, 200000) g`);
|
|
110
|
+
}, 120_000);
|
|
111
|
+
|
|
112
|
+
test("the applier sends ADD ... NOT VALID, then VALIDATE in a transaction of its own, and both end valid", async () => {
|
|
113
|
+
const out = await apply(writeBuild("v2.json", [schema, parents, items("pos", "fk")]));
|
|
114
|
+
expect(out.failed).toEqual([]);
|
|
115
|
+
const mine = out.statements.filter((s) => /items_n_pos|items_parent_fkey/.test(s.sql));
|
|
116
|
+
expect(mine.map((s) => [s.sql, s.class])).toEqual([
|
|
117
|
+
[`ALTER TABLE ${S}.items ADD CONSTRAINT items_n_pos CHECK (n > 0) NOT VALID`, "metadata"],
|
|
118
|
+
[`ALTER TABLE ${S}.items VALIDATE CONSTRAINT items_n_pos`, "validate"],
|
|
119
|
+
[`ALTER TABLE ${S}.items ADD CONSTRAINT items_parent_fkey FOREIGN KEY (parent) REFERENCES ${S}.parents (id) NOT VALID`, "metadata"],
|
|
120
|
+
[`ALTER TABLE ${S}.items VALIDATE CONSTRAINT items_parent_fkey`, "validate"],
|
|
121
|
+
]);
|
|
122
|
+
const [addCheck, validateCheck, addFk, validateFk] = mine.map((s) => s.transaction);
|
|
123
|
+
expect(validateCheck).not.toBe(addCheck);
|
|
124
|
+
expect(validateCheck).not.toBe(addFk);
|
|
125
|
+
expect(validateFk).not.toBe(addFk);
|
|
126
|
+
expect(await validated("items_n_pos")).toBe(true);
|
|
127
|
+
expect(await validated("items_parent_fkey")).toBe(true);
|
|
128
|
+
}, 120_000);
|
|
129
|
+
|
|
130
|
+
test("a concurrent writer is not blocked while VALIDATE reads the table", async () => {
|
|
131
|
+
await admin.query(`ALTER TABLE ${S}.items DROP CONSTRAINT items_parent_fkey`);
|
|
132
|
+
const before = doc([schema, parents, items("pos")]);
|
|
133
|
+
const steps = diffStatements(before, doc([schema, parents, items("pos", "fk", "small")])).steps.filter((s): s is StatementStep => s.kind === "statement");
|
|
134
|
+
expect(steps.map((s) => [s.rule, s.class])).toEqual([
|
|
135
|
+
["SQLPG217", "metadata"],
|
|
136
|
+
["SQLPG220", "validate"],
|
|
137
|
+
["SQLPG217", "metadata"],
|
|
138
|
+
["SQLPG220", "validate"],
|
|
139
|
+
]);
|
|
140
|
+
for (const s of steps.filter((s) => s.precheck)) expect((await admin.query<{ n: string }>(s.precheck!.sql))[0]!.n, s.sql).toBe("0");
|
|
141
|
+
|
|
142
|
+
const applier = await session();
|
|
143
|
+
const writer = await session();
|
|
144
|
+
try {
|
|
145
|
+
await writer.query("SET lock_timeout = '300ms'");
|
|
146
|
+
let id = 1_000_000;
|
|
147
|
+
for (const s of steps) {
|
|
148
|
+
await applier.query("BEGIN");
|
|
149
|
+
await applier.query(s.sql);
|
|
150
|
+
// The applier's transaction is open: does a write to the table wait for it?
|
|
151
|
+
const blocked = await writer.query(`INSERT INTO ${S}.items (id, n, parent) VALUES (${id++}, 1, 1)`).then(
|
|
152
|
+
() => false,
|
|
153
|
+
(e: { code?: string }) => e.code === "55P03",
|
|
154
|
+
);
|
|
155
|
+
await applier.query("COMMIT");
|
|
156
|
+
// VALIDATE reads every row, and the writer goes on.
|
|
157
|
+
if (s.class === "validate") expect(blocked, s.sql).toBe(false);
|
|
158
|
+
// The probe sees a lock: the NOT VALID add does hold the writer while its transaction is open.
|
|
159
|
+
else expect(blocked, s.sql).toBe(true);
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
await applier.end();
|
|
163
|
+
await writer.end();
|
|
164
|
+
}
|
|
165
|
+
expect(await validated("items_parent_fkey")).toBe(true);
|
|
166
|
+
expect(await validated("items_n_small")).toBe(true);
|
|
167
|
+
}, 120_000);
|
|
168
|
+
|
|
169
|
+
test("a run that stopped after the NOT VALID add resumes at VALIDATE", async () => {
|
|
170
|
+
await admin.query(`ALTER TABLE ${S}.items DROP CONSTRAINT items_n_small`);
|
|
171
|
+
// The run stopped after its first statement.
|
|
172
|
+
await admin.query(`ALTER TABLE ${S}.items ADD CONSTRAINT items_n_small CHECK (n < 1000000) NOT VALID`);
|
|
173
|
+
expect(await validated("items_n_small")).toBe(false);
|
|
174
|
+
const out = await apply(writeBuild("v3.json", [schema, parents, items("pos", "fk", "small")]));
|
|
175
|
+
expect(out.failed).toEqual([]);
|
|
176
|
+
expect(out.statements.filter((s) => s.sql.includes("items_n_small")).map((s) => [s.sql, s.class])).toEqual([[`ALTER TABLE ${S}.items VALIDATE CONSTRAINT items_n_small`, "validate"]]);
|
|
177
|
+
expect(await validated("items_n_small")).toBe(true);
|
|
178
|
+
}, 120_000);
|
|
179
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock-safe SET NOT NULL and the pre-checks against a live server (#3686).
|
|
3
|
+
*
|
|
4
|
+
* - The applier makes SET NOT NULL as a NOT VALID check, its validation in a
|
|
5
|
+
* transaction of its own, SET NOT NULL and the check dropped, and leaves no
|
|
6
|
+
* check behind.
|
|
7
|
+
* - Step by step, each statement's transaction held open: a concurrent
|
|
8
|
+
* writer is not blocked while the validation reads the table, and SET NOT
|
|
9
|
+
* NULL is proven by the check without a scan (the server says so at
|
|
10
|
+
* DEBUG1).
|
|
11
|
+
* - Each kind of pre-check runs on the server and counts what its statement
|
|
12
|
+
* would fail on: NULLs, duplicates, rows a check refuses, orphans.
|
|
13
|
+
*
|
|
14
|
+
* The server: `CHANT_SQL_E2E_POSTGRES_URL` (with
|
|
15
|
+
* `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running one, else a throwaway
|
|
16
|
+
* server at the pin, skipped when Docker is not available. The test writes
|
|
17
|
+
* only to the schema `chant_e2e_3686`, dropped afterwards.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import pg from "pg";
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
25
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
26
|
+
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../live/client";
|
|
27
|
+
import { postgresApply } from "../../op/activities/postgres-apply";
|
|
28
|
+
import { diffStatements, type StatementStep } from "../../migration-statements";
|
|
29
|
+
|
|
30
|
+
const S = "chant_e2e_3686";
|
|
31
|
+
const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
32
|
+
const enabled = given ? true : await dockerAvailable();
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pg-safe-"));
|
|
34
|
+
let server: TestPostgres | undefined;
|
|
35
|
+
let endpoint: PostgresEndpoint;
|
|
36
|
+
let admin: PostgresClient;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
if (!enabled) return;
|
|
40
|
+
if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
41
|
+
else {
|
|
42
|
+
server = await startTestPostgres();
|
|
43
|
+
endpoint = server.endpoint();
|
|
44
|
+
}
|
|
45
|
+
admin = await connectPostgres(endpoint);
|
|
46
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`);
|
|
47
|
+
}, 600_000);
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
if (enabled) {
|
|
51
|
+
await admin?.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`).catch(() => undefined);
|
|
52
|
+
await admin?.end();
|
|
53
|
+
}
|
|
54
|
+
await server?.stop();
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
interface Obj {
|
|
59
|
+
export: string;
|
|
60
|
+
type: string;
|
|
61
|
+
ddl: string;
|
|
62
|
+
dependsOn?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const doc = (objects: Obj[]) => ({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) });
|
|
66
|
+
const schema: Obj = { export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` };
|
|
67
|
+
const items = (cols: { a?: boolean; b?: boolean }): Obj => ({
|
|
68
|
+
export: "items",
|
|
69
|
+
type: "Postgres::Table",
|
|
70
|
+
dependsOn: ["app"],
|
|
71
|
+
ddl: `CREATE TABLE ${S}.items (id bigint PRIMARY KEY, a text${cols.a ? " NOT NULL" : ""}, b text${cols.b ? " NOT NULL" : ""}, c text, parent bigint)`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function writeBuild(file: string, objects: Obj[]): string {
|
|
75
|
+
const path = join(dir, file);
|
|
76
|
+
writeFileSync(path, JSON.stringify(doc(objects)));
|
|
77
|
+
return path;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const profile = () => ({
|
|
81
|
+
config: { ownership: { stack: "e2e3686", env: "test" }, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [S] } } } },
|
|
82
|
+
env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
|
|
83
|
+
});
|
|
84
|
+
const apply = (path: string) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(), log: () => undefined });
|
|
85
|
+
|
|
86
|
+
/** A raw node-postgres session, for holding a transaction open and reading the server's notices. */
|
|
87
|
+
async function session(): Promise<pg.Client> {
|
|
88
|
+
const url = new URL(endpoint.url);
|
|
89
|
+
if (endpoint.password !== undefined) url.password = encodeURIComponent(endpoint.password);
|
|
90
|
+
const c = new pg.Client({ connectionString: url.toString() });
|
|
91
|
+
c.on("error", () => undefined);
|
|
92
|
+
await c.connect();
|
|
93
|
+
return c;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe.skipIf(!enabled)("lock-safe SET NOT NULL on a live server", () => {
|
|
97
|
+
beforeAll(async () => {
|
|
98
|
+
const first = await apply(writeBuild("v1.json", [schema, items({})]));
|
|
99
|
+
expect(first.failed).toEqual([]);
|
|
100
|
+
await admin.query(`INSERT INTO ${S}.items (id, a, b, c, parent) SELECT g, 'a', 'b', CASE WHEN g = 7 THEN NULL ELSE 'c' END, g FROM generate_series(1, 200000) g`);
|
|
101
|
+
}, 120_000);
|
|
102
|
+
|
|
103
|
+
test("the applier sends the four statements, validating in a transaction of its own, and leaves no check", async () => {
|
|
104
|
+
const out = await apply(writeBuild("v2.json", [schema, items({ a: true })]));
|
|
105
|
+
expect(out.failed).toEqual([]);
|
|
106
|
+
const mine = out.statements.filter((s) => s.sql.includes("a__chant_nn") || s.sql.includes("COLUMN a "));
|
|
107
|
+
expect(mine.map((s) => [s.sql, s.class])).toEqual([
|
|
108
|
+
[`ALTER TABLE ${S}.items ADD CONSTRAINT a__chant_nn CHECK (a IS NOT NULL) NOT VALID`, "metadata"],
|
|
109
|
+
[`ALTER TABLE ${S}.items VALIDATE CONSTRAINT a__chant_nn`, "validate"],
|
|
110
|
+
[`ALTER TABLE ${S}.items ALTER COLUMN a SET NOT NULL`, "metadata"],
|
|
111
|
+
[`ALTER TABLE ${S}.items DROP CONSTRAINT a__chant_nn`, "metadata"],
|
|
112
|
+
]);
|
|
113
|
+
const [add, validate, set] = mine.map((s) => s.transaction);
|
|
114
|
+
expect(validate).not.toBe(add);
|
|
115
|
+
expect(validate).not.toBe(set);
|
|
116
|
+
const [col] = await admin.query<{ notnull: boolean }>(`SELECT attnotnull AS notnull FROM pg_attribute WHERE attrelid = '${S}.items'::regclass AND attname = 'a'`);
|
|
117
|
+
expect(col?.notnull).toBe(true);
|
|
118
|
+
expect(await admin.query(`SELECT 1 FROM pg_constraint WHERE conrelid = '${S}.items'::regclass AND conname = 'a__chant_nn'`)).toEqual([]);
|
|
119
|
+
}, 120_000);
|
|
120
|
+
|
|
121
|
+
test("a concurrent writer is not blocked while the table is validated, and SET NOT NULL does not scan", async () => {
|
|
122
|
+
const d = diffStatements(doc([schema, items({ a: true })]), doc([schema, items({ a: true, b: true })]));
|
|
123
|
+
const steps = d.steps.filter((s): s is StatementStep => s.kind === "statement");
|
|
124
|
+
expect(steps.map((s) => s.class)).toEqual(["metadata", "validate", "metadata", "metadata"]);
|
|
125
|
+
expect((await admin.query<{ n: string }>(steps[0]!.precheck!.sql))[0]!.n).toBe("0");
|
|
126
|
+
|
|
127
|
+
const applier = await session();
|
|
128
|
+
const writer = await session();
|
|
129
|
+
const notices: string[] = [];
|
|
130
|
+
applier.on("notice", (n) => notices.push(n.message ?? ""));
|
|
131
|
+
try {
|
|
132
|
+
await writer.query("SET lock_timeout = '300ms'");
|
|
133
|
+
let id = 1_000_000;
|
|
134
|
+
for (const s of steps) {
|
|
135
|
+
await applier.query("BEGIN");
|
|
136
|
+
await applier.query(s.sql.includes("SET NOT NULL") ? "SET LOCAL client_min_messages = debug1" : "SET LOCAL client_min_messages = notice");
|
|
137
|
+
await applier.query(s.sql);
|
|
138
|
+
// The applier's transaction is open: does a write to the table wait for it?
|
|
139
|
+
const blocked = await writer.query(`INSERT INTO ${S}.items (id, a, b, c) VALUES (${id++}, 'a', 'b', 'c')`).then(
|
|
140
|
+
() => false,
|
|
141
|
+
(e: { code?: string }) => e.code === "55P03",
|
|
142
|
+
);
|
|
143
|
+
await applier.query("COMMIT");
|
|
144
|
+
if (s.class === "validate") expect(blocked, s.sql).toBe(false);
|
|
145
|
+
// The probe sees a lock: the brief catalog change does hold the writer while its transaction is open.
|
|
146
|
+
if (s.rule === "SQLPG217") expect(blocked, s.sql).toBe(true);
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
await applier.end();
|
|
150
|
+
await writer.end();
|
|
151
|
+
}
|
|
152
|
+
expect(notices.some((m) => /sufficient to prove that it does not contain nulls/.test(m))).toBe(true);
|
|
153
|
+
}, 120_000);
|
|
154
|
+
|
|
155
|
+
test("each pre-check counts what its statement would fail on", async () => {
|
|
156
|
+
// c is NULL in one row; a holds one value in every row; one row has id 7; parent 1 is not a parent.
|
|
157
|
+
await admin.query(`CREATE TABLE ${S}.parents (id bigint PRIMARY KEY)`);
|
|
158
|
+
await admin.query(`INSERT INTO ${S}.parents SELECT g FROM generate_series(2, 200000) g`);
|
|
159
|
+
const after = (ddl: string, extra: Obj[] = []) => doc([schema, { ...items({ a: true, b: true }), ddl }, ...extra]);
|
|
160
|
+
const before = doc([schema, items({ a: true, b: true }), { export: "parents", type: "Postgres::Table", dependsOn: ["app"], ddl: `CREATE TABLE ${S}.parents (id bigint PRIMARY KEY)` }]);
|
|
161
|
+
const base = items({ a: true, b: true }).ddl;
|
|
162
|
+
const parents: Obj = { export: "parents", type: "Postgres::Table", dependsOn: ["app"], ddl: `CREATE TABLE ${S}.parents (id bigint PRIMARY KEY)` };
|
|
163
|
+
const cases: Array<[string, ReturnType<typeof doc>, string]> = [
|
|
164
|
+
["SET NOT NULL", after(base.replace("c text", "c text NOT NULL"), [parents]), "1"],
|
|
165
|
+
["UNIQUE", after(base.replace(", parent bigint)", ", parent bigint, CONSTRAINT items_a_key UNIQUE (a))"), [parents]), "1"],
|
|
166
|
+
["UNIQUE over id", after(base.replace(", parent bigint)", ", parent bigint, CONSTRAINT items_id_key UNIQUE (id))"), [parents]), "0"],
|
|
167
|
+
["CHECK", after(base.replace(", parent bigint)", ", parent bigint, CONSTRAINT not_seven CHECK (id <> 7))"), [parents]), "1"],
|
|
168
|
+
["FOREIGN KEY", after(base.replace("parent bigint", `parent bigint CONSTRAINT items_parent_fkey REFERENCES ${S}.parents (id)`), [parents]), "1"],
|
|
169
|
+
];
|
|
170
|
+
for (const [what, next, n] of cases) {
|
|
171
|
+
const step = diffStatements(before, next).steps.find((s): s is StatementStep => s.kind === "statement" && s.precheck !== undefined);
|
|
172
|
+
expect(step, what).toBeDefined();
|
|
173
|
+
expect((await admin.query<{ n: string }>(step!.precheck!.sql))[0]!.n, what).toBe(n);
|
|
174
|
+
}
|
|
175
|
+
}, 120_000);
|
|
176
|
+
});
|