@intentius/chant-lexicon-sql 0.116.0 → 0.118.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/access.d.ts +132 -0
- package/dist/clickhouse/access.d.ts.map +1 -0
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +2 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +171 -2
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/http.d.ts +3 -0
- package/dist/clickhouse/http.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +28 -0
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/ir.d.ts.map +1 -1
- package/dist/clickhouse/import/parser.d.ts +3 -3
- package/dist/clickhouse/import/parser.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +41 -6
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -2
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +54 -3
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +3 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts +12 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +61 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +7 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +8 -4
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +7 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +8 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +48 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +70 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/core/references.d.ts +4 -2
- package/dist/core/references.d.ts.map +1 -1
- package/dist/core/statements.d.ts +11 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/files/build-root.d.ts +26 -0
- package/dist/files/build-root.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +19 -0
- package/dist/files/clickhouse.d.ts.map +1 -0
- package/dist/files/common.d.ts +25 -0
- package/dist/files/common.d.ts.map +1 -0
- package/dist/files/postgres.d.ts +36 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/import-parser.d.ts +25 -0
- package/dist/import-parser.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +8 -8
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlch002.d.ts.map +1 -1
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +50 -0
- package/dist/okf/index.md +11 -1
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/types/Dictionary.md +9 -0
- package/dist/okf/types/Function.md +9 -0
- package/dist/okf/types/Grant.md +9 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/okf/types/Role.md +9 -0
- package/dist/okf/types/RowPolicy.md +9 -0
- package/dist/okf/types/User.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +19 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/access/acl.d.ts +119 -0
- package/dist/postgres/access/acl.d.ts.map +1 -0
- package/dist/postgres/access/import.d.ts +13 -0
- package/dist/postgres/access/import.d.ts.map +1 -0
- package/dist/postgres/access/live.d.ts +24 -0
- package/dist/postgres/access/live.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +71 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +106 -4
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +4 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +37 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts +6 -0
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +2 -0
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +11 -2
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +3 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +9 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/op.d.ts +29 -0
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +76 -3
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts +12 -0
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +10 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +8 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +8 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +2 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts +4 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/sqlpg001.ts +7 -4
- package/dist/rules/templates.ts +2 -2
- package/dist/serializer.d.ts +2 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sql-files.d.ts +79 -0
- package/dist/sql-files.d.ts.map +1 -0
- package/dist/token-source.d.ts +88 -0
- package/dist/token-source.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.test.ts +203 -0
- package/src/clickhouse/access.ts +595 -0
- package/src/clickhouse/apply/apply.ts +29 -20
- package/src/clickhouse/apply/statements.ts +105 -8
- package/src/clickhouse/dictionary.test.ts +177 -0
- package/src/clickhouse/entities.ts +356 -6
- package/src/clickhouse/function.test.ts +91 -0
- package/src/clickhouse/http.ts +11 -2
- package/src/clickhouse/import/generator.ts +81 -46
- package/src/clickhouse/import/import.test.ts +9 -3
- package/src/clickhouse/import/ir.ts +6 -2
- package/src/clickhouse/import/parser.ts +10 -18
- package/src/clickhouse/live/access.e2e.test.ts +218 -0
- package/src/clickhouse/live/bind.ts +17 -6
- package/src/clickhouse/live/catalog.ts +106 -10
- package/src/clickhouse/live/describe-resources.ts +23 -10
- package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
- package/src/clickhouse/live/functions.e2e.test.ts +155 -0
- package/src/clickhouse/live/live.test.ts +2 -2
- package/src/clickhouse/parser.ts +136 -4
- package/src/clickhouse/plan/commands.ts +18 -7
- package/src/clickhouse/plan/deep.ts +71 -11
- package/src/clickhouse/plan/diff.ts +65 -2
- package/src/clickhouse/plan/normalize.ts +200 -6
- package/src/clickhouse/plan/report.ts +14 -3
- package/src/clickhouse/plan/rules.ts +44 -1
- package/src/clickhouse/plan/schema.ts +40 -17
- package/src/clickhouse/plan/server-format.ts +1 -1
- package/src/clickhouse/rebuild/backfill.ts +5 -25
- package/src/clickhouse/rebuild/observe.ts +7 -3
- package/src/clickhouse/rebuild/op.ts +9 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
- package/src/clickhouse/rebuild/steps.ts +136 -1
- package/src/clickhouse/testing/fake-server.ts +2 -0
- package/src/clickhouse/testing/writable-server.ts +2 -0
- package/src/clickhouse/topology.ts +17 -2
- package/src/clickhouse.ts +19 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/codegen/registry.ts +10 -0
- package/src/config.ts +22 -3
- package/src/core/references.ts +5 -3
- package/src/core/statements.ts +11 -1
- package/src/files/build-root.ts +62 -0
- package/src/files/clickhouse.ts +52 -0
- package/src/files/common.ts +54 -0
- package/src/files/postgres.ts +371 -0
- package/src/generated/lexicon-sql.json +50 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +14 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/postgres-template.ts +2 -0
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +8 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +2 -0
- package/src/plugin.ts +29 -5
- package/src/postgres/access/access.e2e.test.ts +191 -0
- package/src/postgres/access/access.test.ts +147 -0
- package/src/postgres/access/acl.ts +400 -0
- package/src/postgres/access/import.ts +64 -0
- package/src/postgres/access/live.ts +123 -0
- package/src/postgres/apply/apply.ts +117 -4
- package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
- package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
- package/src/postgres/apply/statements.test.ts +138 -6
- package/src/postgres/apply/statements.ts +210 -18
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +129 -46
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +20 -6
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/live/describe-resources.ts +27 -3
- package/src/postgres/migrate/migrate.test.ts +24 -1
- package/src/postgres/migrate/op.ts +80 -35
- package/src/postgres/migrate/outer.e2e.test.ts +163 -0
- package/src/postgres/parser.ts +247 -8
- package/src/postgres/plan/commands.ts +60 -7
- package/src/postgres/plan/deep.ts +24 -3
- package/src/postgres/plan/diff.ts +71 -6
- package/src/postgres/plan/normalize.ts +115 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +49 -5
- package/src/postgres/plan/schema.ts +4 -2
- package/src/postgres/plan/server-normalize.ts +29 -1
- package/src/postgres.ts +11 -0
- package/src/serializer.ts +14 -9
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-postgres.md +9 -2
- package/src/skills/chant-sql-rebuild.md +3 -2
- package/src/skills/chant-sql.md +7 -1
- package/src/sql-files.test.ts +188 -0
- package/src/sql-files.ts +218 -0
- package/src/token-source.e2e.test.ts +142 -0
- package/src/token-source.test.ts +119 -0
- package/src/token-source.ts +189 -0
- package/src/validate.ts +10 -0
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* (`nextval('app.invoice_seq'::regclass)` is `nextval(${invoiceSeq})`);
|
|
12
12
|
* - the schema part of the object's own name, wherever the name appears,
|
|
13
13
|
* becomes the schema's export (`CREATE TABLE ${appSchema}.users`);
|
|
14
|
-
* - an extension's `WITH SCHEMA
|
|
14
|
+
* - an extension's `WITH SCHEMA`, a grant's `ON SCHEMA` and default
|
|
15
|
+
* privileges' `IN SCHEMA` name the schema's export;
|
|
16
|
+
* - a grant's object becomes the object's export (`GRANT SELECT ON TABLE
|
|
17
|
+
* ${orders} TO reader`), a policy's table its table's.
|
|
15
18
|
*
|
|
16
19
|
* Columns stay text, as they do for ClickHouse (#3236): which relation a bare
|
|
17
20
|
* column belongs to is the server's to resolve. One file, because the
|
|
@@ -21,16 +24,24 @@
|
|
|
21
24
|
import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
|
|
22
25
|
import type { TemplateIR } from "@intentius/chant/import/parser";
|
|
23
26
|
import { isTrivia, tokenizeText, type Token } from "../tokens";
|
|
24
|
-
import { identValue } from "../parser";
|
|
27
|
+
import { identValue, parseStatements, type NameNode, type StatementNode } from "../parser";
|
|
25
28
|
import { POSTGRES_ENTITY_TYPES } from "../entity-types";
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
/** One object a template is written for, or (with no `ddl` read) one a template may reference. */
|
|
31
|
+
export interface PgTemplateItem {
|
|
28
32
|
exportName: string;
|
|
29
33
|
type: string;
|
|
30
34
|
schema?: string;
|
|
31
35
|
name: string;
|
|
32
36
|
ddl: string;
|
|
33
37
|
}
|
|
38
|
+
type Item = PgTemplateItem;
|
|
39
|
+
|
|
40
|
+
/** A template's text: `parts` (template-literal safe) around one interpolation per entry of `refs`, an export name. */
|
|
41
|
+
export interface TemplateBody {
|
|
42
|
+
parts: string[];
|
|
43
|
+
refs: string[];
|
|
44
|
+
}
|
|
34
45
|
|
|
35
46
|
const TAG: Record<string, string> = {
|
|
36
47
|
[POSTGRES_ENTITY_TYPES.schema]: "schema",
|
|
@@ -45,39 +56,85 @@ const TAG: Record<string, string> = {
|
|
|
45
56
|
[POSTGRES_ENTITY_TYPES.function]: "func",
|
|
46
57
|
[POSTGRES_ENTITY_TYPES.procedure]: "procedure",
|
|
47
58
|
[POSTGRES_ENTITY_TYPES.trigger]: "trigger",
|
|
59
|
+
[POSTGRES_ENTITY_TYPES.policy]: "policy",
|
|
60
|
+
[POSTGRES_ENTITY_TYPES.role]: "role",
|
|
61
|
+
[POSTGRES_ENTITY_TYPES.grant]: "grant",
|
|
62
|
+
[POSTGRES_ENTITY_TYPES.defaultPrivileges]: "grant",
|
|
48
63
|
};
|
|
49
64
|
|
|
65
|
+
/** The tag each entity type is declared with (`table`, `func`, ...). */
|
|
66
|
+
export const POSTGRES_TAG_OF: Readonly<Record<string, string>> = TAG;
|
|
67
|
+
|
|
50
68
|
/** Text that sits inside a template literal: a backquote and `${` escaped. */
|
|
51
69
|
const templateSafe = (s: string) => s.replace(/\\(?=`|\$\{)/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
52
70
|
|
|
53
71
|
const isName = (t: Token | undefined) => t !== undefined && (t.kind === "ident" || t.kind === "qident");
|
|
54
72
|
|
|
55
|
-
/** `'app.invoice_seq'` as the qualified name it holds. */
|
|
73
|
+
/** `'app.invoice_seq'` as the qualified name it holds, or the bare name when unqualified. */
|
|
56
74
|
function regclassName(t: Token): string | undefined {
|
|
57
75
|
if (t.kind !== "string" || !/^'.*'$/s.test(t.text)) return undefined;
|
|
58
76
|
const inner = t.text.slice(1, -1).replace(/''/g, "'");
|
|
59
77
|
const pieces = inner.match(/"(?:[^"]|"")*"|[^.]+/g)?.map((p) => identValue(p)) ?? [];
|
|
60
|
-
return pieces.length === 2 ? `${pieces[0]}.${pieces[1]}` : pieces.length === 1 ?
|
|
78
|
+
return pieces.length === 2 ? `${pieces[0]}.${pieces[1]}` : pieces.length === 1 ? pieces[0] : undefined;
|
|
61
79
|
}
|
|
62
80
|
|
|
63
|
-
|
|
81
|
+
/** Words after which a bare name is a relation: `FROM t`, `JOIN t`, `REFERENCES t`. */
|
|
82
|
+
const RELATION_WORDS = new Set(["FROM", "JOIN", "REFERENCES"]);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The tokens where a bare (unqualified) name is another object's: a column's
|
|
86
|
+
* or a domain's type, the table of an index, trigger or policy, a trigger's
|
|
87
|
+
* function, a grant's objects. A `FROM`, `JOIN` or `REFERENCES` before a
|
|
88
|
+
* name is read off the tokens directly.
|
|
89
|
+
*/
|
|
90
|
+
function bareSpots(tokens: Token[]): Set<number> {
|
|
91
|
+
const out = new Set<number>();
|
|
92
|
+
let nodes: StatementNode[];
|
|
93
|
+
try {
|
|
94
|
+
nodes = parseStatements(tokens);
|
|
95
|
+
} catch {
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
const first = (from: number, to: number) => {
|
|
99
|
+
for (let i = from; i < to; i++) if (!isTrivia(tokens[i]!)) return out.add(i);
|
|
100
|
+
return out;
|
|
101
|
+
};
|
|
102
|
+
const name = (n: NameNode | undefined) => n && n.span.to - n.span.from > 0 && first(n.span.from, n.span.to);
|
|
103
|
+
for (const node of nodes) {
|
|
104
|
+
if (node.statement === "table") for (const c of node.columns) if (c.type) first(c.type.from, c.type.to);
|
|
105
|
+
if (node.statement === "domain") first(node.type.from, node.type.to);
|
|
106
|
+
if (node.statement === "index" || node.statement === "policy") name(node.table);
|
|
107
|
+
if (node.statement === "trigger") {
|
|
108
|
+
name(node.table);
|
|
109
|
+
name(node.function);
|
|
110
|
+
}
|
|
111
|
+
if (node.statement === "grant") for (const o of node.objects) name(o.name);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function templateBody(item: Item, byQualified: Map<string, Item>, schemaExport: Map<string, string>): TemplateBody {
|
|
64
117
|
const tokens = tokenizeText(item.ddl, 0);
|
|
65
118
|
const sig: number[] = [];
|
|
66
119
|
tokens.forEach((t, i) => {
|
|
67
120
|
if (!isTrivia(t)) sig.push(i);
|
|
68
121
|
});
|
|
69
|
-
const
|
|
122
|
+
const spots = bareSpots(tokens);
|
|
123
|
+
const replace = new Map<number, { to: number; ref: string }>();
|
|
124
|
+
const isSelf = (target: Item) => target === item || (target.exportName === item.exportName && target.type === item.type);
|
|
70
125
|
for (let k = 0; k < sig.length; k++) {
|
|
71
126
|
const a = tokens[sig[k]!]!;
|
|
72
127
|
const prev = tokens[sig[k - 1] ?? -1];
|
|
73
128
|
if (prev?.kind === "punct" && prev.text === ".") continue;
|
|
74
129
|
// A regclass literal: nextval('app.seq'::regclass) or 'app.t'::regclass.
|
|
75
130
|
if (a.kind === "string" && tokens[sig[k + 1] ?? -1]?.text === "::" && tokens[sig[k + 2] ?? -1]?.text.toLowerCase() === "regclass") {
|
|
76
|
-
const
|
|
77
|
-
|
|
131
|
+
const held = regclassName(a) ?? "";
|
|
132
|
+
// An unqualified name is the object in `public`, as the server's own printing has it, else the unqualified object of that name.
|
|
133
|
+
const target = (!held.includes(".") ? byQualified.get(`public.${held}`) : undefined) ?? byQualified.get(held);
|
|
134
|
+
if (target && !isSelf(target)) {
|
|
78
135
|
const fn = tokens[sig[k - 2] ?? -1];
|
|
79
136
|
const inCall = prev?.text === "(" && fn?.kind === "ident" && /^(nextval|currval|setval)$/i.test(fn.text);
|
|
80
|
-
replace.set(sig[k]!, { to: inCall ? sig[k + 2]! + 1 : sig[k]! + 1,
|
|
137
|
+
replace.set(sig[k]!, { to: inCall ? sig[k + 2]! + 1 : sig[k]! + 1, ref: target.exportName });
|
|
81
138
|
k += inCall ? 2 : 0;
|
|
82
139
|
continue;
|
|
83
140
|
}
|
|
@@ -85,39 +142,89 @@ function templateBody(item: Item, byQualified: Map<string, Item>, schemaExport:
|
|
|
85
142
|
const dot = tokens[sig[k + 1] ?? -1];
|
|
86
143
|
const b = tokens[sig[k + 2] ?? -1];
|
|
87
144
|
if (!isName(a) || dot?.kind !== "punct" || dot.text !== "." || !isName(b)) {
|
|
88
|
-
// An extension's `WITH SCHEMA app`.
|
|
89
|
-
|
|
145
|
+
// An extension's `WITH SCHEMA app`, a grant's `ON SCHEMA app`, default privileges' `IN SCHEMA app`.
|
|
146
|
+
const bySchemaWord = item.type === POSTGRES_ENTITY_TYPES.extension || item.type === POSTGRES_ENTITY_TYPES.grant || item.type === POSTGRES_ENTITY_TYPES.defaultPrivileges;
|
|
147
|
+
if (bySchemaWord && isName(a) && prev?.kind === "ident" && prev.text.toUpperCase() === "SCHEMA") {
|
|
90
148
|
const s = schemaExport.get(identValue(a));
|
|
91
|
-
if (s) replace.set(sig[k]!, { to: sig[k]! + 1,
|
|
149
|
+
if (s) replace.set(sig[k]!, { to: sig[k]! + 1, ref: s });
|
|
150
|
+
continue;
|
|
92
151
|
}
|
|
152
|
+
// A bare name where an object is named: `REFERENCES users`, `ON users`, a column of type `status`.
|
|
153
|
+
const bare = isName(a) && (spots.has(sig[k]!) || (prev?.kind === "ident" && RELATION_WORDS.has(prev.text.toUpperCase())));
|
|
154
|
+
const target = bare ? byQualified.get(identValue(a)) : undefined;
|
|
155
|
+
if (target && !isSelf(target)) replace.set(sig[k]!, { to: sig[k]! + 1, ref: target.exportName });
|
|
93
156
|
continue;
|
|
94
157
|
}
|
|
95
158
|
const schema = identValue(a);
|
|
96
159
|
const name = identValue(b);
|
|
97
160
|
const self = schema === item.schema && name === item.name;
|
|
98
161
|
const target = byQualified.get(`${schema}.${name}`);
|
|
99
|
-
if (!self && target && target
|
|
100
|
-
replace.set(sig[k]!, { to: sig[k + 2]! + 1,
|
|
162
|
+
if (!self && target && !isSelf(target)) {
|
|
163
|
+
replace.set(sig[k]!, { to: sig[k + 2]! + 1, ref: target.exportName });
|
|
101
164
|
k += 2;
|
|
102
165
|
} else if (self && schemaExport.has(schema)) {
|
|
103
|
-
replace.set(sig[k]!, { to: sig[k]! + 1,
|
|
166
|
+
replace.set(sig[k]!, { to: sig[k]! + 1, ref: schemaExport.get(schema)! });
|
|
104
167
|
k += 2;
|
|
105
168
|
}
|
|
106
169
|
}
|
|
107
|
-
|
|
170
|
+
const parts: string[] = [""];
|
|
108
171
|
const refs: string[] = [];
|
|
109
172
|
for (let i = 0; i < tokens.length; ) {
|
|
110
173
|
const r = replace.get(i);
|
|
111
174
|
if (r) {
|
|
112
|
-
body += r.text;
|
|
113
175
|
refs.push(r.ref);
|
|
176
|
+
parts.push("");
|
|
114
177
|
i = r.to;
|
|
115
178
|
continue;
|
|
116
179
|
}
|
|
117
|
-
|
|
180
|
+
parts[parts.length - 1] += templateSafe(tokens[i]!.text);
|
|
118
181
|
i++;
|
|
119
182
|
}
|
|
120
|
-
return {
|
|
183
|
+
return { parts, refs };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A template body as the text between the backquotes. */
|
|
187
|
+
export const bodyText = (body: TemplateBody): string => body.parts.map((p, i) => (i === 0 ? p : `\${${body.refs[i - 1]}}${p}`)).join("");
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The template of each object in `items`, with the references to `targets`
|
|
191
|
+
* (the items themselves, and any object declared elsewhere that they may
|
|
192
|
+
* name) interpolated, and the items in the order a file declares them: each
|
|
193
|
+
* after what it references.
|
|
194
|
+
*
|
|
195
|
+
* A name is a reference where it is qualified (`app.users`), or bare where an
|
|
196
|
+
* object is named (`REFERENCES users`, `FROM users`, an index's table, a
|
|
197
|
+
* column's type) and an unqualified object has that name.
|
|
198
|
+
*/
|
|
199
|
+
export function postgresTemplates(items: readonly Item[], targets: readonly Item[] = items): { order: Item[]; bodies: Map<string, TemplateBody> } {
|
|
200
|
+
const byQualified = new Map<string, Item>();
|
|
201
|
+
const schemaExport = new Map<string, string>();
|
|
202
|
+
for (const it of targets) {
|
|
203
|
+
if (it.type === POSTGRES_ENTITY_TYPES.schema) schemaExport.set(it.name, it.exportName);
|
|
204
|
+
// An index or a trigger is never referenced by name in another object's DDL.
|
|
205
|
+
else if (it.type !== POSTGRES_ENTITY_TYPES.index && it.type !== POSTGRES_ENTITY_TYPES.trigger && it.type !== POSTGRES_ENTITY_TYPES.policy && it.type !== POSTGRES_ENTITY_TYPES.grant && it.type !== POSTGRES_ENTITY_TYPES.defaultPrivileges && it.type !== POSTGRES_ENTITY_TYPES.role && it.type !== POSTGRES_ENTITY_TYPES.extension) {
|
|
206
|
+
byQualified.set(it.schema ? `${it.schema}.${it.name}` : it.name, it);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, schemaExport)]));
|
|
211
|
+
const order: Item[] = [];
|
|
212
|
+
const done = new Set<string>();
|
|
213
|
+
const visiting = new Set<string>();
|
|
214
|
+
const byExport = new Map(items.map((it) => [it.exportName, it]));
|
|
215
|
+
const visit = (it: Item) => {
|
|
216
|
+
if (done.has(it.exportName) || visiting.has(it.exportName)) return;
|
|
217
|
+
visiting.add(it.exportName);
|
|
218
|
+
for (const ref of bodies.get(it.exportName)!.refs) {
|
|
219
|
+
const dep = byExport.get(ref);
|
|
220
|
+
if (dep) visit(dep);
|
|
221
|
+
}
|
|
222
|
+
visiting.delete(it.exportName);
|
|
223
|
+
done.add(it.exportName);
|
|
224
|
+
order.push(it);
|
|
225
|
+
};
|
|
226
|
+
for (const it of items) visit(it);
|
|
227
|
+
return { order, bodies };
|
|
121
228
|
}
|
|
122
229
|
|
|
123
230
|
const indent = (body: string) =>
|
|
@@ -143,37 +250,13 @@ export class PostgresGenerator implements TypeScriptGenerator {
|
|
|
143
250
|
}));
|
|
144
251
|
if (items.length === 0) return [];
|
|
145
252
|
|
|
146
|
-
const
|
|
147
|
-
const schemaExport = new Map<string, string>();
|
|
148
|
-
for (const it of items) {
|
|
149
|
-
if (it.type === POSTGRES_ENTITY_TYPES.schema) schemaExport.set(it.name, it.exportName);
|
|
150
|
-
// An index or a trigger is never referenced by name in another object's DDL.
|
|
151
|
-
else if (it.schema && it.type !== POSTGRES_ENTITY_TYPES.index && it.type !== POSTGRES_ENTITY_TYPES.trigger) byQualified.set(`${it.schema}.${it.name}`, it);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, schemaExport)]));
|
|
155
|
-
const order: Item[] = [];
|
|
156
|
-
const done = new Set<string>();
|
|
157
|
-
const visiting = new Set<string>();
|
|
158
|
-
const byExport = new Map(items.map((it) => [it.exportName, it]));
|
|
159
|
-
const visit = (it: Item) => {
|
|
160
|
-
if (done.has(it.exportName) || visiting.has(it.exportName)) return;
|
|
161
|
-
visiting.add(it.exportName);
|
|
162
|
-
for (const ref of bodies.get(it.exportName)!.refs) {
|
|
163
|
-
const dep = byExport.get(ref);
|
|
164
|
-
if (dep) visit(dep);
|
|
165
|
-
}
|
|
166
|
-
visiting.delete(it.exportName);
|
|
167
|
-
done.add(it.exportName);
|
|
168
|
-
order.push(it);
|
|
169
|
-
};
|
|
170
|
-
for (const it of items) visit(it);
|
|
253
|
+
const { order, bodies } = postgresTemplates(items);
|
|
171
254
|
|
|
172
255
|
const tags = [...new Set(order.map((it) => TAG[it.type]!))].sort();
|
|
173
256
|
const lines: string[] = [];
|
|
174
257
|
if (this.header) lines.push(...this.header.split("\n").map((l) => `// ${l}`), "");
|
|
175
258
|
lines.push(`import { ${tags.join(", ")} } from "@intentius/chant-lexicon-sql/postgres";`, "");
|
|
176
|
-
for (const it of order) lines.push(`export const ${it.exportName} = ${TAG[it.type]}\`\n ${indent(bodies.get(it.exportName)
|
|
259
|
+
for (const it of order) lines.push(`export const ${it.exportName} = ${TAG[it.type]}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
|
|
177
260
|
return [{ path: "schema.ts", content: `${lines.join("\n").trimEnd()}\n` }];
|
|
178
261
|
}
|
|
179
262
|
}
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* a declaration of them would have chant fight it.
|
|
12
12
|
* - `verbatim` changes nothing yet: the printers already leave out what the
|
|
13
13
|
* server would add by itself.
|
|
14
|
+
* - Where the profile manages access (`sql.profiles.<env>.access`), the
|
|
15
|
+
* policies and each table's row-level security come with the objects, and
|
|
16
|
+
* the privileges on them and the default privileges are written as `grant`
|
|
17
|
+
* declarations: the GRANT and REVOKE statements that take a new object's
|
|
18
|
+
* privileges to what the server holds. Roles are the environment's and are
|
|
19
|
+
* not imported.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
|
|
@@ -18,6 +24,7 @@ import { bindPostgres, type BindOptions } from "../live/bind";
|
|
|
18
24
|
import { markProviderOwned, readLiveSchema } from "../live/catalog";
|
|
19
25
|
import { objectsToIR, type ImportedPgObject } from "./ir";
|
|
20
26
|
import { isChantManaged } from "../../core/ownership";
|
|
27
|
+
import { importedAccess } from "../access/import";
|
|
21
28
|
|
|
22
29
|
export interface ExportOptions extends Omit<BindOptions, "environment"> {
|
|
23
30
|
environment: string;
|
|
@@ -31,8 +38,11 @@ export interface ExportOptions extends Omit<BindOptions, "environment"> {
|
|
|
31
38
|
export async function exportResources(options: ExportOptions): Promise<ExportedTemplate> {
|
|
32
39
|
const { target, client } = await bindPostgres(options);
|
|
33
40
|
let live;
|
|
41
|
+
let access: ImportedPgObject[] = [];
|
|
34
42
|
try {
|
|
35
|
-
live = markProviderOwned(await readLiveSchema(client, { schemas: target.schemas }), target.provider);
|
|
43
|
+
live = markProviderOwned(await readLiveSchema(client, { schemas: target.schemas, access: target.access === true }), target.provider);
|
|
44
|
+
// Where the profile manages access, the privileges on what is imported, and the default privileges, as grant declarations.
|
|
45
|
+
if (target.access === true && !options.selector) access = await importedAccess(client, live.filter((o) => !o.foreign && !o.unsupported && (!options.owned || isChantManaged(o.comment))), target.schemas);
|
|
36
46
|
} finally {
|
|
37
47
|
await client.end();
|
|
38
48
|
}
|
|
@@ -53,5 +63,5 @@ export async function exportResources(options: ExportOptions): Promise<ExportedT
|
|
|
53
63
|
}
|
|
54
64
|
objects.push({ type: o.type, ...(o.schema ? { schema: o.schema } : {}), name: o.name, ddl: o.statement });
|
|
55
65
|
}
|
|
56
|
-
return objectsToIR(objects, warnings) as ExportedTemplate;
|
|
66
|
+
return objectsToIR([...objects, ...access], warnings) as ExportedTemplate;
|
|
57
67
|
}
|
|
@@ -8,6 +8,7 @@ import type { ChantConfig } from "@intentius/chant/config";
|
|
|
8
8
|
import type { UnobservedReason } from "@intentius/chant/observation";
|
|
9
9
|
import { connectPostgres, PostgresQueryError, type PostgresClient, type PostgresEndpoint } from "./client";
|
|
10
10
|
import type { PostgresProvider } from "../providers/types";
|
|
11
|
+
import { credentialSource, TokenSourceError } from "../../token-source";
|
|
11
12
|
|
|
12
13
|
export interface PostgresTarget {
|
|
13
14
|
endpoint: PostgresEndpoint;
|
|
@@ -21,6 +22,8 @@ export interface PostgresTarget {
|
|
|
21
22
|
provider?: PostgresProvider;
|
|
22
23
|
/** The apply's timeouts the profile sets (`lockTimeoutMs`, `statementTimeoutMs`, `scanTimeoutMs`); the applier's defaults otherwise. */
|
|
23
24
|
timeouts?: { lockTimeoutMs?: number; statementTimeoutMs?: number; scanTimeoutMs?: number };
|
|
25
|
+
/** Whether chant manages access here (`sql.profiles.<env>.access`): policies, row-level security, the declared roles, grants and default privileges. */
|
|
26
|
+
access?: boolean;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface UnresolvedTarget {
|
|
@@ -56,12 +59,21 @@ export function resolvePostgresTarget(input: {
|
|
|
56
59
|
if (profile && isPostgresUrl(profile.url)) {
|
|
57
60
|
const source = `sql.profiles.${input.environment}`;
|
|
58
61
|
const endpoint: PostgresEndpoint = { url: profile.url };
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
if (profile.user) {
|
|
63
|
+
const value = env[profile.user.env];
|
|
64
|
+
if (value === undefined) return { reason: "no-credentials", detail: `${source}.user names ${profile.user.env}, which is not set` };
|
|
65
|
+
endpoint.user = value;
|
|
66
|
+
}
|
|
67
|
+
const password = profile.password;
|
|
68
|
+
if (password && "env" in password) {
|
|
69
|
+
const value = env[password.env];
|
|
70
|
+
if (value === undefined) return { reason: "no-credentials", detail: `${source}.password names ${password.env}, which is not set` };
|
|
71
|
+
endpoint.password = value;
|
|
72
|
+
} else if (password) {
|
|
73
|
+
if (password.token === "rds-iam" && endpoint.user === undefined) {
|
|
74
|
+
return { reason: "no-credentials", detail: `${source}.password is an rds-iam token, which is minted for one user, and ${source}.user is not set` };
|
|
75
|
+
}
|
|
76
|
+
endpoint.token = credentialSource(password, { url: profile.url, ...(endpoint.user !== undefined ? { user: endpoint.user } : {}), env, source: `${source}.password` });
|
|
65
77
|
}
|
|
66
78
|
const timeouts = Object.fromEntries(
|
|
67
79
|
(["lockTimeoutMs", "statementTimeoutMs", "scanTimeoutMs"] as const).filter((k) => profile[k] !== undefined).map((k) => [k, profile[k]]),
|
|
@@ -72,6 +84,7 @@ export function resolvePostgresTarget(input: {
|
|
|
72
84
|
...(profile.schemas ? { schemas: profile.schemas } : {}),
|
|
73
85
|
defaultSchema: profile.defaultSchema ?? "public",
|
|
74
86
|
...(Object.keys(timeouts).length > 0 ? { timeouts } : {}),
|
|
87
|
+
...(profile.access === true ? { access: true } : {}),
|
|
75
88
|
});
|
|
76
89
|
}
|
|
77
90
|
const url = env.POSTGRES_URL;
|
|
@@ -139,6 +152,7 @@ const CREDENTIAL_STATES = new Set(["28000", "28P01", "42501"]);
|
|
|
139
152
|
/** What a failed bind or read means, in the observation vocabulary. */
|
|
140
153
|
export function classifyPostgresFailure(err: unknown): { reason: UnobservedReason; detail: string } {
|
|
141
154
|
if (err instanceof PostgresBindingError) return { reason: err.unresolved.reason, detail: err.unresolved.detail };
|
|
155
|
+
if (err instanceof TokenSourceError) return { reason: "no-credentials", detail: err.message };
|
|
142
156
|
if (err instanceof PostgresQueryError && err.code && CREDENTIAL_STATES.has(err.code)) {
|
|
143
157
|
return { reason: "no-credentials", detail: `${err.message} (the server refused the credentials, SQLSTATE ${err.code})` };
|
|
144
158
|
}
|
|
@@ -93,6 +93,15 @@ export interface LivePgObject {
|
|
|
93
93
|
export interface SchemaScope {
|
|
94
94
|
/** The schemas in scope; undefined is every schema but the server's own and `public`'s own objects included. */
|
|
95
95
|
schemas?: readonly string[];
|
|
96
|
+
/**
|
|
97
|
+
* Whether access is read (`sql.profiles.<env>.access`): the policies on the
|
|
98
|
+
* tables in scope, each table's row-level security, and the roles named in
|
|
99
|
+
* `roles`. Off, none of it is read, so a project that manages access
|
|
100
|
+
* elsewhere sees none of it.
|
|
101
|
+
*/
|
|
102
|
+
access?: boolean;
|
|
103
|
+
/** The roles to read, when access is: the declared ones. A role is the cluster's, never read otherwise. */
|
|
104
|
+
roles?: readonly string[];
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
type Row = Record<string, unknown>;
|
|
@@ -193,8 +202,8 @@ const seqParams = (r: Row): SequenceParams => ({
|
|
|
193
202
|
/**
|
|
194
203
|
* Every object in scope, in a stable order: schemas, extensions, enums,
|
|
195
204
|
* domains, sequences, tables, views and materialized views, indexes,
|
|
196
|
-
* functions and procedures, triggers
|
|
197
|
-
* order.
|
|
205
|
+
* functions and procedures, triggers, and where access is read, policies and
|
|
206
|
+
* the declared roles; each kind by schema then name, in byte order.
|
|
198
207
|
*/
|
|
199
208
|
export async function readLiveSchema(client: PostgresClient, scope: SchemaScope = {}): Promise<LivePgObject[]> {
|
|
200
209
|
const out: LivePgObject[] = [];
|
|
@@ -326,6 +335,7 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
|
|
|
326
335
|
FROM pg_catalog.pg_inherits i JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace
|
|
327
336
|
WHERE i.inhrelid = c.oid ORDER BY i.inhseqno) AS parents,
|
|
328
337
|
c.reloptions AS reloptions, (SELECT a.amname FROM pg_catalog.pg_am a WHERE a.oid = c.relam) AS am,
|
|
338
|
+
c.relrowsecurity AS rowsecurity, c.relforcerowsecurity AS forcerowsecurity,
|
|
329
339
|
(SELECT ts.spcname FROM pg_catalog.pg_tablespace ts WHERE ts.oid = c.reltablespace) AS tablespace,
|
|
330
340
|
pg_catalog.obj_description(c.oid, 'pg_class') AS comment
|
|
331
341
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
@@ -492,7 +502,13 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
|
|
|
492
502
|
oid: rel,
|
|
493
503
|
...(str(t.comment) ? { comment: str(t.comment) } : {}),
|
|
494
504
|
...(FOREIGN_TABLES[String(t.name)] ? { foreign: FOREIGN_TABLES[String(t.name)] } : {}),
|
|
495
|
-
statement: [
|
|
505
|
+
statement: [
|
|
506
|
+
create,
|
|
507
|
+
...(scope.access && t.rowsecurity === true ? [`ALTER TABLE ${q} ENABLE ROW LEVEL SECURITY`] : []),
|
|
508
|
+
...(scope.access && t.forcerowsecurity === true ? [`ALTER TABLE ${q} FORCE ROW LEVEL SECURITY`] : []),
|
|
509
|
+
...commentOn(`TABLE ${q}`, t.comment),
|
|
510
|
+
...commentLines,
|
|
511
|
+
].join(";\n"),
|
|
496
512
|
});
|
|
497
513
|
}
|
|
498
514
|
|
|
@@ -630,6 +646,66 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
|
|
|
630
646
|
}
|
|
631
647
|
}
|
|
632
648
|
|
|
649
|
+
if (scope.access) {
|
|
650
|
+
// Policies, on the tables in scope.
|
|
651
|
+
const policies = await client.query<Row>(
|
|
652
|
+
`SELECT p.oid::text AS oid, n.nspname AS schema, c.relname AS table_name, p.polname AS name, p.polpermissive AS permissive, p.polcmd AS cmd,
|
|
653
|
+
ARRAY(SELECT CASE WHEN r = 0 THEN 'PUBLIC' ELSE pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(r)) END FROM pg_catalog.unnest(p.polroles) r ORDER BY 1) AS roles,
|
|
654
|
+
pg_catalog.pg_get_expr(p.polqual, p.polrelid, true) AS qual, pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid, true) AS withcheck,
|
|
655
|
+
pg_catalog.obj_description(p.oid, 'pg_policy') AS comment
|
|
656
|
+
FROM pg_catalog.pg_policy p JOIN pg_catalog.pg_class c ON c.oid = p.polrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
657
|
+
WHERE ${s.sql} AND ${notExtensionOwned("pg_class", "c.oid")}
|
|
658
|
+
ORDER BY n.nspname ${C}, c.relname ${C}, p.polname ${C}`,
|
|
659
|
+
s.params,
|
|
660
|
+
);
|
|
661
|
+
const COMMAND: Record<string, string> = { r: "SELECT", a: "INSERT", w: "UPDATE", d: "DELETE" };
|
|
662
|
+
for (const r of policies) {
|
|
663
|
+
const table = qname(String(r.schema), String(r.table_name));
|
|
664
|
+
const roles = (r.roles as string[] | null) ?? [];
|
|
665
|
+
const parts = [`CREATE POLICY ${quoteIdent(String(r.name))} ON ${table}`];
|
|
666
|
+
if (r.permissive === false) parts.push("AS RESTRICTIVE");
|
|
667
|
+
if (COMMAND[String(r.cmd)]) parts.push(`FOR ${COMMAND[String(r.cmd)]}`);
|
|
668
|
+
if (!(roles.length === 1 && roles[0] === "PUBLIC")) parts.push(`TO ${roles.join(", ")}`);
|
|
669
|
+
if (str(r.qual)) parts.push(`USING (${r.qual})`);
|
|
670
|
+
if (str(r.withcheck)) parts.push(`WITH CHECK (${r.withcheck})`);
|
|
671
|
+
out.push({
|
|
672
|
+
type: POSTGRES_ENTITY_TYPES.policy,
|
|
673
|
+
schema: String(r.schema),
|
|
674
|
+
name: String(r.name),
|
|
675
|
+
oid: String(r.oid),
|
|
676
|
+
signature: ` ON ${table}`,
|
|
677
|
+
...(str(r.comment) ? { comment: str(r.comment) } : {}),
|
|
678
|
+
statement: [parts.join(" "), ...commentOn(`POLICY ${quoteIdent(String(r.name))} ON ${table}`, r.comment)].join(";\n"),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// The declared roles, by name: a role is the cluster's, and the ones the declarations do not name are the environment's.
|
|
683
|
+
if (scope.roles && scope.roles.length > 0) {
|
|
684
|
+
const roles = await client.query<Row>(
|
|
685
|
+
`SELECT r.oid::text AS oid, r.rolname AS name, r.rolsuper AS superuser, r.rolinherit AS inherit, r.rolcreaterole AS createrole, r.rolcreatedb AS createdb,
|
|
686
|
+
r.rolcanlogin AS login, r.rolreplication AS replication, r.rolbypassrls AS bypassrls, r.rolconnlimit AS connlimit,
|
|
687
|
+
pg_catalog.shobj_description(r.oid, 'pg_authid') AS comment
|
|
688
|
+
FROM pg_catalog.pg_roles r WHERE r.rolname = ANY($1::text[]) ORDER BY r.rolname ${C}`,
|
|
689
|
+
[[...scope.roles]],
|
|
690
|
+
);
|
|
691
|
+
for (const r of roles) {
|
|
692
|
+
const words: string[] = [];
|
|
693
|
+
for (const [k, dflt] of [["superuser", false], ["createdb", false], ["createrole", false], ["inherit", true], ["login", false], ["replication", false], ["bypassrls", false]] as const) {
|
|
694
|
+
if ((r[k] === true) !== dflt) words.push(r[k] === true ? k.toUpperCase() : `NO${k.toUpperCase()}`);
|
|
695
|
+
}
|
|
696
|
+
if (Number(r.connlimit) !== -1) words.push(`CONNECTION LIMIT ${Number(r.connlimit)}`);
|
|
697
|
+
const name = quoteIdent(String(r.name));
|
|
698
|
+
out.push({
|
|
699
|
+
type: POSTGRES_ENTITY_TYPES.role,
|
|
700
|
+
name: String(r.name),
|
|
701
|
+
oid: String(r.oid),
|
|
702
|
+
...(str(r.comment) ? { comment: str(r.comment) } : {}),
|
|
703
|
+
statement: [`CREATE ROLE ${name}${words.length > 0 ? ` WITH ${words.join(" ")}` : ""}`, ...commentOn(`ROLE ${name}`, r.comment)].join(";\n"),
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
633
709
|
return out;
|
|
634
710
|
}
|
|
635
711
|
|
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
* search path.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { CredentialSource } from "../../token-source";
|
|
12
|
+
|
|
11
13
|
export interface PostgresEndpoint {
|
|
12
14
|
/** A `postgres://` connection URL, without a password. */
|
|
13
15
|
url: string;
|
|
14
16
|
user?: string;
|
|
15
17
|
password?: string;
|
|
18
|
+
/** Mints the password at each connect, in place of `password` (a profile's token source, #3685). */
|
|
19
|
+
token?: CredentialSource;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/** What chant reads and writes through: one connection. */
|
|
@@ -54,7 +58,8 @@ export async function connectPostgres(endpoint: PostgresEndpoint, options: { app
|
|
|
54
58
|
// node-postgres lets the connection string's fields win over the config's, so the credentials go into the URL.
|
|
55
59
|
const url = new URL(endpoint.url);
|
|
56
60
|
if (endpoint.user !== undefined) url.username = encodeURIComponent(endpoint.user);
|
|
57
|
-
|
|
61
|
+
const password = endpoint.token ? await endpoint.token.get() : endpoint.password;
|
|
62
|
+
if (password !== undefined) url.password = encodeURIComponent(password);
|
|
58
63
|
const client = new pg.Client({ connectionString: url.toString(), application_name: options.applicationName ?? "chant" });
|
|
59
64
|
// A server that goes away emits an error event; the next query fails with it instead of the process crashing.
|
|
60
65
|
client.on("error", () => undefined);
|
|
@@ -63,7 +68,10 @@ export async function connectPostgres(endpoint: PostgresEndpoint, options: { app
|
|
|
63
68
|
await client.query("SELECT pg_catalog.set_config('search_path', '', false)");
|
|
64
69
|
} catch (err) {
|
|
65
70
|
await client.end().catch(() => undefined);
|
|
66
|
-
|
|
71
|
+
const wrapped = wrap(err);
|
|
72
|
+
// A refused token is not reused: the next connect mints another.
|
|
73
|
+
if (endpoint.token && (wrapped.code === "28P01" || wrapped.code === "28000")) endpoint.token.invalidate();
|
|
74
|
+
throw wrapped;
|
|
67
75
|
}
|
|
68
76
|
let ended = false;
|
|
69
77
|
return {
|
|
@@ -41,15 +41,30 @@ interface Bound {
|
|
|
41
41
|
*/
|
|
42
42
|
export function declaredAddress(entity: { type: string; props: Record<string, unknown> }, defaultSchema: string): { schema?: string; name: string; signature?: string } {
|
|
43
43
|
const name = String(entity.props.name ?? "");
|
|
44
|
-
if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension) return { name };
|
|
44
|
+
if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension || entity.type === POSTGRES_ENTITY_TYPES.role) return { name };
|
|
45
45
|
const schema = typeof entity.props.schema === "string" ? entity.props.schema : identValue(defaultSchema);
|
|
46
|
-
if (entity.type === POSTGRES_ENTITY_TYPES.function || entity.type === POSTGRES_ENTITY_TYPES.procedure || entity.type === POSTGRES_ENTITY_TYPES.trigger) {
|
|
46
|
+
if (entity.type === POSTGRES_ENTITY_TYPES.function || entity.type === POSTGRES_ENTITY_TYPES.procedure || entity.type === POSTGRES_ENTITY_TYPES.trigger || entity.type === POSTGRES_ENTITY_TYPES.policy) {
|
|
47
47
|
const signature = canonicalPgObject(entity.type, entity.props, defaultSchema).signature;
|
|
48
48
|
return { schema, name, ...(signature !== undefined ? { signature } : {}) };
|
|
49
49
|
}
|
|
50
50
|
return { schema, name };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Why an access declaration is not observed one by one: a grant or default
|
|
55
|
+
* privileges, which `chant sql plan` compares as the access they add up to;
|
|
56
|
+
* a policy or role where the profile does not manage access.
|
|
57
|
+
*/
|
|
58
|
+
export function accessUnobserved(type: string, managed: boolean): { reason: "unsupported-kind" | "filtered"; detail: string } | undefined {
|
|
59
|
+
if (type === POSTGRES_ENTITY_TYPES.grant || type === POSTGRES_ENTITY_TYPES.defaultPrivileges) {
|
|
60
|
+
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" };
|
|
61
|
+
}
|
|
62
|
+
if (!managed && (type === POSTGRES_ENTITY_TYPES.policy || type === POSTGRES_ENTITY_TYPES.role)) {
|
|
63
|
+
return { reason: "filtered", detail: "access is not managed in this environment (sql.profiles.<env>.access)" };
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
/** A schema scope for the catalog read: the profile's schemas, else every schema the declarations name and the default one. */
|
|
54
69
|
export function scopeFor(target: PostgresTarget, entities: ReadonlyArray<{ type: string; props: Record<string, unknown> }>): string[] | undefined {
|
|
55
70
|
if (target.schemas) return target.schemas;
|
|
@@ -68,7 +83,14 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
|
|
|
68
83
|
async bind() {
|
|
69
84
|
const { target, client } = await bindPostgres(options);
|
|
70
85
|
try {
|
|
71
|
-
const live = markProviderOwned(
|
|
86
|
+
const live = markProviderOwned(
|
|
87
|
+
await readLiveSchema(client, {
|
|
88
|
+
schemas: scopeFor(target, options.declared.filter((d) => d.type.startsWith("Postgres::"))),
|
|
89
|
+
access: target.access === true,
|
|
90
|
+
roles: options.declared.filter((d) => d.type === POSTGRES_ENTITY_TYPES.role).map((d) => String(d.props.name)),
|
|
91
|
+
}),
|
|
92
|
+
target.provider,
|
|
93
|
+
);
|
|
72
94
|
return { target, byKey: new Map(live.map((o) => [liveKey(o.type, o.schema, o.name, o.signature), o])) };
|
|
73
95
|
} finally {
|
|
74
96
|
await client.end();
|
|
@@ -77,6 +99,8 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
|
|
|
77
99
|
classifyBindFailure: (err) => classifyPostgresFailure(err),
|
|
78
100
|
async read({ target, byKey }, entity): Promise<EntityObservation> {
|
|
79
101
|
if (!entity.type.startsWith("Postgres::")) return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
|
|
102
|
+
const why = accessUnobserved(entity.type, target.access === true);
|
|
103
|
+
if (why) return { unobserved: why };
|
|
80
104
|
const { schema, name, signature } = declaredAddress(entity, target.defaultSchema);
|
|
81
105
|
const queried = `${redactUrl(target.endpoint.url)} ${schema ? `${schema}.` : ""}${name}${signature ?? ""}`;
|
|
82
106
|
const live = byKey.get(liveKey(entity.type, schema, name, signature));
|
|
@@ -72,7 +72,30 @@ describe("PostgresMigrationOp", () => {
|
|
|
72
72
|
expect(Object.keys(g.approval.context)).toEqual(["verifiedRows", "mismatched"]);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
test('gates: "outer" leaves out both approval gates and the Contract phase, and keeps the verification (#3687)', () => {
|
|
76
|
+
const props = propsOf({ ...BASE, gates: "outer" });
|
|
77
|
+
expect(props.phases.map((p) => p.name)).toEqual(["Build", "Plan", "Expand", "Dual write", "Backfill", "Carry over", "Verify", "Switch", "Retain"]);
|
|
78
|
+
expect(props.phases.flatMap((p) => p.steps).filter((s) => s.kind === "gate")).toEqual([]);
|
|
79
|
+
const fns = props.phases.flatMap((p) => p.steps).map((s) => (s as { fn?: string }).fn);
|
|
80
|
+
expect(fns.indexOf("postgresMigrationVerify")).toBeLessThan(fns.indexOf("postgresMigrationSwitch"));
|
|
81
|
+
expect(fns).not.toContain("postgresMigrationContract");
|
|
82
|
+
expect(props.onFailure?.map((p) => p.steps.map((s) => (s as { fn?: string }).fn))).toEqual([["postgresMigrationCompensate"]]);
|
|
83
|
+
expect(props.overview).toContain("the caller's approval");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('onFailure: "keep" has no onFailure and tells every step so (#3687)', () => {
|
|
87
|
+
const props = propsOf({ ...BASE, onFailure: "keep" });
|
|
88
|
+
expect(props.onFailure).toBeUndefined();
|
|
89
|
+
const steps = props.phases.flatMap((p) => p.steps).filter((s) => s.kind === "activity" && (s as { fn: string }).fn.startsWith("postgresMigration")) as Array<{ args: Record<string, unknown> }>;
|
|
90
|
+
expect(steps).toHaveLength(9);
|
|
91
|
+
for (const s of steps) expect(s.args.keepOnFailure).toBe(true);
|
|
92
|
+
expect(props.phases.map((p) => p.name)).toContain("Approve");
|
|
93
|
+
expect((propsOf(BASE).phases[1]!.steps[0] as { args: Record<string, unknown> }).args).not.toHaveProperty("keepOnFailure");
|
|
94
|
+
});
|
|
95
|
+
|
|
75
96
|
test("bad configuration fails when the Op is built", () => {
|
|
97
|
+
expect(() => PostgresMigrationOp({ ...BASE, gates: "none" as never })).toThrow(/gates must be/);
|
|
98
|
+
expect(() => PostgresMigrationOp({ ...BASE, onFailure: "retry" as never })).toThrow(/onFailure must be/);
|
|
76
99
|
expect(() => PostgresMigrationOp({ ...BASE, table: "app.users; DROP" })).toThrow(/table must be/);
|
|
77
100
|
expect(() => PostgresMigrationOp({ ...BASE, column: "" })).toThrow(/column must be/);
|
|
78
101
|
expect(() => PostgresMigrationOp({ ...BASE, batchSize: 0 })).toThrow(/batchSize/);
|
|
@@ -95,7 +118,7 @@ describe("PostgresMigrationOp", () => {
|
|
|
95
118
|
ownershipEnv: "prod",
|
|
96
119
|
build: false,
|
|
97
120
|
};
|
|
98
|
-
for (const config of [BASE, full, { ...BASE, replicationLag: false as const }]) {
|
|
121
|
+
for (const config of [BASE, full, { ...BASE, replicationLag: false as const }, { ...BASE, gates: "outer" as const, onFailure: "keep" as const }]) {
|
|
99
122
|
const props = propsOf(config);
|
|
100
123
|
expect(validateActivitySteps(props, merged)).toEqual([]);
|
|
101
124
|
expect(validateStepOutputRefs(props, merged)).toEqual([]);
|