@intentius/chant-lexicon-sql 0.124.31 → 0.124.32
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/change-set.js +250 -0
- package/dist/clickhouse/access.js +722 -0
- package/dist/clickhouse/apply/apply.js +243 -0
- package/dist/clickhouse/apply/mutations.js +66 -0
- package/dist/clickhouse/apply/statements.js +545 -0
- package/dist/clickhouse/catalog-types.js +7 -0
- package/dist/clickhouse/container.js +152 -0
- package/dist/clickhouse/engine-syntax.js +159 -0
- package/dist/clickhouse/entities.js +593 -0
- package/dist/clickhouse/expressions.js +107 -0
- package/dist/clickhouse/http.js +55 -0
- package/dist/clickhouse/import/generator.js +192 -0
- package/dist/clickhouse/import/inlined.js +207 -0
- package/dist/clickhouse/import/ir.js +160 -0
- package/dist/clickhouse/import/live-export.js +131 -0
- package/dist/clickhouse/import/parser.js +17 -0
- package/dist/clickhouse/live/bind.js +116 -0
- package/dist/clickhouse/live/catalog.js +223 -0
- package/dist/clickhouse/live/describe-resources.js +91 -0
- package/dist/clickhouse/live/undeclarable.js +59 -0
- package/dist/clickhouse/overlays/codecs.js +57 -0
- package/dist/clickhouse/overlays/engines.js +101 -0
- package/dist/clickhouse/overlays/kinds.js +1 -0
- package/dist/clickhouse/overlays/projections.js +26 -0
- package/dist/clickhouse/overlays/skip-indexes.js +40 -0
- package/dist/clickhouse/overlays/ttl.js +34 -0
- package/dist/clickhouse/overlays/types.js +59 -0
- package/dist/clickhouse/ownership.js +83 -0
- package/dist/clickhouse/parser.js +674 -0
- package/dist/clickhouse/plan/commands.js +159 -0
- package/dist/clickhouse/plan/deep.js +345 -0
- package/dist/clickhouse/plan/diff.js +312 -0
- package/dist/clickhouse/plan/disruption.js +73 -0
- package/dist/clickhouse/plan/normalize.js +545 -0
- package/dist/clickhouse/plan/rebuild-handoff.js +57 -0
- package/dist/clickhouse/plan/report.js +26 -0
- package/dist/clickhouse/plan/rules.js +72 -0
- package/dist/clickhouse/plan/schema.js +67 -0
- package/dist/clickhouse/plan/server-format.js +35 -0
- package/dist/clickhouse/rebuild/backfill.js +265 -0
- package/dist/clickhouse/rebuild/engines.js +146 -0
- package/dist/clickhouse/rebuild/observe.js +260 -0
- package/dist/clickhouse/rebuild/op.js +152 -0
- package/dist/clickhouse/rebuild/partitions.js +65 -0
- package/dist/clickhouse/rebuild/receipts.js +120 -0
- package/dist/clickhouse/rebuild/replicas.js +121 -0
- package/dist/clickhouse/rebuild/shards.js +76 -0
- package/dist/clickhouse/rebuild/steps.js +383 -0
- package/dist/clickhouse/rebuild/verify.js +279 -0
- package/dist/clickhouse/row-types.js +151 -0
- package/dist/clickhouse/testing/fake-server.js +47 -0
- package/dist/clickhouse/testing/roundtrip-schema.js +25 -0
- package/dist/clickhouse/testing/writable-server.js +123 -0
- package/dist/clickhouse/tokens.js +23 -0
- package/dist/clickhouse/topology.js +463 -0
- package/dist/clickhouse/type-syntax.js +73 -0
- package/dist/clickhouse.js +25 -0
- package/dist/codegen/classifier-page.js +93 -0
- package/dist/codegen/clickhouse-module.js +249 -0
- package/dist/codegen/coverage-page.js +70 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +222 -0
- package/dist/codegen/generate-cli.js +13 -0
- package/dist/codegen/generate.js +97 -0
- package/dist/codegen/package.js +28 -0
- package/dist/codegen/postgres-classifier-page.js +95 -0
- package/dist/codegen/postgres-module.js +418 -0
- package/dist/codegen/registry.js +52 -0
- package/dist/composites/audit-log-table.js +35 -0
- package/dist/composites/catalog.js +524 -0
- package/dist/composites/cdc-mirror.js +32 -0
- package/dist/composites/clickhouse.js +6 -0
- package/dist/composites/events-table.js +26 -0
- package/dist/composites/index.js +8 -0
- package/dist/composites/join-table.js +25 -0
- package/dist/composites/postgres.js +6 -0
- package/dist/composites/refreshed-view.js +24 -0
- package/dist/composites/replacing-table.js +28 -0
- package/dist/composites/rollup-view.js +29 -0
- package/dist/composites/sharded-table.js +27 -0
- package/dist/composites/soft-delete-table.js +34 -0
- package/dist/composites/tenant-table.js +26 -0
- package/dist/composites/typed.js +8 -0
- package/dist/config.js +206 -0
- package/dist/core/apply.js +93 -0
- package/dist/core/classifier.js +115 -0
- package/dist/core/container.js +69 -0
- package/dist/core/cursor.js +261 -0
- package/dist/core/diff.js +48 -0
- package/dist/core/entity.js +58 -0
- package/dist/core/find-templates.js +118 -0
- package/dist/core/foreign-tables.js +27 -0
- package/dist/core/handoff.js +32 -0
- package/dist/core/index.js +64 -0
- package/dist/core/interpolation.js +191 -0
- package/dist/core/normalize.js +43 -0
- package/dist/core/ownership.js +119 -0
- package/dist/core/receipts.js +22 -0
- package/dist/core/references.js +61 -0
- package/dist/core/statements.js +42 -0
- package/dist/core/template.js +79 -0
- package/dist/core/testing/http-stub.js +21 -0
- package/dist/core/tokens.js +202 -0
- package/dist/core/typed.js +40 -0
- package/dist/core/undeclarable.js +46 -0
- package/dist/coverage.js +161 -0
- package/dist/detect.js +32 -0
- package/dist/dialects.js +18 -0
- package/dist/files/build-root.js +72 -0
- package/dist/files/clickhouse.js +138 -0
- package/dist/files/common.js +58 -0
- package/dist/files/postgres.js +389 -0
- package/dist/generated/clickhouse-types.js +5 -0
- package/dist/generated/clickhouse.js +4361 -0
- package/dist/generated/postgres-types.js +227 -0
- package/dist/generated/postgres.js +3667 -0
- package/dist/import-generator.js +14 -0
- package/dist/import-parser.js +71 -0
- package/dist/index.js +24 -0
- package/dist/init-templates.js +290 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +31 -0
- package/dist/lint/audit-lineage.js +22 -0
- package/dist/lint/post-synth/audit-entries.js +34 -0
- package/dist/lint/post-synth/clickhouse-helpers.js +79 -0
- package/dist/lint/post-synth/clickhouse-type-check.js +162 -0
- package/dist/lint/post-synth/index.js +112 -0
- package/dist/lint/post-synth/postgres-audit-entries.js +35 -0
- package/dist/lint/post-synth/postgres-helpers.js +65 -0
- package/dist/lint/post-synth/postgres-names.js +507 -0
- package/dist/lint/post-synth/sql-helpers.js +23 -0
- package/dist/lint/post-synth/sql101.js +59 -0
- package/dist/lint/post-synth/sql102.js +34 -0
- package/dist/lint/post-synth/sqlch101.js +34 -0
- package/dist/lint/post-synth/sqlch102.js +23 -0
- package/dist/lint/post-synth/sqlch103.js +45 -0
- package/dist/lint/post-synth/sqlch104.js +41 -0
- package/dist/lint/post-synth/sqlch105.js +36 -0
- package/dist/lint/post-synth/sqlch106.js +27 -0
- package/dist/lint/post-synth/sqlch107.js +39 -0
- package/dist/lint/post-synth/sqlch108.js +28 -0
- package/dist/lint/post-synth/sqlch109.js +21 -0
- package/dist/lint/post-synth/sqlch110.js +31 -0
- package/dist/lint/post-synth/sqlch111.js +41 -0
- package/dist/lint/post-synth/sqlch112.js +28 -0
- package/dist/lint/post-synth/sqlch113.js +19 -0
- package/dist/lint/post-synth/sqlch114.js +26 -0
- package/dist/lint/post-synth/sqlch115.js +30 -0
- package/dist/lint/post-synth/sqlch116.js +22 -0
- package/dist/lint/post-synth/sqlch117.js +26 -0
- package/dist/lint/post-synth/sqlch118.js +27 -0
- package/dist/lint/post-synth/sqlch119.js +32 -0
- package/dist/lint/post-synth/sqlch120.js +20 -0
- package/dist/lint/post-synth/sqlch121.js +11 -0
- package/dist/lint/post-synth/sqlch122.js +11 -0
- package/dist/lint/post-synth/sqlch123.js +10 -0
- package/dist/lint/post-synth/sqlch124.js +21 -0
- package/dist/lint/post-synth/sqlch125.js +55 -0
- package/dist/lint/post-synth/sqlch126.js +62 -0
- package/dist/lint/post-synth/sqlch127.js +132 -0
- package/dist/lint/post-synth/sqlpg101.js +25 -0
- package/dist/lint/post-synth/sqlpg102.js +35 -0
- package/dist/lint/post-synth/sqlpg103.js +23 -0
- package/dist/lint/post-synth/sqlpg104.js +23 -0
- package/dist/lint/post-synth/sqlpg105.js +22 -0
- package/dist/lint/post-synth/sqlpg106.js +22 -0
- package/dist/lint/post-synth/sqlpg107.js +22 -0
- package/dist/lint/post-synth/sqlpg108.js +28 -0
- package/dist/lint/post-synth/sqlpg109.js +39 -0
- package/dist/lint/post-synth/sqlpg110.js +38 -0
- package/dist/lint/post-synth/sqlpg111.js +23 -0
- package/dist/lint/post-synth/sqlpg112.js +25 -0
- package/dist/lint/post-synth/sqlpg113.js +29 -0
- package/dist/lint/post-synth/sqlpg114.js +39 -0
- package/dist/lint/post-synth/sqlpg115.js +48 -0
- package/dist/lint/post-synth/sqlpg116.js +26 -0
- package/dist/lint/post-synth/sqlpg117.js +24 -0
- package/dist/lint/post-synth/sqlpg118.js +22 -0
- package/dist/lint/post-synth/sqlpg119.js +41 -0
- package/dist/lint/post-synth/sqlpg120.js +83 -0
- package/dist/lint/post-synth/sqlpg121.js +33 -0
- package/dist/lint/post-synth/sqlpg122.js +27 -0
- package/dist/lint/post-synth/sqlpg123.js +69 -0
- package/dist/lint/post-synth/sqlpg124.js +50 -0
- package/dist/lint/post-synth/sqlpg125.js +41 -0
- package/dist/lint/post-synth/sqlpg126.js +38 -0
- package/dist/lint/rules/index.js +12 -0
- package/dist/lint/rules/postgres-templates.js +27 -0
- package/dist/lint/rules/sqlch001.js +79 -0
- package/dist/lint/rules/sqlch002.js +124 -0
- package/dist/lint/rules/sqlch003.js +69 -0
- package/dist/lint/rules/sqlch004.js +83 -0
- package/dist/lint/rules/sqlpg001.js +68 -0
- package/dist/lint/rules/sqlpg002.js +69 -0
- package/dist/lint/rules/sqlpg003.js +58 -0
- package/dist/lint/rules/sqlpg004.js +77 -0
- package/dist/lint/rules/sqlpg005.js +86 -0
- package/dist/lint/rules/templates.js +25 -0
- package/dist/lint/rules/typed-names.js +132 -0
- package/dist/live-dialect.js +32 -0
- package/dist/lsp/catalog.js +44 -0
- package/dist/lsp/completions.js +136 -0
- package/dist/lsp/hover.js +115 -0
- package/dist/lsp/postgres-catalog.js +176 -0
- package/dist/lsp/postgres-template.js +171 -0
- package/dist/lsp/postgres.js +266 -0
- package/dist/lsp/refs.js +60 -0
- package/dist/lsp/registry.d.ts.map +1 -1
- package/dist/lsp/registry.js +19 -0
- package/dist/lsp/template.js +143 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +293 -0
- package/dist/mcp/postgres.js +117 -0
- package/dist/migration-statements.js +243 -0
- package/dist/op/activities/clickhouse-apply.js +83 -0
- package/dist/op/activities/clickhouse-emulator.js +53 -0
- package/dist/op/activities/clickhouse-rebuild.js +119 -0
- package/dist/op/activities/index.js +26 -0
- package/dist/op/activities/postgres-apply.js +126 -0
- package/dist/op/activities/postgres-emulator.js +70 -0
- package/dist/op/activities/postgres-migration.js +156 -0
- package/dist/op/activities/sql-exec.js +57 -0
- package/dist/op/activity-contracts.js +103 -0
- package/dist/package-cli.js +14 -0
- package/dist/plugin.js +259 -0
- package/dist/postgres/access/acl.js +344 -0
- package/dist/postgres/access/changes.js +30 -0
- package/dist/postgres/access/import.js +61 -0
- package/dist/postgres/access/live.js +96 -0
- package/dist/postgres/apply/apply.js +557 -0
- package/dist/postgres/apply/statements.js +882 -0
- package/dist/postgres/catalog-types.js +18 -0
- package/dist/postgres/container.js +58 -0
- package/dist/postgres/entities.js +1390 -0
- package/dist/postgres/entity-types.js +28 -0
- package/dist/postgres/import/generator.js +283 -0
- package/dist/postgres/import/ir.js +53 -0
- package/dist/postgres/import/live-export.js +83 -0
- package/dist/postgres/keywords.js +32 -0
- package/dist/postgres/live/bind.js +130 -0
- package/dist/postgres/live/catalog-kinds.js +301 -0
- package/dist/postgres/live/catalog.js +644 -0
- package/dist/postgres/live/client.js +151 -0
- package/dist/postgres/live/describe-resources.js +135 -0
- package/dist/postgres/live/undeclarable.js +111 -0
- package/dist/postgres/migrate/backfill.js +170 -0
- package/dist/postgres/migrate/batches.js +122 -0
- package/dist/postgres/migrate/carry.js +518 -0
- package/dist/postgres/migrate/handoff.js +59 -0
- package/dist/postgres/migrate/names.js +61 -0
- package/dist/postgres/migrate/observe.js +360 -0
- package/dist/postgres/migrate/op.js +178 -0
- package/dist/postgres/migrate/receipts.js +117 -0
- package/dist/postgres/migrate/replication.js +106 -0
- package/dist/postgres/migrate/steps.js +514 -0
- package/dist/postgres/overlays/columns.js +31 -0
- package/dist/postgres/overlays/constraints.js +52 -0
- package/dist/postgres/overlays/extension-objects.js +88 -0
- package/dist/postgres/overlays/indexes.js +25 -0
- package/dist/postgres/overlays/kinds.js +2 -0
- package/dist/postgres/overlays/partitioning.js +23 -0
- package/dist/postgres/overlays/relations.js +25 -0
- package/dist/postgres/overlays/storage.js +65 -0
- package/dist/postgres/overlays/type-ddl.js +36 -0
- package/dist/postgres/overlays/types.js +74 -0
- package/dist/postgres/parser.js +1824 -0
- package/dist/postgres/plan/commands.js +137 -0
- package/dist/postgres/plan/deep.js +324 -0
- package/dist/postgres/plan/diff.js +499 -0
- package/dist/postgres/plan/disruption.js +64 -0
- package/dist/postgres/plan/normalize.js +889 -0
- package/dist/postgres/plan/report.js +21 -0
- package/dist/postgres/plan/rules.js +115 -0
- package/dist/postgres/plan/schema.js +105 -0
- package/dist/postgres/plan/server-normalize.js +166 -0
- package/dist/postgres/providers/aurora.js +44 -0
- package/dist/postgres/providers/azure.js +53 -0
- package/dist/postgres/providers/cloud-sql.js +41 -0
- package/dist/postgres/providers/index.js +73 -0
- package/dist/postgres/providers/neon.js +44 -0
- package/dist/postgres/providers/rds.js +47 -0
- package/dist/postgres/providers/supabase.js +45 -0
- package/dist/postgres/providers/types.js +21 -0
- package/dist/postgres/row-types.js +107 -0
- package/dist/postgres/testing/fake-client.js +34 -0
- package/dist/postgres/testing/migration.js +48 -0
- package/dist/postgres/testing/roundtrip-schema.js +64 -0
- package/dist/postgres/testing/server.js +47 -0
- package/dist/postgres/testing/writable-server.js +160 -0
- package/dist/postgres/tokens.js +41 -0
- package/dist/postgres.js +15 -0
- package/dist/read/clickhouse.js +82 -0
- package/dist/read/objects.js +44 -0
- package/dist/read/postgres.js +104 -0
- package/dist/read.js +52 -0
- package/dist/receipts.js +207 -0
- package/dist/serializer.js +172 -0
- package/dist/skill-defs.js +93 -0
- package/dist/spec/catalog.js +191 -0
- package/dist/spec/fetch.d.ts.map +1 -1
- package/dist/spec/fetch.js +90 -0
- package/dist/spec/pin.js +42 -0
- package/dist/spec/postgres-catalog.js +171 -0
- package/dist/spec/postgres-fetch.js +91 -0
- package/dist/spec/postgres-pin.js +82 -0
- package/dist/sql-files.js +206 -0
- package/dist/sql-types-command.js +97 -0
- package/dist/sql-types.js +323 -0
- package/dist/token-source.js +156 -0
- package/dist/validate-cli.js +5 -0
- package/dist/validate.js +151 -0
- package/dist/workspace-outputs.js +191 -0
- package/package.json +7 -7
- package/src/codegen/package.ts +2 -2
- package/src/lint/rules/typescript-7.test.ts +2 -1
- package/src/lsp/catalog.ts +2 -2
- package/src/lsp/postgres-catalog.ts +2 -2
- package/src/lsp/registry.ts +2 -1
- package/src/mcp/index.ts +4 -2
- package/src/spec/fetch.ts +2 -2
- package/src/spec/postgres-fetch.ts +2 -2
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Postgres wire client: node-postgres (`pg`), loaded the first time a
|
|
3
|
+
* Postgres server is read, so a ClickHouse-only project never loads it.
|
|
4
|
+
*
|
|
5
|
+
* Every session reads the catalog with an empty `search_path`, as `pg_dump`
|
|
6
|
+
* does: `pg_get_*def()` and `format_type()` then qualify every name outside
|
|
7
|
+
* `pg_catalog`, so what a server prints does not depend on the role's own
|
|
8
|
+
* search path.
|
|
9
|
+
*/
|
|
10
|
+
/** A failed statement, with the server's SQLSTATE when it sent one. */
|
|
11
|
+
export class PostgresQueryError extends Error {
|
|
12
|
+
constructor(message, code, position) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.position = position;
|
|
16
|
+
this.name = "PostgresQueryError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
let loaded;
|
|
20
|
+
async function pgModule() {
|
|
21
|
+
loaded ?? (loaded = import("pg").then((m) => (m.default ?? m)));
|
|
22
|
+
return loaded;
|
|
23
|
+
}
|
|
24
|
+
const wrap = (err) => {
|
|
25
|
+
const e = err;
|
|
26
|
+
return new PostgresQueryError(e.message ?? String(err), e.code, e.position !== undefined ? Number(e.position) : undefined);
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The URL with libpq's meaning of `sslmode`. pg-connection-string reads
|
|
30
|
+
* `sslmode=require` as verify-full unless `uselibpqcompat=true` is set, so an
|
|
31
|
+
* RDS host without its CA bundle, or a tunnel whose host name doesn't match
|
|
32
|
+
* the certificate, fails to connect. In psql, `require` encrypts without
|
|
33
|
+
* verifying, and a profile url means what it means there. A url that sets
|
|
34
|
+
* `uselibpqcompat` itself is left alone.
|
|
35
|
+
*/
|
|
36
|
+
export function withLibpqSslMode(url) {
|
|
37
|
+
if (url.searchParams.has("sslmode") && !url.searchParams.has("uselibpqcompat"))
|
|
38
|
+
url.searchParams.set("uselibpqcompat", "true");
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
/** Connect to `endpoint` and set the catalog-reading session up. */
|
|
42
|
+
export async function connectPostgres(endpoint, options = {}) {
|
|
43
|
+
const pg = await pgModule();
|
|
44
|
+
// node-postgres lets the connection string's fields win over the config's, so the credentials go into the URL.
|
|
45
|
+
const url = withLibpqSslMode(new URL(endpoint.url));
|
|
46
|
+
if (endpoint.user !== undefined)
|
|
47
|
+
url.username = encodeURIComponent(endpoint.user);
|
|
48
|
+
const password = endpoint.token ? await endpoint.token.get() : endpoint.password;
|
|
49
|
+
if (password !== undefined)
|
|
50
|
+
url.password = encodeURIComponent(password);
|
|
51
|
+
const client = new pg.Client({ connectionString: url.toString(), application_name: options.applicationName ?? "chant" });
|
|
52
|
+
// A server that goes away emits an error event; the next query fails with it instead of the process crashing.
|
|
53
|
+
client.on("error", () => undefined);
|
|
54
|
+
try {
|
|
55
|
+
await client.connect();
|
|
56
|
+
await client.query("SELECT pg_catalog.set_config('search_path', '', false)");
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
await client.end().catch(() => undefined);
|
|
60
|
+
const wrapped = wrap(err);
|
|
61
|
+
// A refused token is not reused: the next connect mints another.
|
|
62
|
+
if (endpoint.token && (wrapped.code === "28P01" || wrapped.code === "28000"))
|
|
63
|
+
endpoint.token.invalidate();
|
|
64
|
+
throw wrapped;
|
|
65
|
+
}
|
|
66
|
+
let ended = false;
|
|
67
|
+
return {
|
|
68
|
+
async query(sql, params) {
|
|
69
|
+
try {
|
|
70
|
+
const r = await client.query(sql, params);
|
|
71
|
+
return r.rows;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
throw wrap(err);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
async end() {
|
|
78
|
+
if (ended)
|
|
79
|
+
return;
|
|
80
|
+
ended = true;
|
|
81
|
+
await client.end().catch(() => undefined);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** SQLSTATE lock_not_available: `lock_timeout` passed while a statement waited for a lock. */
|
|
86
|
+
export const SQLSTATE_LOCK_NOT_AVAILABLE = "55P03";
|
|
87
|
+
/** The `lock_timeout` a statement waits under when the profile sets none (`sql.profiles.<env>.lockTimeoutMs`): the applier's and the catalog reader's. */
|
|
88
|
+
export const DEFAULT_LOCK_TIMEOUT_MS = 5000;
|
|
89
|
+
/**
|
|
90
|
+
* The sessions holding ACCESS EXCLUSIVE on a relation in the current database,
|
|
91
|
+
* other than this one. Only that mode conflicts with the ACCESS SHARE a
|
|
92
|
+
* catalog function (`pg_get_viewdef()`, `pg_get_indexdef()`) takes. Empty when
|
|
93
|
+
* the lookup itself fails.
|
|
94
|
+
*/
|
|
95
|
+
export async function accessExclusiveHolders(client) {
|
|
96
|
+
try {
|
|
97
|
+
const rows = await client.query(`SELECT l.pid, l.relation::pg_catalog.regclass::text AS relation, a.application_name AS application, a.state,
|
|
98
|
+
(EXTRACT(EPOCH FROM pg_catalog.now() - a.xact_start))::int AS seconds, pg_catalog.left(a.query, 120) AS query
|
|
99
|
+
FROM pg_catalog.pg_locks l LEFT JOIN pg_catalog.pg_stat_activity a ON a.pid = l.pid
|
|
100
|
+
WHERE l.locktype = 'relation' AND l.granted AND l.mode = 'AccessExclusiveLock'
|
|
101
|
+
AND l.pid <> pg_catalog.pg_backend_pid()
|
|
102
|
+
AND l.database = (SELECT oid FROM pg_catalog.pg_database WHERE datname = pg_catalog.current_database())
|
|
103
|
+
ORDER BY l.pid, 2 LIMIT 10`);
|
|
104
|
+
return rows.map((r) => ({
|
|
105
|
+
pid: Number(r.pid),
|
|
106
|
+
relation: r.relation,
|
|
107
|
+
...(r.application ? { application: r.application } : {}),
|
|
108
|
+
...(r.state ? { state: r.state } : {}),
|
|
109
|
+
...(r.seconds !== null && r.seconds !== undefined ? { seconds: Number(r.seconds) } : {}),
|
|
110
|
+
...(r.query ? { query: r.query.replace(/\s+/g, " ").trim() } : {}),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** One line naming the lock holders, for a message. */
|
|
118
|
+
export function describeLockHolders(holders) {
|
|
119
|
+
if (holders.length === 0)
|
|
120
|
+
return "no session holds ACCESS EXCLUSIVE now; the lock was released after the wait";
|
|
121
|
+
return holders
|
|
122
|
+
.map((h) => {
|
|
123
|
+
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);
|
|
124
|
+
return `${h.relation} is held in ACCESS EXCLUSIVE by pid ${h.pid}${about.length > 0 ? ` (${about.join(", ")})` : ""}`;
|
|
125
|
+
})
|
|
126
|
+
.join("; ");
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* A client for catalog reads: the session's `lock_timeout` is `lockTimeoutMs`
|
|
130
|
+
* (0 waits without limit, as Postgres does), so a read that needs a table
|
|
131
|
+
* another session holds in ACCESS EXCLUSIVE (a long `ALTER TABLE`, `LOCK
|
|
132
|
+
* TABLE`) fails after the wait instead of hanging. The failure names the
|
|
133
|
+
* relation and the pid holding it.
|
|
134
|
+
*/
|
|
135
|
+
export async function withCatalogLockTimeout(client, lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS) {
|
|
136
|
+
await client.query("SELECT pg_catalog.set_config('lock_timeout', $1, false)", [`${lockTimeoutMs}ms`]);
|
|
137
|
+
return {
|
|
138
|
+
async query(sql, params) {
|
|
139
|
+
try {
|
|
140
|
+
return await client.query(sql, params);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (!(err instanceof PostgresQueryError) || err.code !== SQLSTATE_LOCK_NOT_AVAILABLE)
|
|
144
|
+
throw err;
|
|
145
|
+
const holders = describeLockHolders(await accessExclusiveHolders(client));
|
|
146
|
+
throw new PostgresQueryError(`${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`, err.code, err.position);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
end: () => client.end(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `describeResources()` for the Postgres dialect: which declared objects exist
|
|
3
|
+
* on the environment's server.
|
|
4
|
+
*
|
|
5
|
+
* One catalog read per run (`./catalog.ts`), then a lookup per declared
|
|
6
|
+
* entity, so N entities are a fixed handful of queries. An object the catalog
|
|
7
|
+
* does not list is absent, and the `queried` address names the server and the
|
|
8
|
+
* object asked for. A server that cannot be reached, or refuses the
|
|
9
|
+
* credentials, leaves every entity unobserved with that reason: never absent.
|
|
10
|
+
*
|
|
11
|
+
* Ownership is chant's marker, the trailer on the object's comment
|
|
12
|
+
* (`../../core/ownership.ts`): `owned` when the comment carries it, `foreign`
|
|
13
|
+
* when it does not. With `owned: true` a foreign object is withheld as
|
|
14
|
+
* `filtered`. An object another tool keeps (an ORM's revision table) is
|
|
15
|
+
* foreign whatever its comment says.
|
|
16
|
+
*/
|
|
17
|
+
import { observeEntities, } from "@intentius/chant/observation";
|
|
18
|
+
import { bindPostgres, classifyPostgresFailure, redactUrl } from "./bind.js";
|
|
19
|
+
import { liveKey, markProviderOwned, readLiveSchema } from "./catalog.js";
|
|
20
|
+
import { POSTGRES_ENTITY_TYPES } from "../entity-types.js";
|
|
21
|
+
import { isChantManaged, readMarker, stripMarker } from "../../core/ownership.js";
|
|
22
|
+
import { identValue } from "../parser.js";
|
|
23
|
+
import { canonicalPgObject } from "../plan/normalize.js";
|
|
24
|
+
/**
|
|
25
|
+
* The schema and name a declared entity is created as; a bare name is in the
|
|
26
|
+
* default schema. A routine's parameter types and a trigger's table tell it
|
|
27
|
+
* from another of the same name (`signature`).
|
|
28
|
+
*/
|
|
29
|
+
export function declaredAddress(entity, defaultSchema) {
|
|
30
|
+
const name = String(entity.props.name ?? "");
|
|
31
|
+
if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension || entity.type === POSTGRES_ENTITY_TYPES.role || entity.type === POSTGRES_ENTITY_TYPES.eventTrigger)
|
|
32
|
+
return { name };
|
|
33
|
+
// A cast is named by its two types, as the catalog prints them.
|
|
34
|
+
if (entity.type === POSTGRES_ENTITY_TYPES.cast)
|
|
35
|
+
return { name: canonicalPgObject(entity.type, entity.props, defaultSchema).name };
|
|
36
|
+
const schema = typeof entity.props.schema === "string" ? entity.props.schema : identValue(defaultSchema);
|
|
37
|
+
if (entity.type === POSTGRES_ENTITY_TYPES.function ||
|
|
38
|
+
entity.type === POSTGRES_ENTITY_TYPES.procedure ||
|
|
39
|
+
entity.type === POSTGRES_ENTITY_TYPES.aggregate ||
|
|
40
|
+
entity.type === POSTGRES_ENTITY_TYPES.trigger ||
|
|
41
|
+
entity.type === POSTGRES_ENTITY_TYPES.policy) {
|
|
42
|
+
const signature = canonicalPgObject(entity.type, entity.props, defaultSchema).signature;
|
|
43
|
+
return { schema, name, ...(signature !== undefined ? { signature } : {}) };
|
|
44
|
+
}
|
|
45
|
+
return { schema, name };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Why an access declaration is not observed one by one: a grant or default
|
|
49
|
+
* privileges, which `chant sql plan` compares as the access they add up to;
|
|
50
|
+
* a policy or role where the profile does not manage access.
|
|
51
|
+
*/
|
|
52
|
+
export function accessUnobserved(type, managed) {
|
|
53
|
+
if (type === POSTGRES_ENTITY_TYPES.grant || type === POSTGRES_ENTITY_TYPES.defaultPrivileges) {
|
|
54
|
+
return { reason: "unsupported-kind", detail: "a grant is compared as the privileges the declarations add up to, by chant sql plan and the applier, not one statement at a time" };
|
|
55
|
+
}
|
|
56
|
+
if (!managed && (type === POSTGRES_ENTITY_TYPES.policy || type === POSTGRES_ENTITY_TYPES.role)) {
|
|
57
|
+
return { reason: "filtered", detail: "access is not managed in this environment (sql.profiles.<env>.access)" };
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/** A schema scope for the catalog read: the profile's schemas, else every schema the declarations name and the default one. */
|
|
62
|
+
export function scopeFor(target, entities) {
|
|
63
|
+
if (target.schemas)
|
|
64
|
+
return target.schemas;
|
|
65
|
+
if (entities.length === 0)
|
|
66
|
+
return undefined;
|
|
67
|
+
const out = new Set([target.defaultSchema]);
|
|
68
|
+
// The schema alone: the rest of an address (a routine's parameter types) needs props a caller may not have.
|
|
69
|
+
const unplaced = new Set([POSTGRES_ENTITY_TYPES.extension, POSTGRES_ENTITY_TYPES.role, POSTGRES_ENTITY_TYPES.cast, POSTGRES_ENTITY_TYPES.eventTrigger]);
|
|
70
|
+
for (const e of entities) {
|
|
71
|
+
if (e.type === POSTGRES_ENTITY_TYPES.schema)
|
|
72
|
+
out.add(String(e.props.name ?? ""));
|
|
73
|
+
else if (!unplaced.has(e.type))
|
|
74
|
+
out.add(typeof e.props.schema === "string" ? e.props.schema : identValue(target.defaultSchema));
|
|
75
|
+
}
|
|
76
|
+
return [...out].sort();
|
|
77
|
+
}
|
|
78
|
+
function adapter(options) {
|
|
79
|
+
return {
|
|
80
|
+
async bind() {
|
|
81
|
+
const { target, client } = await bindPostgres(options);
|
|
82
|
+
try {
|
|
83
|
+
const live = markProviderOwned(await readLiveSchema(client, {
|
|
84
|
+
schemas: scopeFor(target, options.declared.filter((d) => d.type.startsWith("Postgres::"))),
|
|
85
|
+
access: target.access === true,
|
|
86
|
+
roles: options.declared.filter((d) => d.type === POSTGRES_ENTITY_TYPES.role).map((d) => String(d.props.name)),
|
|
87
|
+
}), target.provider);
|
|
88
|
+
return { target, byKey: new Map(live.map((o) => [liveKey(o.type, o.schema, o.name, o.signature), o])) };
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await client.end();
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
classifyBindFailure: (err) => classifyPostgresFailure(err),
|
|
95
|
+
async read({ target, byKey }, entity) {
|
|
96
|
+
if (!entity.type.startsWith("Postgres::"))
|
|
97
|
+
return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
|
|
98
|
+
const why = accessUnobserved(entity.type, target.access === true);
|
|
99
|
+
if (why)
|
|
100
|
+
return { unobserved: why };
|
|
101
|
+
const { schema, name, signature } = declaredAddress(entity, target.defaultSchema);
|
|
102
|
+
const queried = `${redactUrl(target.endpoint.url)} ${schema ? `${schema}.` : ""}${name}${signature ?? ""}`;
|
|
103
|
+
const live = byKey.get(liveKey(entity.type, schema, name, signature));
|
|
104
|
+
if (!live)
|
|
105
|
+
return { absent: true, queried };
|
|
106
|
+
const owned = !live.foreign && isChantManaged(live.comment);
|
|
107
|
+
if (options.owned && !owned) {
|
|
108
|
+
return {
|
|
109
|
+
unobserved: { reason: "filtered", detail: live.foreign ? `${live.foreign} keeps this object` : "the object's comment carries no chant ownership marker and owned was requested" },
|
|
110
|
+
queried,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const marker = readMarker(live.comment);
|
|
114
|
+
const comment = live.comment ? stripMarker(live.comment) : "";
|
|
115
|
+
return {
|
|
116
|
+
present: {
|
|
117
|
+
type: live.type,
|
|
118
|
+
physicalId: live.oid,
|
|
119
|
+
status: "PRESENT",
|
|
120
|
+
attributes: { oid: live.oid, ...(comment ? { comment } : {}), ...(live.foreign ? { keptBy: live.foreign } : {}) },
|
|
121
|
+
ownership: owned ? "owned" : "foreign",
|
|
122
|
+
...(owned && marker?.stack ? { marker: { stack: marker.stack, ...(marker.env ? { env: marker.env } : {}) } } : {}),
|
|
123
|
+
},
|
|
124
|
+
queried,
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export async function describeResources(options) {
|
|
130
|
+
const declared = options.entityNames.map((name) => {
|
|
131
|
+
const entity = options.entities.get(name);
|
|
132
|
+
return { name, type: entity?.entityType ?? "", props: entity?.props ?? {} };
|
|
133
|
+
});
|
|
134
|
+
return observeEntities(declared, adapter({ ...options, declared }));
|
|
135
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The objects in a live Postgres database of the kinds no declaration holds
|
|
3
|
+
* (#3911), found so import can refuse them by name instead of leaving them
|
|
4
|
+
* out (`../../core/undeclarable.ts`).
|
|
5
|
+
*
|
|
6
|
+
* One catalog query per kind the coverage table marks refused
|
|
7
|
+
* (`../../coverage/postgres.json`), keyed by the row's `kind`. A kind whose
|
|
8
|
+
* row is flipped to declared is no longer asked about: its objects are then
|
|
9
|
+
* the catalog reader's (`./catalog.ts`) to read.
|
|
10
|
+
*
|
|
11
|
+
* Left out, as the reader leaves them out: the server's own schemas, objects
|
|
12
|
+
* an extension owns (`pg_depend.deptype = 'e'`; the extension is declared),
|
|
13
|
+
* the objects `initdb` makes, whose oids are below 16384
|
|
14
|
+
* (`FirstNormalObjectId`), and an object that is part of another
|
|
15
|
+
* (`deptype = 'i'`: the casts a range type makes for itself). An object in a
|
|
16
|
+
* schema is looked for in the schemas in scope; one that belongs to the whole
|
|
17
|
+
* database (a cast, an event trigger, a publication, a foreign data wrapper)
|
|
18
|
+
* is always looked for.
|
|
19
|
+
*/
|
|
20
|
+
import { notExtensionOwned, scopeSql } from "./catalog.js";
|
|
21
|
+
import { KIND_COVERAGE } from "../../coverage.js";
|
|
22
|
+
/** `FirstNormalObjectId`: every oid below it is the server's own. */
|
|
23
|
+
const FIRST_USER_OID = 16384;
|
|
24
|
+
const C = 'COLLATE "C"';
|
|
25
|
+
/** Not part of another object (`pg_depend.deptype = 'i'`): the multirange cast a range type makes for itself is the range type's. */
|
|
26
|
+
const notInternal = (catalog, oid) => `NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d WHERE d.classid = 'pg_catalog.${catalog}'::pg_catalog.regclass AND d.objid = ${oid} AND d.deptype = 'i')`;
|
|
27
|
+
const ns = (alias, column) => `JOIN pg_catalog.pg_namespace ${alias} ON ${alias}.oid = ${column}`;
|
|
28
|
+
const args = (oid) => `'(' || pg_catalog.replace(pg_catalog.oidvectortypes(${oid}), ', ', ',') || ')'`;
|
|
29
|
+
/** A probe for objects of a catalog in a schema, the catalog aliased `x`; `name` is the name expression. */
|
|
30
|
+
const inSchema = (catalog, nsColumn, name, where = "TRUE") => ({
|
|
31
|
+
sql: (scope) => `SELECT n.nspname AS schema, ${name} AS name, NULL::text AS owner FROM pg_catalog.${catalog} x ${ns("n", `x.${nsColumn}`)}
|
|
32
|
+
WHERE ${where} AND ${scope} AND x.oid >= ${FIRST_USER_OID} AND ${notExtensionOwned(catalog, "x.oid")} AND ${notInternal(catalog, "x.oid")}`,
|
|
33
|
+
});
|
|
34
|
+
/** A probe for objects that belong to the database, not a schema. */
|
|
35
|
+
const databaseWide = (catalog, name, owner) => ({
|
|
36
|
+
sql: () => `SELECT NULL::text AS schema, ${name} AS name, ${owner ? `pg_catalog.pg_get_userbyid(x.${owner})` : "NULL::text"} AS owner FROM pg_catalog.${catalog} x
|
|
37
|
+
WHERE x.oid >= ${FIRST_USER_OID} AND ${notExtensionOwned(catalog, "x.oid")} AND ${notInternal(catalog, "x.oid")}`,
|
|
38
|
+
});
|
|
39
|
+
/** The pg_type rows of one `typtype`. */
|
|
40
|
+
const type = (where) => inSchema("pg_type", "typnamespace", "x.typname", where);
|
|
41
|
+
export const POSTGRES_PROBES = {
|
|
42
|
+
"access method": databaseWide("pg_am", "x.amname"),
|
|
43
|
+
aggregate: inSchema("pg_proc", "pronamespace", `x.proname || ${args("x.proargtypes")}`, "x.prokind = 'a'"),
|
|
44
|
+
"routine with a SQL-standard body": inSchema("pg_proc", "pronamespace", `x.proname || ${args("x.proargtypes")}`, "x.prokind IN ('f', 'p', 'w') AND x.prosqlbody IS NOT NULL"),
|
|
45
|
+
cast: databaseWide("pg_cast", "'(' || pg_catalog.format_type(x.castsource, NULL) || ' AS ' || pg_catalog.format_type(x.casttarget, NULL) || ')'"),
|
|
46
|
+
collation: inSchema("pg_collation", "collnamespace", "x.collname"),
|
|
47
|
+
conversion: inSchema("pg_conversion", "connamespace", "x.conname"),
|
|
48
|
+
"event trigger": databaseWide("pg_event_trigger", "x.evtname", "evtowner"),
|
|
49
|
+
"foreign data wrapper": databaseWide("pg_foreign_data_wrapper", "x.fdwname", "fdwowner"),
|
|
50
|
+
"foreign server": databaseWide("pg_foreign_server", "x.srvname", "srvowner"),
|
|
51
|
+
"user mapping": {
|
|
52
|
+
// pg_user_mapping is the superuser's to read; the view shows every mapping without its options.
|
|
53
|
+
sql: () => `SELECT NULL::text AS schema, 'for ' || x.usename || ' on server ' || x.srvname AS name, pg_catalog.pg_get_userbyid(s.srvowner) AS owner FROM pg_catalog.pg_user_mappings x
|
|
54
|
+
JOIN pg_catalog.pg_foreign_server s ON s.oid = x.srvid
|
|
55
|
+
WHERE ${notExtensionOwned("pg_foreign_server", "s.oid")}`,
|
|
56
|
+
},
|
|
57
|
+
"foreign table": inSchema("pg_class", "relnamespace", "x.relname", "x.relkind = 'f'"),
|
|
58
|
+
"procedural language": databaseWide("pg_language", "x.lanname", "lanowner"),
|
|
59
|
+
operator: inSchema("pg_operator", "oprnamespace", "x.oprname || '(' || pg_catalog.format_type(x.oprleft, NULL) || ',' || pg_catalog.format_type(x.oprright, NULL) || ')'"),
|
|
60
|
+
"operator class": inSchema("pg_opclass", "opcnamespace", "x.opcname || ' USING ' || (SELECT a.amname FROM pg_catalog.pg_am a WHERE a.oid = x.opcmethod)"),
|
|
61
|
+
// A family CREATE OPERATOR CLASS made for itself, named as its class, is the class's.
|
|
62
|
+
"operator family": inSchema("pg_opfamily", "opfnamespace", "x.opfname || ' USING ' || (SELECT a.amname FROM pg_catalog.pg_am a WHERE a.oid = x.opfmethod)", "NOT EXISTS (SELECT 1 FROM pg_catalog.pg_opclass c WHERE c.opcfamily = x.oid AND c.opcname = x.opfname AND c.opcnamespace = x.opfnamespace)"),
|
|
63
|
+
publication: databaseWide("pg_publication", "x.pubname", "pubowner"),
|
|
64
|
+
subscription: {
|
|
65
|
+
sql: () => `SELECT NULL::text AS schema, x.subname AS name, pg_catalog.pg_get_userbyid(x.subowner) AS owner FROM pg_catalog.pg_subscription x
|
|
66
|
+
WHERE x.subdbid = (SELECT d.oid FROM pg_catalog.pg_database d WHERE d.datname = pg_catalog.current_database())`,
|
|
67
|
+
},
|
|
68
|
+
rule: {
|
|
69
|
+
sql: (scope) => `SELECT n.nspname AS schema, r.rulename || ' ON ' || c.relname AS name, NULL::text AS owner
|
|
70
|
+
FROM pg_catalog.pg_rewrite r JOIN pg_catalog.pg_class c ON c.oid = r.ev_class ${ns("n", "c.relnamespace")}
|
|
71
|
+
WHERE r.rulename <> '_RETURN' AND ${scope} AND ${notExtensionOwned("pg_class", "c.oid")}`,
|
|
72
|
+
},
|
|
73
|
+
"extended statistics": inSchema("pg_statistic_ext", "stxnamespace", "x.stxname"),
|
|
74
|
+
"text search configuration": inSchema("pg_ts_config", "cfgnamespace", "x.cfgname"),
|
|
75
|
+
"text search dictionary": inSchema("pg_ts_dict", "dictnamespace", "x.dictname"),
|
|
76
|
+
"text search parser": inSchema("pg_ts_parser", "prsnamespace", "x.prsname"),
|
|
77
|
+
"text search template": inSchema("pg_ts_template", "tmplnamespace", "x.tmplname"),
|
|
78
|
+
transform: databaseWide("pg_transform", "'FOR ' || pg_catalog.format_type(x.trftype, NULL) || ' LANGUAGE ' || (SELECT l.lanname FROM pg_catalog.pg_language l WHERE l.oid = x.trflang)"),
|
|
79
|
+
"composite type": type("x.typtype = 'c' AND EXISTS (SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = x.typrelid AND c.relkind = 'c')"),
|
|
80
|
+
"range type": type("x.typtype = 'r'"),
|
|
81
|
+
// A base type, or a shell; not the array type the server makes beside every type.
|
|
82
|
+
"base type": type("x.typtype IN ('b', 'p') AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_type e WHERE e.typarray = x.oid)"),
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Every object in scope of a kind the coverage table refuses, kind by kind
|
|
86
|
+
* in the table's order, each by schema then name. A kind the session may not
|
|
87
|
+
* read (a role without the privilege) is a problem in `unread`, not a pass.
|
|
88
|
+
*/
|
|
89
|
+
export async function readUndeclarable(client, scope = {}) {
|
|
90
|
+
const s = scopeSql("n.nspname", scope);
|
|
91
|
+
const objects = [];
|
|
92
|
+
const unread = [];
|
|
93
|
+
for (const row of KIND_COVERAGE.postgres) {
|
|
94
|
+
if (row.status !== "refused")
|
|
95
|
+
continue;
|
|
96
|
+
const probe = POSTGRES_PROBES[row.kind];
|
|
97
|
+
if (!probe)
|
|
98
|
+
throw new Error(`no catalog query finds a Postgres ${row.kind} (src/postgres/live/undeclarable.ts)`);
|
|
99
|
+
// The comment names the kind, in the server's log and to a test's fake client.
|
|
100
|
+
const sql = `/* chant undeclarable: ${row.kind} */ SELECT q.schema, q.name, q.owner FROM (${probe.sql(s.sql)}) q ORDER BY q.schema ${C} NULLS FIRST, q.name ${C}`;
|
|
101
|
+
try {
|
|
102
|
+
const rows = await client.query(sql, sql.includes("$1") ? s.params : []);
|
|
103
|
+
for (const r of rows)
|
|
104
|
+
objects.push({ kind: row.kind, ...(r.schema ? { schema: r.schema } : {}), name: r.name, ...(r.owner ? { owner: r.owner } : {}) });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
unread.push(`${row.kind}: ${err instanceof Error ? err.message : String(err)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { objects, unread };
|
|
111
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backfill and the verification of the expand-and-contract migration
|
|
3
|
+
* (#3281).
|
|
4
|
+
*
|
|
5
|
+
* ## Backfill
|
|
6
|
+
*
|
|
7
|
+
* The rows that were in the table before the dual write started are filled
|
|
8
|
+
* in batches: one range of the primary key per batch (`./batches.ts`:
|
|
9
|
+
* `[b * size, (b + 1) * size)` of an integer key, else between boundaries
|
|
10
|
+
* recorded in the receipt store), so a batch is the same rows on every run
|
|
11
|
+
* however many rows were written since. Each batch is one transaction:
|
|
12
|
+
*
|
|
13
|
+
* UPDATE t SET new = <expression> WHERE key >= lo AND key < hi AND new IS DISTINCT FROM <expression>;
|
|
14
|
+
* INSERT INTO __chant_receipts ... -- the batch's receipt, last
|
|
15
|
+
* COMMIT;
|
|
16
|
+
*
|
|
17
|
+
* under `lock_timeout` (a row an application transaction holds makes the
|
|
18
|
+
* batch give up and try again rather than queue behind it) and the
|
|
19
|
+
* statement timeout for a catalog change, which bounds how long one batch
|
|
20
|
+
* may hold its row locks.
|
|
21
|
+
*
|
|
22
|
+
* Per batch, the cycle core's `effect()` step runs
|
|
23
|
+
* (concepts/effect-receipts.mdx): read the receipt, skip the batch when it
|
|
24
|
+
* matches, otherwise update and write the receipt last, on success only.
|
|
25
|
+
* The batches are only known when the run reads the table, so the cycle runs
|
|
26
|
+
* here over the shared core's `ReceiptStore` seam (`./receipts.ts`), as the
|
|
27
|
+
* ClickHouse rebuild's does per partition. Unlike ClickHouse, the receipt
|
|
28
|
+
* commits in the batch's own transaction: a run killed anywhere leaves a
|
|
29
|
+
* batch with its receipt or neither, and a resumed backfill starts at the
|
|
30
|
+
* first batch with no receipt and updates no batch twice.
|
|
31
|
+
*
|
|
32
|
+
* Before each batch the backfill waits while a replica is further behind
|
|
33
|
+
* than the bound (`./replication.ts`).
|
|
34
|
+
*
|
|
35
|
+
* ## Verification
|
|
36
|
+
*
|
|
37
|
+
* One statement over the whole table, one snapshot: the rows, the rows whose
|
|
38
|
+
* new column differs from the expression over the old one, the new column's
|
|
39
|
+
* NULLs, and a checksum (the sum of a 64-bit hash of each value) of the new
|
|
40
|
+
* column and of the expression. Any difference, or a NULL where the column
|
|
41
|
+
* is declared NOT NULL, fails the run, whose onFailure then drops what the
|
|
42
|
+
* expand added. The dual-write trigger keeps writes going during the
|
|
43
|
+
* verification consistent; the statement reads the table under ACCESS SHARE
|
|
44
|
+
* only.
|
|
45
|
+
*/
|
|
46
|
+
import { EffectReceipt, receiptExpectation } from "@intentius/chant/effect-receipt";
|
|
47
|
+
import { computePlanDigest } from "@intentius/chant/op";
|
|
48
|
+
import { col } from "./names.js";
|
|
49
|
+
import { MigrationRefusal, migrationPlanSubject } from "./observe.js";
|
|
50
|
+
import { postgresReceiptStore, receiptAddress } from "./receipts.js";
|
|
51
|
+
import { waitForReplicas } from "./replication.js";
|
|
52
|
+
import { batchPrefix, identityOf, inTransaction, observe } from "./steps.js";
|
|
53
|
+
import { carriedNotReady, requiresNotNull } from "./carry.js";
|
|
54
|
+
import { integerBatches, isIntegerKey, recordedBatches } from "./batches.js";
|
|
55
|
+
/** The effect a batch is, and its receipt's address suffix. */
|
|
56
|
+
export const batchEffect = (key, batch) => `migrate/${key}/${batch}`;
|
|
57
|
+
/** The receipt's expected value for one batch: bound to the table, the new column's attribute number, the batch's rows and the expression. */
|
|
58
|
+
export function batchExpectation(o, batch) {
|
|
59
|
+
const effect = batchEffect(o.names.key, batch.id);
|
|
60
|
+
return receiptExpectation(EffectReceipt(effect, {
|
|
61
|
+
effect,
|
|
62
|
+
flavor: "hash",
|
|
63
|
+
inputs: { migration: o.names.key, table: o.oid, column: o.newColumn.attnum, ...batch.inputs, expression: o.expression },
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
/** The address suffix of the recorded batch boundaries of a key that is not one integer column. */
|
|
67
|
+
export const BATCHES_RECORD = "batches";
|
|
68
|
+
export async function backfill(run, deps = {}) {
|
|
69
|
+
const o = await observe(run);
|
|
70
|
+
const result = { state: o.state, batches: 0, filled: 0, skipped: 0, rows: 0, pausedMs: 0 };
|
|
71
|
+
if (o.state !== "migrate")
|
|
72
|
+
return result;
|
|
73
|
+
const n = o.names;
|
|
74
|
+
if (!o.newColumn)
|
|
75
|
+
throw new MigrationRefusal(`${n.key}: the new column is not there yet; the Expand phase adds it`);
|
|
76
|
+
if (!o.trigger)
|
|
77
|
+
throw new MigrationRefusal(`${n.key}: the dual write is not on; the Dual write phase starts it, and the backfill fills only rows written before it`);
|
|
78
|
+
if (!Number.isInteger(run.batchSize) || run.batchSize < 1)
|
|
79
|
+
throw new MigrationRefusal(`${n.key}: batchSize must be a positive integer, got ${run.batchSize}`);
|
|
80
|
+
const identity = identityOf(run.marker);
|
|
81
|
+
const receipts = deps.receipts ?? postgresReceiptStore(run.client, n.schema, identity, run.runId ? { runId: run.runId } : {});
|
|
82
|
+
await receipts.ensure();
|
|
83
|
+
const recorded = await receipts.readAll(batchPrefix(identity, n.key));
|
|
84
|
+
const batches = isIntegerKey(o.keyColumns)
|
|
85
|
+
? await integerBatches(run.client, n.qualifiedTable, o.keyColumns[0], run.batchSize)
|
|
86
|
+
: (await recordedBatches(run.client, {
|
|
87
|
+
table: n.qualifiedTable,
|
|
88
|
+
oid: o.oid,
|
|
89
|
+
attnum: o.newColumn.attnum,
|
|
90
|
+
keys: o.keyColumns,
|
|
91
|
+
size: run.batchSize,
|
|
92
|
+
effect: batchEffect(n.key, BATCHES_RECORD),
|
|
93
|
+
receipts,
|
|
94
|
+
log: run.log,
|
|
95
|
+
...(run.signal ? { signal: run.signal } : {}),
|
|
96
|
+
})).batches;
|
|
97
|
+
result.batches = batches.length;
|
|
98
|
+
const wait = deps.waitForReplicas ?? waitForReplicas;
|
|
99
|
+
const target = `${col(n.newColumn)}`;
|
|
100
|
+
for (const b of batches) {
|
|
101
|
+
run.signal?.throwIfAborted();
|
|
102
|
+
const effect = batchEffect(n.key, b.id);
|
|
103
|
+
const expectation = batchExpectation(o, b);
|
|
104
|
+
if (recorded.get(receiptAddress(identity, effect)) === expectation) {
|
|
105
|
+
result.skipped++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (run.replicationLag)
|
|
109
|
+
result.pausedMs += await wait(run.client, run.replicationLag, { log: run.log, ...(run.signal ? { signal: run.signal } : {}) });
|
|
110
|
+
const updated = await inTransaction(run, async (exec) => {
|
|
111
|
+
const [row] = await exec(`WITH u AS (UPDATE ${n.qualifiedTable} SET ${target} = (${o.expression}) WHERE ${b.where} AND ${target} IS DISTINCT FROM (${o.expression}) RETURNING 1) ` +
|
|
112
|
+
"SELECT count(*)::int AS n FROM u", b.params);
|
|
113
|
+
// The receipt, last, in the batch's own transaction.
|
|
114
|
+
await receipts.write({ name: effect, effect, flavor: "hash", inputs: {} }, expectation);
|
|
115
|
+
await deps.beforeCommit?.(b.id);
|
|
116
|
+
return Number(row?.n ?? 0);
|
|
117
|
+
});
|
|
118
|
+
result.rows += updated;
|
|
119
|
+
result.filled++;
|
|
120
|
+
await deps.afterBatch?.(b.id);
|
|
121
|
+
}
|
|
122
|
+
run.log(`-- backfill of ${n.key}: ${result.batches} batch(es), ${result.filled} filled (${result.rows} row(s)), ${result.skipped} already filled`);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
/** A verification that found the columns different. */
|
|
126
|
+
export class MigrationVerificationError extends Error {
|
|
127
|
+
constructor(key, result, reason) {
|
|
128
|
+
super(`${key}: ${reason}. Nothing was switched; onFailure drops what the expand added, and the next run starts again.`);
|
|
129
|
+
this.key = key;
|
|
130
|
+
this.result = result;
|
|
131
|
+
this.name = "MigrationVerificationError";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function verifyMigration(run) {
|
|
135
|
+
const o = await observe(run);
|
|
136
|
+
const empty = { rows: 0, mismatched: 0, nulls: 0, checksum: "0", expected: "0" };
|
|
137
|
+
if (o.state !== "migrate")
|
|
138
|
+
return { state: o.state, ...empty, summary: `nothing to verify: ${o.state}` };
|
|
139
|
+
const n = o.names;
|
|
140
|
+
if (!o.newColumn || !o.trigger)
|
|
141
|
+
throw new MigrationRefusal(`${n.key}: the new column and its dual write are not there; the Expand and Dual write phases make them`);
|
|
142
|
+
const nw = col(n.newColumn);
|
|
143
|
+
const [row] = await inTransaction(run, (exec) => exec(`SELECT count(*)::text AS rows, count(*) FILTER (WHERE ${nw} IS DISTINCT FROM (${o.expression}))::text AS mismatched,
|
|
144
|
+
count(*) FILTER (WHERE ${nw} IS NULL)::text AS nulls,
|
|
145
|
+
COALESCE(sum(pg_catalog.hashtextextended(${nw}::text, 0)), 0)::text AS checksum,
|
|
146
|
+
COALESCE(sum(pg_catalog.hashtextextended((${o.expression})::text, 0)), 0)::text AS expected
|
|
147
|
+
FROM ${n.qualifiedTable}`), { scan: true });
|
|
148
|
+
const r = { rows: Number(row?.rows ?? 0), mismatched: Number(row?.mismatched ?? 0), nulls: Number(row?.nulls ?? 0), checksum: String(row?.checksum ?? "0"), expected: String(row?.expected ?? "0") };
|
|
149
|
+
if (r.mismatched > 0 || r.checksum !== r.expected) {
|
|
150
|
+
throw new MigrationVerificationError(n.key, { state: o.state, ...r }, `${r.mismatched} of ${r.rows} row(s) have ${n.newColumn} different from ${o.expression} (checksums ${r.checksum} and ${r.expected})`);
|
|
151
|
+
}
|
|
152
|
+
if (requiresNotNull(o.column, o.carried) && r.nulls > 0) {
|
|
153
|
+
throw new MigrationVerificationError(n.key, { state: o.state, ...r }, `${r.nulls} of ${r.rows} row(s) have ${n.newColumn} NULL, and ${n.column} is declared NOT NULL or is in the primary key`);
|
|
154
|
+
}
|
|
155
|
+
const notReady = carriedNotReady(o.carried, o.carriedStates);
|
|
156
|
+
if (notReady.length > 0) {
|
|
157
|
+
throw new MigrationRefusal(`${n.key}: ${notReady.map((c) => `${c.kind} ${c.working}`).join(", ")} on the new column is not there or not yet valid; the Carry over phase makes them`);
|
|
158
|
+
}
|
|
159
|
+
const carried = o.carried.length > 0 || o.views.length > 0
|
|
160
|
+
? `; ${o.carried.map((c) => `${c.kind} ${c.name}${c.target !== c.name ? ` (as ${c.target})` : ""}`).join(", ") || "no index or constraint"} carried over${o.views.length > 0 ? `, view(s) ${o.views.map((v) => v.name).join(", ")} made again at the switch` : ""}`
|
|
161
|
+
: "";
|
|
162
|
+
const summary = `${n.key}: ${r.rows} row(s), ${n.newColumn} equal to ${o.expression} in every one (checksum ${r.checksum})${carried}`;
|
|
163
|
+
run.log(`-- ${summary}`);
|
|
164
|
+
return {
|
|
165
|
+
state: o.state,
|
|
166
|
+
planDigest: computePlanDigest("postgres-migration", { plan: migrationPlanSubject(o), table: o.oid, column: o.newColumn.attnum, verified: { mismatched: 0 } }),
|
|
167
|
+
...r,
|
|
168
|
+
summary,
|
|
169
|
+
};
|
|
170
|
+
}
|