@intentius/chant-lexicon-sql 0.118.1 → 0.121.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.
- package/dist/clickhouse/apply/apply.d.ts +1 -1
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +8 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +1 -1
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/inlined.d.ts +49 -0
- package/dist/clickhouse/import/inlined.d.ts.map +1 -0
- package/dist/clickhouse/import/live-export.d.ts +22 -0
- package/dist/clickhouse/import/live-export.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts +8 -0
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +15 -0
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts +19 -2
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +3 -0
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
- package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/engines.d.ts +44 -0
- package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
- package/dist/clickhouse/rebuild/verify.d.ts +25 -1
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts +5 -0
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/config.d.ts +36 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/core/foreign-tables.d.ts +13 -0
- package/dist/core/foreign-tables.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +7 -3
- package/dist/files/clickhouse.d.ts.map +1 -1
- package/dist/files/postgres.d.ts +3 -0
- package/dist/files/postgres.d.ts.map +1 -1
- package/dist/import-parser.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/clickhouse-emulator.d.ts +7 -1
- package/dist/op/activities/clickhouse-emulator.d.ts.map +1 -1
- package/dist/op/activities/postgres-emulator.d.ts +8 -1
- package/dist/op/activities/postgres-emulator.d.ts.map +1 -1
- package/dist/postgres/access/changes.d.ts +23 -0
- package/dist/postgres/access/changes.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +8 -1
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +3 -3
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +31 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/sql-files.d.ts +2 -1
- package/dist/sql-files.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +8 -2
- package/src/clickhouse/apply/statements.ts +32 -5
- package/src/clickhouse/import/generator.ts +8 -3
- package/src/clickhouse/import/import.test.ts +72 -0
- package/src/clickhouse/import/inlined-functions.e2e.test.ts +87 -0
- package/src/clickhouse/import/inlined.test.ts +48 -0
- package/src/clickhouse/import/inlined.ts +200 -0
- package/src/clickhouse/import/live-export.ts +75 -2
- package/src/clickhouse/live/access.e2e.test.ts +71 -18
- package/src/clickhouse/live/bind.ts +10 -0
- package/src/clickhouse/live/catalog.ts +25 -2
- package/src/clickhouse/live/describe-resources.ts +5 -2
- package/src/clickhouse/live/live.test.ts +8 -0
- package/src/clickhouse/plan/commands.ts +6 -3
- package/src/clickhouse/plan/deep-grants.test.ts +67 -0
- package/src/clickhouse/plan/deep.ts +70 -6
- package/src/clickhouse/plan/diff.ts +4 -1
- package/src/clickhouse/plan/normalize.ts +5 -2
- package/src/clickhouse/plan/plan.test.ts +9 -1
- package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
- package/src/clickhouse/rebuild/engines.ts +163 -0
- package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
- package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
- package/src/clickhouse/rebuild/verify.ts +153 -24
- package/src/clickhouse/testing/fake-server.ts +3 -2
- package/src/codegen/classifier-page.ts +1 -1
- package/src/config.ts +18 -4
- package/src/core/foreign-tables.ts +28 -0
- package/src/files/clickhouse.ts +71 -5
- package/src/files/postgres.ts +2 -1
- package/src/import-parser.ts +2 -0
- package/src/migration-statements.test.ts +33 -0
- package/src/migration-statements.ts +2 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
- package/src/op/activities/clickhouse-emulator.ts +22 -1
- package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
- package/src/op/activities/postgres-apply.test.ts +32 -0
- package/src/op/activities/postgres-emulator.test.ts +20 -0
- package/src/op/activities/postgres-emulator.ts +41 -1
- package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
- package/src/op/apply-gate-access.e2e.test.ts +207 -0
- package/src/postgres/access/access.test.ts +26 -1
- package/src/postgres/access/changes.ts +39 -0
- package/src/postgres/apply/apply.ts +10 -7
- package/src/postgres/entities.ts +20 -1
- package/src/postgres/live/bind.ts +16 -4
- package/src/postgres/live/catalog.ts +3 -15
- package/src/postgres/live/client.ts +85 -0
- package/src/postgres/live/live.test.ts +54 -0
- package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
- package/src/postgres/plan/commands.ts +2 -14
- package/src/postgres/plan/deep.ts +44 -2
- package/src/serializer.test.ts +4 -0
- package/src/skills/chant-sql-postgres-plan.md +1 -1
- package/src/skills/chant-sql-rebuild.md +2 -0
- package/src/skills/chant-sql.md +1 -1
- package/src/sql-files.test.ts +78 -1
- package/src/sql-files.ts +2 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The privilege changes a connected server needs (#3681): what `chant sql
|
|
3
|
+
* plan` reports, and what the deep read reports as pending so the plan digest
|
|
4
|
+
* an approval binds to moves with a privilege granted or revoked by hand
|
|
5
|
+
* (#3706).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostgresClient } from "../live/client";
|
|
9
|
+
import type { CanonicalPgObject } from "../plan/normalize";
|
|
10
|
+
import { declaredAccess, diffAccess, predictedAccess, targetKey, type AccessChange } from "./acl";
|
|
11
|
+
import { readLiveAccess } from "./live";
|
|
12
|
+
|
|
13
|
+
/** The role a session runs as: the one whose own privileges owning an object gives, and whose default privileges apply. */
|
|
14
|
+
async function currentRole(client: PostgresClient): Promise<{ self?: string }> {
|
|
15
|
+
const rows = await client.query<{ self: string }>("SELECT current_user AS self").catch(() => []);
|
|
16
|
+
return rows[0]?.self ? { self: rows[0].self } : {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The changes from the access the server holds to the access the
|
|
21
|
+
* declarations (keyed by export name, in the build's order) add up to. An
|
|
22
|
+
* object the server does not hold yet is taken to have what the server gives
|
|
23
|
+
* a new one. `scope` limits the default privileges read to those schemas.
|
|
24
|
+
*/
|
|
25
|
+
export async function accessChangesAgainst(
|
|
26
|
+
client: PostgresClient,
|
|
27
|
+
declared: ReadonlyArray<{ key: string; canonical: CanonicalPgObject }>,
|
|
28
|
+
options: { major: number; scope?: readonly string[] },
|
|
29
|
+
): Promise<AccessChange[]> {
|
|
30
|
+
const want = declaredAccess(declared, { major: options.major, ...(await currentRole(client)) });
|
|
31
|
+
const have = await readLiveAccess(client, want.targets, {
|
|
32
|
+
...(options.scope ? { schemas: options.scope } : {}),
|
|
33
|
+
forRoles: want.forRoles,
|
|
34
|
+
declaredDefaults: want.targets.filter((t) => t.kind === "default"),
|
|
35
|
+
});
|
|
36
|
+
const missing = want.targets.filter((t) => t.kind !== "default" && t.kind !== "column" && !have.present.has(targetKey(t)));
|
|
37
|
+
const now = new Map([...have.state, ...predictedAccess(missing, have)]);
|
|
38
|
+
return diffAccess(now, want.state, want.exportsOf);
|
|
39
|
+
}
|
|
@@ -70,7 +70,7 @@ import type { OwnershipMarker } from "@intentius/chant/ownership";
|
|
|
70
70
|
import type { ApplyRef } from "@intentius/chant/apply";
|
|
71
71
|
import { SqlApplyError, dependencyFailedDetail, missingDependencies, readBuildObjects, type SqlApplyOutcome } from "../../core/apply";
|
|
72
72
|
import { carriesMarker, isChantManaged } from "../../core/ownership";
|
|
73
|
-
import { PostgresQueryError, type PostgresClient } from "../live/client";
|
|
73
|
+
import { DEFAULT_LOCK_TIMEOUT_MS, PostgresQueryError, withCatalogLockTimeout, type PostgresClient } from "../live/client";
|
|
74
74
|
import type { PostgresTarget } from "../live/bind";
|
|
75
75
|
import type { LivePgObject, readLiveSchema } from "../live/catalog";
|
|
76
76
|
import { liveProps } from "../plan/deep";
|
|
@@ -98,7 +98,7 @@ export interface PostgresApplyTimeouts {
|
|
|
98
98
|
scanTimeoutMs: number;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
export const DEFAULT_POSTGRES_APPLY_TIMEOUTS: PostgresApplyTimeouts = { lockTimeoutMs:
|
|
101
|
+
export const DEFAULT_POSTGRES_APPLY_TIMEOUTS: PostgresApplyTimeouts = { lockTimeoutMs: DEFAULT_LOCK_TIMEOUT_MS, statementTimeoutMs: 60_000, scanTimeoutMs: 0 };
|
|
102
102
|
|
|
103
103
|
/** One statement as it ran: its object, the timeouts it ran with, and the transaction it was in. */
|
|
104
104
|
export interface RanStatement {
|
|
@@ -254,8 +254,10 @@ export async function applyPostgres(
|
|
|
254
254
|
};
|
|
255
255
|
|
|
256
256
|
// ── Plan: the same diff `chant sql plan` shows. ──
|
|
257
|
+
// The catalog reads wait under lockTimeoutMs, as a plan's do (#3726): one blocked behind ACCESS EXCLUSIVE fails naming the holder instead of hanging.
|
|
258
|
+
const reader = await withCatalogLockTimeout(client, timeouts.lockTimeoutMs);
|
|
257
259
|
const build: PgSchemaObject[] = declared.map((o) => ({ key: o.exportName, canonical: o.canonical }));
|
|
258
|
-
const plan = await planAgainstClient(
|
|
260
|
+
const plan = await planAgainstClient(reader, target, build, {
|
|
259
261
|
...(opts.major !== undefined ? { major: opts.major } : {}),
|
|
260
262
|
...(opts.readLive ? { readLive: opts.readLive } : {}),
|
|
261
263
|
...(opts.serverNormalize ? { serverNormalize: opts.serverNormalize } : {}),
|
|
@@ -281,7 +283,7 @@ export async function applyPostgres(
|
|
|
281
283
|
for (const m of matchPgObjects(plan.live, plan.declared)) if (m.kind === "matched") matchedLive.add(m.after.key);
|
|
282
284
|
|
|
283
285
|
// An extension's own comment stays under the trailer when the declaration sets none.
|
|
284
|
-
const extensionComments = await extensionDefaults(
|
|
286
|
+
const extensionComments = await extensionDefaults(reader, objects.filter((o) => o.canonical.kind === "extension" && !matchedLive.has(o.key)).map((o) => o.canonical.name));
|
|
285
287
|
|
|
286
288
|
// ── Each object's statements, or its verdict when it has none to send. ──
|
|
287
289
|
const statements = planPgStatements({
|
|
@@ -454,7 +456,7 @@ export async function applyPostgres(
|
|
|
454
456
|
}
|
|
455
457
|
await commit();
|
|
456
458
|
|
|
457
|
-
if (managed) await applyAccess(client, declared, onServer, exportNames, { ...opts, major: plan.major ?? opts.major ?? POSTGRES_LATEST_MAJOR, ...(plan.scope ? { schemas: plan.scope } : {}) }, timeouts, outcome, log);
|
|
459
|
+
if (managed) await applyAccess(client, reader, declared, onServer, exportNames, { ...opts, major: plan.major ?? opts.major ?? POSTGRES_LATEST_MAJOR, ...(plan.scope ? { schemas: plan.scope } : {}) }, timeouts, outcome, log);
|
|
458
460
|
|
|
459
461
|
if (opts.prune) await prune(client, statements.drops, liveRawByKey, opts, timeouts, outcome, log);
|
|
460
462
|
if (outcome.failed.length > 0) throw new PostgresApplyError(outcome);
|
|
@@ -470,6 +472,7 @@ export async function applyPostgres(
|
|
|
470
472
|
*/
|
|
471
473
|
async function applyAccess(
|
|
472
474
|
client: PostgresClient,
|
|
475
|
+
reader: PostgresClient,
|
|
473
476
|
declared: readonly DeclaredPgObject[],
|
|
474
477
|
onServer: ReadonlySet<string>,
|
|
475
478
|
exportNames: ReadonlySet<string>,
|
|
@@ -487,12 +490,12 @@ async function applyAccess(
|
|
|
487
490
|
else ready.push(o);
|
|
488
491
|
}
|
|
489
492
|
const skipped = new Set(decls.filter((o) => !ready.includes(o)).map((o) => o.exportName));
|
|
490
|
-
const [me] = await
|
|
493
|
+
const [me] = await reader.query<{ self: string }>("SELECT current_user AS self");
|
|
491
494
|
const want = declaredAccess(
|
|
492
495
|
declared.filter((o) => !skipped.has(o.exportName)).map((o) => ({ key: o.exportName, canonical: o.canonical })),
|
|
493
496
|
{ major: opts.major, ...(me?.self ? { self: me.self } : {}) },
|
|
494
497
|
);
|
|
495
|
-
const have = await readLiveAccess(
|
|
498
|
+
const have = await readLiveAccess(reader, want.targets, {
|
|
496
499
|
...(opts.schemas ? { schemas: opts.schemas } : {}),
|
|
497
500
|
forRoles: want.forRoles,
|
|
498
501
|
declaredDefaults: want.targets.filter((t) => t.kind === "default"),
|
package/src/postgres/entities.ts
CHANGED
|
@@ -1048,7 +1048,7 @@ function grantProps(ctx: Ctx, node: GrantNode, ddl: string, source: { strings: s
|
|
|
1048
1048
|
if (o.args) sqlName += `(${req(text(ctx, o.args, "objects")) ?? ""})`;
|
|
1049
1049
|
else if (typeof t !== "string" && (t.entityType === POSTGRES_ENTITY_TYPES.function || t.entityType === POSTGRES_ENTITY_TYPES.procedure)) {
|
|
1050
1050
|
// An interpolated routine is named by its own parameter types.
|
|
1051
|
-
sqlName += `(${(t.props as RoutineProps)
|
|
1051
|
+
sqlName += `(${routineArgTypes(t.props as RoutineProps)})`;
|
|
1052
1052
|
} else throw new SqlTemplateError("grant", `${sqlName}: name the parameter types (${sqlName}(...)), or interpolate the routine, since overloads share a name`, 0, 0);
|
|
1053
1053
|
}
|
|
1054
1054
|
objectNames.push(sqlName);
|
|
@@ -1071,6 +1071,22 @@ function grantProps(ctx: Ctx, node: GrantNode, ddl: string, source: { strings: s
|
|
|
1071
1071
|
}) as GrantProps;
|
|
1072
1072
|
}
|
|
1073
1073
|
|
|
1074
|
+
/** A routine's parameter types, as `GRANT ... ON FUNCTION f(...)` names it: the `OUT` ones left out. */
|
|
1075
|
+
const routineArgTypes = (props: RoutineProps): string => props.args.filter((a) => a.mode !== "out").map((a) => a.type).join(", ");
|
|
1076
|
+
|
|
1077
|
+
/** The interpolated functions and procedures not followed by their own `(...)`: the ones a grant's statement writes with the parameter types (#3707). */
|
|
1078
|
+
function bareRoutineSplices(tokens: readonly Token[], values: readonly unknown[]): Set<number> {
|
|
1079
|
+
const out = new Set<number>();
|
|
1080
|
+
tokens.forEach((t, k) => {
|
|
1081
|
+
if (t.kind !== "ref") return;
|
|
1082
|
+
const v = values[t.part];
|
|
1083
|
+
if (!isPostgresObject(v) || (v.entityType !== POSTGRES_ENTITY_TYPES.function && v.entityType !== POSTGRES_ENTITY_TYPES.procedure)) return;
|
|
1084
|
+
const next = tokens.slice(k + 1).find((x) => !isTrivia(x));
|
|
1085
|
+
if (!(next?.kind === "punct" && next.text === "(")) out.add(t.part);
|
|
1086
|
+
});
|
|
1087
|
+
return out;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1074
1090
|
function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string[], rawValues: readonly unknown[]): PostgresObject {
|
|
1075
1091
|
const rawParts = templateParts(strings);
|
|
1076
1092
|
const routine = tag === "func" || tag === "procedure";
|
|
@@ -1113,9 +1129,12 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
|
|
|
1113
1129
|
const rowSecurity = rest.filter((n): n is RowSecurityNode => n.statement === "rowSecurity");
|
|
1114
1130
|
|
|
1115
1131
|
const positions = regclassPositions(tokens, values);
|
|
1132
|
+
const bareRoutines = tag === "grant" ? bareRoutineSplices(tokens, values) : new Set<number>();
|
|
1116
1133
|
const render = (v: unknown, i?: number): string => {
|
|
1117
1134
|
const at = i === undefined ? undefined : positions.get(i);
|
|
1118
1135
|
if (at && isPostgresObject(v)) return at === "call" ? `${regclassText(v.sqlName)}::regclass` : regclassText(v.sqlName);
|
|
1136
|
+
// #3707: a routine a grant interpolates is written with its parameter types, so the statement names it on its own.
|
|
1137
|
+
if (i !== undefined && bareRoutines.has(i) && isPostgresObject(v)) return `${renderReference(v)}(${routineArgTypes(v.props as RoutineProps)})`;
|
|
1119
1138
|
return renderReference(v);
|
|
1120
1139
|
};
|
|
1121
1140
|
const ctx: Ctx = { d: { ...POSTGRES_TEMPLATES, renderReference: render }, tokens, values, fed: values.map(() => new Set<string>()) };
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ChantConfig } from "@intentius/chant/config";
|
|
8
8
|
import type { UnobservedReason } from "@intentius/chant/observation";
|
|
9
|
-
import { connectPostgres, PostgresQueryError, type PostgresClient, type PostgresEndpoint } from "./client";
|
|
9
|
+
import { connectPostgres, DEFAULT_LOCK_TIMEOUT_MS, PostgresQueryError, withCatalogLockTimeout, type PostgresClient, type PostgresEndpoint } from "./client";
|
|
10
10
|
import type { PostgresProvider } from "../providers/types";
|
|
11
11
|
import { credentialSource, TokenSourceError } from "../../token-source";
|
|
12
12
|
|
|
@@ -139,11 +139,23 @@ export async function resolveBoundTarget(options: BindOptions = {}): Promise<Pos
|
|
|
139
139
|
return target;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Resolve the environment's server and connect to it. The caller ends the client.
|
|
144
|
+
*
|
|
145
|
+
* The session reads under the profile's `lockTimeoutMs` (the applier's 5000 ms
|
|
146
|
+
* otherwise): a catalog read blocked behind ACCESS EXCLUSIVE fails with the
|
|
147
|
+
* relation and the pid that holds it, instead of waiting for as long as the
|
|
148
|
+
* lock is held.
|
|
149
|
+
*/
|
|
143
150
|
export async function bindPostgres(options: BindOptions = {}): Promise<{ target: PostgresTarget; client: PostgresClient }> {
|
|
144
151
|
const target = await resolveBoundTarget(options);
|
|
145
|
-
const
|
|
146
|
-
|
|
152
|
+
const raw = await (options.connect ?? connectPostgres)(target.endpoint);
|
|
153
|
+
try {
|
|
154
|
+
return { target, client: await withCatalogLockTimeout(raw, target.timeouts?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS) };
|
|
155
|
+
} catch (err) {
|
|
156
|
+
await raw.end();
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
/** SQLSTATEs that mean the server refused who we are: invalid_authorization_specification, invalid_password, insufficient_privilege. */
|
|
@@ -44,26 +44,14 @@ import { quoteIdent } from "../keywords";
|
|
|
44
44
|
import { hasChantTrailerKey, RECEIPTS_TRAILER_KEY, stripMarker } from "../../core/ownership";
|
|
45
45
|
import { MIGRATION_TRAILER_KEY } from "../migrate/names";
|
|
46
46
|
import { isProviderOwned, providerData } from "../providers";
|
|
47
|
+
import { FOREIGN_TABLES } from "../../core/foreign-tables";
|
|
47
48
|
import type { PostgresProvider } from "../providers/types";
|
|
48
49
|
|
|
49
50
|
/** The server's own schemas, never part of a declared schema. */
|
|
50
51
|
export const SYSTEM_SCHEMAS = ["pg_catalog", "information_schema", "pg_toast"];
|
|
51
52
|
|
|
52
|
-
/** Tables another tool keeps its migration history in, by name, with the tool. */
|
|
53
|
-
export
|
|
54
|
-
_prisma_migrations: "Prisma Migrate",
|
|
55
|
-
schema_migrations: "a migration runner (Rails, golang-migrate, dbmate)",
|
|
56
|
-
ar_internal_metadata: "Rails",
|
|
57
|
-
django_migrations: "Django",
|
|
58
|
-
alembic_version: "Alembic",
|
|
59
|
-
__drizzle_migrations: "drizzle-kit",
|
|
60
|
-
flyway_schema_history: "Flyway",
|
|
61
|
-
goose_db_version: "goose",
|
|
62
|
-
knex_migrations: "Knex",
|
|
63
|
-
knex_migrations_lock: "Knex",
|
|
64
|
-
SequelizeMeta: "Sequelize",
|
|
65
|
-
pgmigrations: "node-pg-migrate",
|
|
66
|
-
};
|
|
53
|
+
/** Tables another tool keeps its migration history in, by name, with the tool; both dialects share the list. */
|
|
54
|
+
export { FOREIGN_TABLES };
|
|
67
55
|
|
|
68
56
|
export interface LivePgObject {
|
|
69
57
|
type: PostgresEntityType;
|
|
@@ -90,3 +90,88 @@ export async function connectPostgres(endpoint: PostgresEndpoint, options: { app
|
|
|
90
90
|
},
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
/** SQLSTATE lock_not_available: `lock_timeout` passed while a statement waited for a lock. */
|
|
95
|
+
export const SQLSTATE_LOCK_NOT_AVAILABLE = "55P03";
|
|
96
|
+
|
|
97
|
+
/** The `lock_timeout` a statement waits under when the profile sets none (`sql.profiles.<env>.lockTimeoutMs`): the applier's and the catalog reader's. */
|
|
98
|
+
export const DEFAULT_LOCK_TIMEOUT_MS = 5_000;
|
|
99
|
+
|
|
100
|
+
/** A session holding ACCESS EXCLUSIVE on a relation: what a catalog read waits behind. */
|
|
101
|
+
export interface LockHolder {
|
|
102
|
+
pid: number;
|
|
103
|
+
relation: string;
|
|
104
|
+
application?: string;
|
|
105
|
+
state?: string;
|
|
106
|
+
/** Seconds since the holder's transaction began. */
|
|
107
|
+
seconds?: number;
|
|
108
|
+
query?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The sessions holding ACCESS EXCLUSIVE on a relation in the current database,
|
|
113
|
+
* other than this one. Only that mode conflicts with the ACCESS SHARE a
|
|
114
|
+
* catalog function (`pg_get_viewdef()`, `pg_get_indexdef()`) takes. Empty when
|
|
115
|
+
* the lookup itself fails.
|
|
116
|
+
*/
|
|
117
|
+
export async function accessExclusiveHolders(client: PostgresClient): Promise<LockHolder[]> {
|
|
118
|
+
try {
|
|
119
|
+
const rows = await client.query<{ pid: number; relation: string; application: string | null; state: string | null; seconds: number | null; query: string | null }>(
|
|
120
|
+
`SELECT l.pid, l.relation::pg_catalog.regclass::text AS relation, a.application_name AS application, a.state,
|
|
121
|
+
(EXTRACT(EPOCH FROM pg_catalog.now() - a.xact_start))::int AS seconds, pg_catalog.left(a.query, 120) AS query
|
|
122
|
+
FROM pg_catalog.pg_locks l LEFT JOIN pg_catalog.pg_stat_activity a ON a.pid = l.pid
|
|
123
|
+
WHERE l.locktype = 'relation' AND l.granted AND l.mode = 'AccessExclusiveLock'
|
|
124
|
+
AND l.pid <> pg_catalog.pg_backend_pid()
|
|
125
|
+
AND l.database = (SELECT oid FROM pg_catalog.pg_database WHERE datname = pg_catalog.current_database())
|
|
126
|
+
ORDER BY l.pid, 2 LIMIT 10`,
|
|
127
|
+
);
|
|
128
|
+
return rows.map((r) => ({
|
|
129
|
+
pid: Number(r.pid),
|
|
130
|
+
relation: r.relation,
|
|
131
|
+
...(r.application ? { application: r.application } : {}),
|
|
132
|
+
...(r.state ? { state: r.state } : {}),
|
|
133
|
+
...(r.seconds !== null && r.seconds !== undefined ? { seconds: Number(r.seconds) } : {}),
|
|
134
|
+
...(r.query ? { query: r.query.replace(/\s+/g, " ").trim() } : {}),
|
|
135
|
+
}));
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One line naming the lock holders, for a message. */
|
|
142
|
+
export function describeLockHolders(holders: readonly LockHolder[]): string {
|
|
143
|
+
if (holders.length === 0) return "no session holds ACCESS EXCLUSIVE now; the lock was released after the wait";
|
|
144
|
+
return holders
|
|
145
|
+
.map((h) => {
|
|
146
|
+
const about = [h.application ? `application ${JSON.stringify(h.application)}` : "", h.state ?? "", h.seconds !== undefined ? `transaction open ${h.seconds}s` : "", h.query ? `last statement: ${h.query}` : ""].filter(Boolean);
|
|
147
|
+
return `${h.relation} is held in ACCESS EXCLUSIVE by pid ${h.pid}${about.length > 0 ? ` (${about.join(", ")})` : ""}`;
|
|
148
|
+
})
|
|
149
|
+
.join("; ");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A client for catalog reads: the session's `lock_timeout` is `lockTimeoutMs`
|
|
154
|
+
* (0 waits without limit, as Postgres does), so a read that needs a table
|
|
155
|
+
* another session holds in ACCESS EXCLUSIVE (a long `ALTER TABLE`, `LOCK
|
|
156
|
+
* TABLE`) fails after the wait instead of hanging. The failure names the
|
|
157
|
+
* relation and the pid holding it.
|
|
158
|
+
*/
|
|
159
|
+
export async function withCatalogLockTimeout(client: PostgresClient, lockTimeoutMs: number = DEFAULT_LOCK_TIMEOUT_MS): Promise<PostgresClient> {
|
|
160
|
+
await client.query("SELECT pg_catalog.set_config('lock_timeout', $1, false)", [`${lockTimeoutMs}ms`]);
|
|
161
|
+
return {
|
|
162
|
+
async query<T>(sql: string, params?: readonly unknown[]): Promise<T[]> {
|
|
163
|
+
try {
|
|
164
|
+
return await client.query<T>(sql, params);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (!(err instanceof PostgresQueryError) || err.code !== SQLSTATE_LOCK_NOT_AVAILABLE) throw err;
|
|
167
|
+
const holders = describeLockHolders(await accessExclusiveHolders(client));
|
|
168
|
+
throw new PostgresQueryError(
|
|
169
|
+
`${err.message.split("\n")[0]}: the catalog read waited ${lockTimeoutMs}ms for a lock; ${holders}. Wait for that transaction to finish, or raise sql.profiles.<env>.lockTimeoutMs`,
|
|
170
|
+
err.code,
|
|
171
|
+
err.position,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
end: () => client.end(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -196,3 +196,57 @@ describe("a managed provider's own objects (sql.provider)", () => {
|
|
|
196
196
|
expect(r.resources.authUsers).toMatchObject({ ownership: "foreign", attributes: { keptBy: "Supabase" } });
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
describe("catalog reads under lock_timeout (#3678)", () => {
|
|
201
|
+
/** A fake whose view read waits behind a lock and times out, and whose pg_locks names the holder. */
|
|
202
|
+
const blocked = (log: string[]) => {
|
|
203
|
+
const inner = fakeClient(CATALOG, log);
|
|
204
|
+
return {
|
|
205
|
+
async query<T>(sql: string, params?: readonly unknown[]): Promise<T[]> {
|
|
206
|
+
if (sql.includes("c.relkind IN ('v', 'm')")) {
|
|
207
|
+
log.push(sql);
|
|
208
|
+
throw new PostgresQueryError("canceling statement due to lock timeout", "55P03");
|
|
209
|
+
}
|
|
210
|
+
if (sql.includes("pg_catalog.pg_locks")) {
|
|
211
|
+
log.push(sql);
|
|
212
|
+
return [{ pid: 4242, relation: "app.orders", application: "psql", state: "idle in transaction", seconds: 12, query: "LOCK TABLE app.orders IN ACCESS EXCLUSIVE MODE" }] as T[];
|
|
213
|
+
}
|
|
214
|
+
return inner.query<T>(sql, params);
|
|
215
|
+
},
|
|
216
|
+
end: () => inner.end(),
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
test("the session's lock_timeout is the profile's lockTimeoutMs, else the applier's 5000 ms", async () => {
|
|
221
|
+
for (const [lockTimeoutMs, expected] of [[undefined, "5000ms"], [250, "250ms"]] as const) {
|
|
222
|
+
const log: string[] = [];
|
|
223
|
+
const params: unknown[][] = [];
|
|
224
|
+
const cfg = { sql: { profiles: { prod: { ...config.sql.profiles.prod, ...(lockTimeoutMs !== undefined ? { lockTimeoutMs } : {}) } } } };
|
|
225
|
+
await describeResources({
|
|
226
|
+
...options({ users: DECLARED.users }),
|
|
227
|
+
config: cfg,
|
|
228
|
+
connect: async () => {
|
|
229
|
+
const c = fakeClient(CATALOG, log);
|
|
230
|
+
return { query: (sql: string, p?: readonly unknown[]) => (params.push([sql, ...(p ?? [])]), c.query(sql, p)), end: c.end } as never;
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
expect(params[0]).toEqual(["SELECT pg_catalog.set_config('lock_timeout', $1, false)", expected]);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("a read that times out on a lock is read-failed, naming the relation and the pid holding it", async () => {
|
|
238
|
+
const log: string[] = [];
|
|
239
|
+
const r = normalizeObservation(await describeResources({ ...options({ users: DECLARED.users }), connect: async () => blocked(log) as never }));
|
|
240
|
+
const users = r.unobserved.users;
|
|
241
|
+
expect(users?.reason).toBe("read-failed");
|
|
242
|
+
expect(users?.detail).toContain("canceling statement due to lock timeout");
|
|
243
|
+
expect(users?.detail).toContain("app.orders is held in ACCESS EXCLUSIVE by pid 4242");
|
|
244
|
+
expect(users?.detail).toContain("lockTimeoutMs");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("other failures pass through unchanged", async () => {
|
|
248
|
+
const { withCatalogLockTimeout } = await import("./client");
|
|
249
|
+
const c = await withCatalogLockTimeout({ query: async (sql: string) => { if (sql.startsWith("SELECT 1")) throw new PostgresQueryError("boom", "XX000"); return []; }, end: async () => {} }, 100);
|
|
250
|
+
await expect(c.query("SELECT 1")).rejects.toThrow(/^boom$/);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog reads under `lock_timeout` (#3678). One session holds ACCESS
|
|
3
|
+
* EXCLUSIVE on a table behind a view; the observation of the view, which
|
|
4
|
+
* reads its definition with `pg_get_viewdef()`, fails after the profile's
|
|
5
|
+
* `lockTimeoutMs` instead of waiting for the lock, and names the table and the
|
|
6
|
+
* pid holding it.
|
|
7
|
+
*
|
|
8
|
+
* `CHANT_SQL_E2E_POSTGRES_URL` (with `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names
|
|
9
|
+
* a running server, else a throwaway one, skipped when Docker is not
|
|
10
|
+
* available. The test makes only the schema `chant_e2e_3678`, dropped
|
|
11
|
+
* afterwards.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
15
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
16
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
17
|
+
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "./client";
|
|
18
|
+
import { describeResources } from "./describe-resources";
|
|
19
|
+
|
|
20
|
+
const SCHEMA = "chant_e2e_3678";
|
|
21
|
+
const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
22
|
+
const enabled = given ? true : await dockerAvailable();
|
|
23
|
+
let server: TestPostgres | undefined;
|
|
24
|
+
let endpoint: PostgresEndpoint;
|
|
25
|
+
let admin: PostgresClient;
|
|
26
|
+
let holder: PostgresClient;
|
|
27
|
+
|
|
28
|
+
describe.skipIf(!enabled)("a catalog read blocked behind ACCESS EXCLUSIVE", () => {
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
31
|
+
else {
|
|
32
|
+
server = await startTestPostgres();
|
|
33
|
+
endpoint = server.endpoint();
|
|
34
|
+
}
|
|
35
|
+
admin = await connectPostgres(endpoint);
|
|
36
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
|
|
37
|
+
await admin.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
38
|
+
await admin.query(`CREATE TABLE ${SCHEMA}.orders (id bigint PRIMARY KEY)`);
|
|
39
|
+
await admin.query(`CREATE VIEW ${SCHEMA}.recent AS SELECT id FROM ${SCHEMA}.orders`);
|
|
40
|
+
holder = await connectPostgres(endpoint, { applicationName: "chant-e2e-3678-holder" });
|
|
41
|
+
await holder.query("BEGIN");
|
|
42
|
+
await holder.query(`LOCK TABLE ${SCHEMA}.orders IN ACCESS EXCLUSIVE MODE`);
|
|
43
|
+
}, 600_000);
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await holder?.query("ROLLBACK").catch(() => undefined);
|
|
47
|
+
await holder?.end();
|
|
48
|
+
await admin?.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => undefined);
|
|
49
|
+
await admin?.end();
|
|
50
|
+
await server?.stop();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("fails after lockTimeoutMs, naming the relation and the pid", async () => {
|
|
54
|
+
const [{ pid }] = await holder.query<{ pid: number }>("SELECT pg_catalog.pg_backend_pid() AS pid");
|
|
55
|
+
const started = Date.now();
|
|
56
|
+
const r = normalizeObservation(await describeResources({
|
|
57
|
+
environment: "e2e",
|
|
58
|
+
config: { sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_3678_PASSWORD" }, schemas: [SCHEMA], lockTimeoutMs: 300 } } } },
|
|
59
|
+
env: { PG_E2E_3678_PASSWORD: endpoint.password ?? "" },
|
|
60
|
+
entityNames: ["recent"],
|
|
61
|
+
entities: new Map([["recent", { entityType: "Postgres::View", props: { schema: SCHEMA, name: "recent" } }]]),
|
|
62
|
+
}));
|
|
63
|
+
expect(Date.now() - started).toBeLessThan(10_000);
|
|
64
|
+
const recent = r.unobserved.recent;
|
|
65
|
+
expect(recent?.reason).toBe("read-failed");
|
|
66
|
+
expect(recent?.detail).toContain("lock timeout");
|
|
67
|
+
expect(recent?.detail).toContain(`${SCHEMA}.orders is held in ACCESS EXCLUSIVE by pid ${pid}`);
|
|
68
|
+
expect(recent?.detail).toContain("chant-e2e-3678-holder");
|
|
69
|
+
}, 60_000);
|
|
70
|
+
});
|
|
@@ -20,8 +20,7 @@ import { POSTGRES_ENTITY_TYPES } from "../entity-types";
|
|
|
20
20
|
import { migrationOpSuggestions } from "../migrate/handoff";
|
|
21
21
|
import { POSTGRES_LATEST_MAJOR } from "../../spec/postgres-pin";
|
|
22
22
|
import { ACCESS_MANAGED_KINDS } from "./normalize";
|
|
23
|
-
import {
|
|
24
|
-
import { readLiveAccess } from "../access/live";
|
|
23
|
+
import { accessChangesAgainst } from "../access/changes";
|
|
25
24
|
|
|
26
25
|
export function emitPg(diff: PgSchemaDiff, json: boolean, title: string): number {
|
|
27
26
|
console.log(json ? JSON.stringify(diff, null, 2) : renderPgDiff(diff, { title }));
|
|
@@ -112,12 +111,7 @@ export async function planAgainstClient(
|
|
|
112
111
|
const major = running ?? options.major;
|
|
113
112
|
const diff = diffPgSchemas(live, declared, { major, access: false });
|
|
114
113
|
if (managed) {
|
|
115
|
-
const
|
|
116
|
-
const have = await readLiveAccess(client, want.targets, { ...(scope ? { schemas: scope } : {}), forRoles: want.forRoles, declaredDefaults: want.targets.filter((t) => t.kind === "default") });
|
|
117
|
-
// An object this plan creates is given what the server gives a new one.
|
|
118
|
-
const missing = want.targets.filter((t) => t.kind !== "default" && t.kind !== "column" && !have.present.has(targetKey(t)));
|
|
119
|
-
const now = new Map([...have.state, ...predictedAccess(missing, have)]);
|
|
120
|
-
const access = diffAccess(now, want.state, want.exportsOf);
|
|
114
|
+
const access = await accessChangesAgainst(client, declared, { major: major ?? POSTGRES_LATEST_MAJOR, ...(scope ? { scope } : {}) });
|
|
121
115
|
if (access.length > 0) {
|
|
122
116
|
diff.changes.push(...access.map((a) => a.change));
|
|
123
117
|
diff.access = access;
|
|
@@ -131,12 +125,6 @@ export async function planAgainstClient(
|
|
|
131
125
|
return { declared, live, liveObjects, diff, ...(major !== undefined ? { major } : {}), ...(scope ? { scope } : {}) };
|
|
132
126
|
}
|
|
133
127
|
|
|
134
|
-
/** The role a session runs as: the one whose own privileges owning an object gives, and whose default privileges apply. */
|
|
135
|
-
async function currentRole(client: PostgresClient): Promise<{ self?: string }> {
|
|
136
|
-
const rows = await client.query<{ self: string }>("SELECT current_user AS self").catch(() => []);
|
|
137
|
-
return rows[0]?.self ? { self: rows[0].self } : {};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
128
|
/**
|
|
141
129
|
* The declarations a plan compares: all of them where the profile manages
|
|
142
130
|
* access; otherwise none of the policies, roles, grants and default
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* of the object (the DDL text, the template source, lineage, reads).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { deepObservation, type DeepObservationResult, type DeepResourceObservation } from "@intentius/chant/deep-observation";
|
|
16
|
+
import { deepObservation, type DeepObservationResult, type DeepPendingChange, type DeepResourceObservation } from "@intentius/chant/deep-observation";
|
|
17
17
|
import type { UnobservedEntity } from "@intentius/chant/lexicon";
|
|
18
18
|
import { bindPostgres, classifyPostgresFailure, type BindOptions } from "../live/bind";
|
|
19
19
|
import { liveKey, readLiveSchema, type LivePgObject } from "../live/catalog";
|
|
@@ -23,6 +23,8 @@ import { POSTGRES_ENTITY_TYPES } from "../entity-types";
|
|
|
23
23
|
import { canonicalPgObject, columnShape, sameConstraint, type CanonicalPgObject } from "./normalize";
|
|
24
24
|
import { serverNormalized } from "./server-normalize";
|
|
25
25
|
import type { PostgresClient } from "../live/client";
|
|
26
|
+
import { accessChangesAgainst } from "../access/changes";
|
|
27
|
+
import { POSTGRES_LATEST_MAJOR } from "../../spec/postgres-pin";
|
|
26
28
|
|
|
27
29
|
type Props = Record<string, unknown>;
|
|
28
30
|
|
|
@@ -214,7 +216,21 @@ export async function observeResourcesDeep(
|
|
|
214
216
|
for (const { name, entity } of declared) unobserved[name] = { type: entity?.entityType ?? "", reason: why.reason, detail: why.detail };
|
|
215
217
|
return deepObservation({}, unobserved);
|
|
216
218
|
}
|
|
219
|
+
const pending: DeepPendingChange[] = [];
|
|
217
220
|
try {
|
|
221
|
+
if (target.access === true) {
|
|
222
|
+
// #3706: the privilege changes an apply would make, which no one
|
|
223
|
+
// declaration's properties show, so the plan digest moves with them.
|
|
224
|
+
try {
|
|
225
|
+
pending.push(...(await pendingAccess(client, target.defaultSchema, declared, scopeFor(target, declared.filter((x) => x.entity?.entityType.startsWith("Postgres::")).map((x) => ({ type: x.entity!.entityType, props: x.entity!.props }))))));
|
|
226
|
+
} catch (err) {
|
|
227
|
+
for (const { name, entity } of declared) {
|
|
228
|
+
if (entity?.entityType === POSTGRES_ENTITY_TYPES.grant || entity?.entityType === POSTGRES_ENTITY_TYPES.defaultPrivileges) {
|
|
229
|
+
unobserved[name] = { type: entity.entityType, reason: "read-failed", detail: `the server's privileges could not be compared: ${(err as Error).message}` };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
218
234
|
const byKey = new Map(live.map((o) => [liveKey(o.type, o.schema, o.name, o.signature), o]));
|
|
219
235
|
for (const { name, entity } of declared) {
|
|
220
236
|
if (!entity || !entity.entityType.startsWith("Postgres::")) {
|
|
@@ -223,6 +239,7 @@ export async function observeResourcesDeep(
|
|
|
223
239
|
}
|
|
224
240
|
const why = accessUnobserved(entity.entityType, target.access === true);
|
|
225
241
|
if (why) {
|
|
242
|
+
if (unobserved[name]) continue;
|
|
226
243
|
unobserved[name] = { type: entity.entityType, ...why };
|
|
227
244
|
continue;
|
|
228
245
|
}
|
|
@@ -245,5 +262,30 @@ export async function observeResourcesDeep(
|
|
|
245
262
|
} finally {
|
|
246
263
|
await client.end();
|
|
247
264
|
}
|
|
248
|
-
return deepObservation(resources, unobserved);
|
|
265
|
+
return deepObservation(resources, unobserved, pending);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The privilege changes an apply would make (#3706), as `chant sql plan`
|
|
270
|
+
* computes them, one per object and grantee: the statements, and the
|
|
271
|
+
* declarations behind them.
|
|
272
|
+
*/
|
|
273
|
+
async function pendingAccess(
|
|
274
|
+
client: PostgresClient,
|
|
275
|
+
defaultSchema: string,
|
|
276
|
+
declared: ReadonlyArray<{ name: string; entity?: { entityType: string; props: Record<string, unknown> } }>,
|
|
277
|
+
scope: string[] | undefined,
|
|
278
|
+
): Promise<DeepPendingChange[]> {
|
|
279
|
+
const objects = declared
|
|
280
|
+
.filter((x) => x.entity?.entityType.startsWith("Postgres::"))
|
|
281
|
+
.map((x) => ({ key: x.name, canonical: canonicalPgObject(x.entity!.entityType, x.entity!.props, defaultSchema) }));
|
|
282
|
+
const [row] = await client.query<{ v: string }>("SELECT current_setting('server_version_num') AS v");
|
|
283
|
+
const n = Number(row?.v);
|
|
284
|
+
const major = Number.isFinite(n) && n > 0 ? Math.floor(n / 10000) : POSTGRES_LATEST_MAJOR;
|
|
285
|
+
const changes = await accessChangesAgainst(client, objects, { major, ...(scope ? { scope } : {}) });
|
|
286
|
+
return changes.map((c) => ({
|
|
287
|
+
subject: c.change.object.startsWith("acl ") ? c.change.object.slice(4) : c.change.object,
|
|
288
|
+
change: c.sql.join("; "),
|
|
289
|
+
...(c.exports.length > 0 ? { entities: [...c.exports].sort() } : {}),
|
|
290
|
+
}));
|
|
249
291
|
}
|
package/src/serializer.test.ts
CHANGED
|
@@ -50,6 +50,8 @@ describe("sql serializer", () => {
|
|
|
50
50
|
expect(sql.indexOf("CREATE DATABASE")).toBeLessThan(sql.indexOf("CREATE TABLE"));
|
|
51
51
|
expect(sql.indexOf("CREATE TABLE")).toBeLessThan(sql.indexOf("CREATE VIEW"));
|
|
52
52
|
expect(r.verbatimFiles).toEqual([CLICKHOUSE_DDL_FILE]);
|
|
53
|
+
// DDL is copied exactly but is not a secret, so it prints with no --output (#3738).
|
|
54
|
+
expect(r.secretFiles).toBeUndefined();
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
test("10. a declarable of another lexicon is not written", () => {
|
|
@@ -98,6 +100,8 @@ describe("sql serializer, Postgres", () => {
|
|
|
98
100
|
expect(doc(r).objects[1]).toMatchObject({ export: "users", type: "Postgres::Table", name: "users", schema: "app", sqlName: "app.users" });
|
|
99
101
|
expect(r.files![POSTGRES_DDL_FILE]).toBe("CREATE SCHEMA app;\n\nCREATE TABLE app.users (id bigint PRIMARY KEY, email text NOT NULL);\n");
|
|
100
102
|
expect(r.verbatimFiles).toEqual([POSTGRES_DDL_FILE]);
|
|
103
|
+
// DDL is copied exactly but is not a secret, so it prints with no --output (#3738).
|
|
104
|
+
expect(r.secretFiles).toBeUndefined();
|
|
101
105
|
});
|
|
102
106
|
|
|
103
107
|
test("a Postgres build records the target major: sql.postgresMajor, else the newest pinned", () => {
|
|
@@ -167,7 +167,7 @@ Transactions follow the class, so a long scan holds locks on one table and what
|
|
|
167
167
|
|
|
168
168
|
A primary key or unique constraint added to an existing table goes in two steps, the unique index built `CONCURRENTLY` and then `ADD CONSTRAINT ... USING INDEX` (SQLPG221), so a busy table is never blocked for the build.
|
|
169
169
|
|
|
170
|
-
Timeouts are set on every statement: `lock_timeout` 5000 ms, so a statement that cannot get its lock fails instead of queueing behind a long transaction and blocking everything behind it; `statement_timeout` 60000 ms for catalog-only statements, and 0 (no limit) for scans, rewrites and `CONCURRENTLY` builds. Set them per environment with `lockTimeoutMs`, `statementTimeoutMs` and `scanTimeoutMs` on `sql.profiles.<env>`, or per call.
|
|
170
|
+
Timeouts are set on every statement: `lock_timeout` 5000 ms, so a statement that cannot get its lock fails instead of queueing behind a long transaction and blocking everything behind it; `statement_timeout` 60000 ms for catalog-only statements, and 0 (no limit) for scans, rewrites and `CONCURRENTLY` builds. Set them per environment with `lockTimeoutMs`, `statementTimeoutMs` and `scanTimeoutMs` on `sql.profiles.<env>`, or per call. The catalog read an apply makes before its first statement waits under the same `lock_timeout`: blocked behind `ACCESS EXCLUSIVE`, the apply stops with nothing written, naming the table and the pid that holds it.
|
|
171
171
|
|
|
172
172
|
```ts
|
|
173
173
|
profiles: { prod: { url: "postgres://db.internal:5432/shop", lockTimeoutMs: 2000, scanTimeoutMs: 600000 } }
|
|
@@ -57,6 +57,8 @@ The swap gate is bound to a digest of the plan and the verification (row counts
|
|
|
57
57
|
- A run stopped during the backfill (Ctrl-C, a killed job) is not a failure. The next run resumes from the receipts in `chant_receipts.receipts` on the same server (in `<db>.__chant_receipts` on a Replicated database), and a partition whose copy was cut off before its receipt is cleared and copied again, so no row is copied twice.
|
|
58
58
|
- A failed step (a failed copy after its retries, a verification mismatch, a refused declaration) runs onFailure: the new table `<t>__chant_new` and the dual-write view `<t>__chant_dual` are dropped, and the next run starts from a new table. With `onFailure: "keep"` they stay, and the next run resumes; drop them by hand to start over. The original table is untouched until the swap.
|
|
59
59
|
- A verification mismatch in materialized-view mode usually means rows arrived with a time before the cut-over later than `cutoverDelay`. Raise the delay and run again.
|
|
60
|
+
- On a Summing, Replacing, Aggregating or Collapsing MergeTree the verification compares both tables under `FINAL`, so unmerged parts in the old table are not a mismatch. A mismatch that remains names the engine: run `OPTIMIZE TABLE <db>.<table> FINAL` and run the rebuild again.
|
|
61
|
+
- A plain MergeTree rebuilt into one of those engines is compared per key of the new sorting key, on what the new engine keeps (a Summing table's sums, a Replacing table's highest version, a Collapsing table's sum of signs), so the different row counts are not a mismatch. A missing key or a changed sum still fails.
|
|
60
62
|
- A backfill that stops naming writes "begun before the cut-over" waited `cutoverTimeout` for INSERTs into the old table that were still running. Let them finish, or stop them, and run again.
|
|
61
63
|
- The Op refuses a database that is neither Atomic nor Replicated (`EXCHANGE TABLES` needs one of them), a table whose change is not a rebuild (use the applier), and an object already under one of its working names that is not its own. In a Replicated database it also refuses a table, or a declaration, whose engine is not `Replicated*MergeTree`: a plain MergeTree keeps different rows on each replica and one replica's copy would lose the others'.
|
|
62
64
|
|
package/src/skills/chant-sql.md
CHANGED
|
@@ -37,7 +37,7 @@ A dictionary is a `dictionary` template holding `CREATE DICTIONARY`, with its at
|
|
|
37
37
|
|
|
38
38
|
A SQL user-defined function is a `func` template holding `CREATE FUNCTION name AS (x) -> expr`, with no database in its name. A changed expression is SQLCH260 (`CREATE OR REPLACE FUNCTION`). It carries no ownership marker, so chant never drops one.
|
|
39
39
|
|
|
40
|
-
Users, roles, row policies and grants are `user`, `role`, `policy` and `grant` templates. Never write a password: `IDENTIFIED BY` is refused, a user declared without `IDENTIFIED` is created by the environment with its password, and chant manages the rest of it. Grants are compared per grantee: every `grant` naming a grantee adds up to its complete list, and anything else it holds is revoked (SQLCH274). None of these is ever dropped by chant.
|
|
40
|
+
Users, roles, row policies and grants are `user`, `role`, `policy` and `grant` templates. Never write a password: `IDENTIFIED BY` is refused, a user declared without `IDENTIFIED` is created by the environment with its password, and chant manages the rest of it. Grants are compared per grantee: every `grant` naming a grantee adds up to its complete list, and anything else it holds is revoked (SQLCH274). None of these is ever dropped by chant. They are planned and applied only where the profile sets `access: true` (`sql.profiles.<env>.access`); elsewhere each is reported `filtered`.
|
|
41
41
|
|
|
42
42
|
## Interpolations
|
|
43
43
|
|