@intentius/chant-lexicon-sql 0.115.0 → 0.117.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/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -5
- package/dist/lint/rules/sqlpg001.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 +35 -0
- package/dist/okf/index.md +7 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresFunction.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresProcedure.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/okf/types/PostgresTrigger.md +9 -0
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +10 -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 +6 -1
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +12 -2
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +212 -3
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +7 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +4 -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 +25 -4
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +15 -1
- 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 +155 -5
- 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 +11 -2
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +28 -2
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +14 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +12 -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/sqlpg001.ts +10 -4
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/postgres-classifier-page.ts +1 -1
- package/src/codegen/registry.ts +7 -0
- package/src/config.ts +10 -0
- package/src/generated/lexicon-sql.json +35 -0
- package/src/index.ts +1 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlpg001.ts +10 -4
- package/src/lsp/postgres-template.ts +8 -0
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.ts +7 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +1 -0
- package/src/plugin.ts +6 -0
- 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 +135 -6
- package/src/postgres/apply/statements.ts +104 -16
- package/src/postgres/entities.ts +613 -14
- package/src/postgres/entity-types.ts +7 -0
- package/src/postgres/import/generator.ts +18 -5
- package/src/postgres/import/live-export.ts +16 -2
- package/src/postgres/live/bind.ts +3 -0
- package/src/postgres/live/catalog.ts +155 -5
- package/src/postgres/live/describe-resources.ts +43 -9
- package/src/postgres/live/routines.e2e.test.ts +173 -0
- package/src/postgres/migrate/batches.e2e.test.ts +2 -1
- package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
- 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 +554 -9
- package/src/postgres/plan/commands.ts +61 -8
- package/src/postgres/plan/deep.ts +79 -18
- package/src/postgres/plan/diff.ts +114 -9
- package/src/postgres/plan/normalize.ts +272 -5
- package/src/postgres/plan/rules.ts +80 -0
- package/src/postgres/plan/schema.ts +27 -6
- package/src/postgres/plan/server-normalize.ts +53 -1
- package/src/postgres/routines.test.ts +150 -0
- package/src/postgres.ts +22 -0
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres.md +25 -2
- package/src/validate.ts +7 -0
|
@@ -18,6 +18,10 @@ import { keyedByQualifiedName, pgBuildFileMajor, pgSchemaFromBuildFile, pgSchema
|
|
|
18
18
|
import { serverNormalized } from "./server-normalize";
|
|
19
19
|
import { POSTGRES_ENTITY_TYPES } from "../entity-types";
|
|
20
20
|
import { migrationOpSuggestions } from "../migrate/handoff";
|
|
21
|
+
import { POSTGRES_LATEST_MAJOR } from "../../spec/postgres-pin";
|
|
22
|
+
import { ACCESS_MANAGED_KINDS } from "./normalize";
|
|
23
|
+
import { declaredAccess, diffAccess, predictedAccess, targetKey } from "../access/acl";
|
|
24
|
+
import { readLiveAccess } from "../access/live";
|
|
21
25
|
|
|
22
26
|
export function emitPg(diff: PgSchemaDiff, json: boolean, title: string): number {
|
|
23
27
|
console.log(json ? JSON.stringify(diff, null, 2) : renderPgDiff(diff, { title }));
|
|
@@ -69,6 +73,8 @@ export interface PgServerPlan {
|
|
|
69
73
|
diff: PgSchemaDiff;
|
|
70
74
|
/** The major the changes were classified for: the server's, else the one asked for. */
|
|
71
75
|
major?: number;
|
|
76
|
+
/** The schemas the read was limited to; undefined is every schema. */
|
|
77
|
+
scope?: string[];
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/**
|
|
@@ -83,29 +89,76 @@ export async function planAgainstClient(
|
|
|
83
89
|
build: readonly PgSchemaObject[],
|
|
84
90
|
options: { major?: number; environment?: string; readLive?: typeof readLiveSchema; serverNormalize?: typeof serverNormalized } = {},
|
|
85
91
|
): Promise<PgServerPlan> {
|
|
86
|
-
const
|
|
92
|
+
const managed = target.access === true;
|
|
93
|
+
const { kept, unmanaged } = accessScoped(keyedByQualifiedName(build), managed);
|
|
94
|
+
const declaredRaw = kept;
|
|
87
95
|
const scope = scopeFor(
|
|
88
96
|
target,
|
|
89
|
-
declaredRaw.map((o) => ({ type: POSTGRES_ENTITY_TYPES[o.canonical.kind], props: { name: o.canonical.name, ...(o.canonical.schema ? { schema: o.canonical.schema } : {}) } })),
|
|
97
|
+
declaredRaw.filter((o) => o.canonical.kind !== "role" && o.canonical.kind !== "grant" && o.canonical.kind !== "defaultPrivileges").map((o) => ({ type: POSTGRES_ENTITY_TYPES[o.canonical.kind], props: { name: o.canonical.name, ...(o.canonical.schema ? { schema: o.canonical.schema } : {}) } })),
|
|
90
98
|
);
|
|
91
|
-
const
|
|
99
|
+
const roles = declaredRaw.filter((o) => o.canonical.kind === "role").map((o) => o.canonical.name);
|
|
100
|
+
const liveObjects = markProviderOwned(await (options.readLive ?? readLiveSchema)(client, { schemas: scope, access: managed, roles }), target.provider);
|
|
92
101
|
const live = pgSchemaFromLive(liveObjects, target.defaultSchema);
|
|
93
102
|
const liveByKey = new Map(live.map((o) => [o.key, o]));
|
|
94
103
|
const declared: PgSchemaObject[] = [];
|
|
95
104
|
for (const o of declaredRaw) {
|
|
96
105
|
const l = liveByKey.get(o.key);
|
|
97
|
-
if (l && differs(o, l) && (o.canonical.kind === "table" || o.canonical.kind === "view" || o.canonical.kind === "materializedView")) {
|
|
106
|
+
if (l && differs(o, l) && (o.canonical.kind === "table" || o.canonical.kind === "view" || o.canonical.kind === "materializedView" || o.canonical.kind === "trigger" || o.canonical.kind === "policy")) {
|
|
98
107
|
declared.push({ ...o, canonical: { ...o.canonical, ...(await (options.serverNormalize ?? serverNormalized)(client, o.canonical, target.defaultSchema)) } });
|
|
99
108
|
} else declared.push(o);
|
|
100
109
|
}
|
|
101
110
|
// The build's major, else the server's own when it differs, since the server is what takes the locks.
|
|
102
111
|
const running = await serverMajor(client).catch(() => undefined);
|
|
103
112
|
const major = running ?? options.major;
|
|
104
|
-
const diff = diffPgSchemas(live, declared, { major });
|
|
113
|
+
const diff = diffPgSchemas(live, declared, { major, access: false });
|
|
114
|
+
if (managed) {
|
|
115
|
+
const want = declaredAccess(declared, { major: major ?? POSTGRES_LATEST_MAJOR, ...(await currentRole(client)) });
|
|
116
|
+
const have = await readLiveAccess(client, want.targets, { ...(scope ? { schemas: scope } : {}), forRoles: want.forRoles, declaredDefaults: want.targets.filter((t) => t.kind === "default") });
|
|
117
|
+
// An object this plan creates is given what the server gives a new one.
|
|
118
|
+
const missing = want.targets.filter((t) => t.kind !== "default" && t.kind !== "column" && !have.present.has(targetKey(t)));
|
|
119
|
+
const now = new Map([...have.state, ...predictedAccess(missing, have)]);
|
|
120
|
+
const access = diffAccess(now, want.state, want.exportsOf);
|
|
121
|
+
if (access.length > 0) {
|
|
122
|
+
diff.changes.push(...access.map((a) => a.change));
|
|
123
|
+
diff.access = access;
|
|
124
|
+
}
|
|
125
|
+
} else if (unmanaged > 0) {
|
|
126
|
+
diff.hints.push(`${unmanaged} access declaration${unmanaged === 1 ? " is" : "s are"} not planned: ${options.environment ?? "this environment"}'s profile does not manage access (sql.profiles.<env>.access)`);
|
|
127
|
+
}
|
|
105
128
|
if (running !== undefined && options.major !== undefined && running !== options.major) {
|
|
106
129
|
diff.hints.push(`the build targets Postgres ${options.major} but ${options.environment ?? "the server"} runs Postgres ${running}; changes are classified for ${running}`);
|
|
107
130
|
}
|
|
108
|
-
return { declared, live, liveObjects, diff, ...(major !== undefined ? { major } : {}) };
|
|
131
|
+
return { declared, live, liveObjects, diff, ...(major !== undefined ? { major } : {}), ...(scope ? { scope } : {}) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The role a session runs as: the one whose own privileges owning an object gives, and whose default privileges apply. */
|
|
135
|
+
async function currentRole(client: PostgresClient): Promise<{ self?: string }> {
|
|
136
|
+
const rows = await client.query<{ self: string }>("SELECT current_user AS self").catch(() => []);
|
|
137
|
+
return rows[0]?.self ? { self: rows[0].self } : {};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The declarations a plan compares: all of them where the profile manages
|
|
142
|
+
* access; otherwise none of the policies, roles, grants and default
|
|
143
|
+
* privileges, and no table's row-level security, so a project that manages
|
|
144
|
+
* access elsewhere is not affected. `unmanaged` counts what was left out.
|
|
145
|
+
*/
|
|
146
|
+
export function accessScoped(objects: readonly PgSchemaObject[], managed: boolean): { kept: PgSchemaObject[]; unmanaged: number } {
|
|
147
|
+
if (managed) return { kept: [...objects], unmanaged: 0 };
|
|
148
|
+
let unmanaged = 0;
|
|
149
|
+
const kept: PgSchemaObject[] = [];
|
|
150
|
+
for (const o of objects) {
|
|
151
|
+
if (ACCESS_MANAGED_KINDS.has(o.canonical.kind)) {
|
|
152
|
+
unmanaged++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (o.canonical.kind === "table" && (o.canonical.fields.rowSecurity || o.canonical.fields.forceRowSecurity)) {
|
|
156
|
+
unmanaged++;
|
|
157
|
+
const { rowSecurity: _r, forceRowSecurity: _f, ...fields } = o.canonical.fields;
|
|
158
|
+
kept.push({ ...o, canonical: { ...o.canonical, fields } });
|
|
159
|
+
} else kept.push(o);
|
|
160
|
+
}
|
|
161
|
+
return { kept, unmanaged };
|
|
109
162
|
}
|
|
110
163
|
|
|
111
164
|
/** The declared objects against the server, keys by qualified name and labels with the export name. */
|
|
@@ -115,8 +168,8 @@ export async function planPgAgainstServer(environment: string, buildFile: string
|
|
|
115
168
|
const targeted = pgBuildFileMajor(buildFile) ?? (await projectMajor(options));
|
|
116
169
|
const { declared, diff } = await planAgainstClient(client, target, pgSchemaFromBuildFile(buildFile, target.defaultSchema), { ...(targeted !== undefined ? { major: targeted } : {}), environment });
|
|
117
170
|
const { migrationOps } = withMigrationOps(diff, declared, environment, target.defaultSchema);
|
|
118
|
-
const label = new Map(declared.map((o) => [o.key, `${o.canonical.exportName} (${o.key.
|
|
119
|
-
const changes = diff.changes.map((c) => ({ ...c, object: label.get(c.object) ?? c.object }));
|
|
171
|
+
const label = new Map(declared.map((o) => [o.key, `${o.canonical.exportName} (${o.key.slice(o.key.indexOf(" ") + 1)})`]));
|
|
172
|
+
const changes = diff.changes.map((c) => ({ ...c, object: label.get(c.object) ?? (c.object.startsWith("acl ") ? c.object.slice(4) : c.object) }));
|
|
120
173
|
return { changes, hints: diff.hints, refused: changes.filter((c) => c.class === "expand"), ...(migrationOps ? { migrationOps } : {}) };
|
|
121
174
|
} finally {
|
|
122
175
|
await client.end();
|
|
@@ -17,10 +17,12 @@ import { deepObservation, type DeepObservationResult, type DeepResourceObservati
|
|
|
17
17
|
import type { UnobservedEntity } from "@intentius/chant/lexicon";
|
|
18
18
|
import { bindPostgres, classifyPostgresFailure, type BindOptions } from "../live/bind";
|
|
19
19
|
import { liveKey, readLiveSchema, type LivePgObject } from "../live/catalog";
|
|
20
|
-
import { declaredAddress, scopeFor } from "../live/describe-resources";
|
|
20
|
+
import { accessUnobserved, declaredAddress, scopeFor } from "../live/describe-resources";
|
|
21
21
|
import * as tags from "../entities";
|
|
22
22
|
import { POSTGRES_ENTITY_TYPES } from "../entity-types";
|
|
23
23
|
import { canonicalPgObject, columnShape, sameConstraint, type CanonicalPgObject } from "./normalize";
|
|
24
|
+
import { serverNormalized } from "./server-normalize";
|
|
25
|
+
import type { PostgresClient } from "../live/client";
|
|
24
26
|
|
|
25
27
|
type Props = Record<string, unknown>;
|
|
26
28
|
|
|
@@ -34,6 +36,13 @@ const TAG: Record<string, (s: TemplateStringsArray) => { props: object }> = {
|
|
|
34
36
|
[POSTGRES_ENTITY_TYPES.enum]: tags.type,
|
|
35
37
|
[POSTGRES_ENTITY_TYPES.domain]: tags.domain,
|
|
36
38
|
[POSTGRES_ENTITY_TYPES.extension]: tags.extension,
|
|
39
|
+
[POSTGRES_ENTITY_TYPES.function]: tags.func,
|
|
40
|
+
[POSTGRES_ENTITY_TYPES.procedure]: tags.procedure,
|
|
41
|
+
[POSTGRES_ENTITY_TYPES.trigger]: tags.trigger,
|
|
42
|
+
[POSTGRES_ENTITY_TYPES.policy]: tags.policy,
|
|
43
|
+
[POSTGRES_ENTITY_TYPES.role]: tags.role,
|
|
44
|
+
[POSTGRES_ENTITY_TYPES.grant]: tags.grant,
|
|
45
|
+
[POSTGRES_ENTITY_TYPES.defaultPrivileges]: tags.grant,
|
|
37
46
|
};
|
|
38
47
|
|
|
39
48
|
/** The props of what the server printed, parsed with the declaration's tag. */
|
|
@@ -74,6 +83,40 @@ const PROP_OF_FIELD: Record<string, string[]> = {
|
|
|
74
83
|
partitionBound: ["partitionBound"],
|
|
75
84
|
inherits: ["inherits"],
|
|
76
85
|
using: ["using"],
|
|
86
|
+
args: ["args"],
|
|
87
|
+
returns: ["returns", "returnsTable"],
|
|
88
|
+
language: ["language"],
|
|
89
|
+
body: ["body"],
|
|
90
|
+
link: ["link"],
|
|
91
|
+
securityDefiner: ["securityDefiner"],
|
|
92
|
+
set: ["set"],
|
|
93
|
+
transform: ["transform"],
|
|
94
|
+
volatility: ["volatility"],
|
|
95
|
+
strict: ["strict"],
|
|
96
|
+
leakproof: ["leakproof"],
|
|
97
|
+
parallel: ["parallel"],
|
|
98
|
+
cost: ["cost"],
|
|
99
|
+
rows: ["rows"],
|
|
100
|
+
support: ["support"],
|
|
101
|
+
window: ["window"],
|
|
102
|
+
constraint: ["constraint"],
|
|
103
|
+
timing: ["timing"],
|
|
104
|
+
events: ["events"],
|
|
105
|
+
forEach: ["forEach"],
|
|
106
|
+
when: ["when"],
|
|
107
|
+
function: ["function", "functionName"],
|
|
108
|
+
from: ["from"],
|
|
109
|
+
deferrable: ["deferrable"],
|
|
110
|
+
initiallyDeferred: ["initiallyDeferred"],
|
|
111
|
+
referencing: ["referencing"],
|
|
112
|
+
restrictive: ["permissive"],
|
|
113
|
+
command: ["command"],
|
|
114
|
+
roles: ["roles"],
|
|
115
|
+
check: ["check"],
|
|
116
|
+
attributes: ["options"],
|
|
117
|
+
connectionLimit: ["connectionLimit"],
|
|
118
|
+
rowSecurity: ["rowSecurity"],
|
|
119
|
+
forceRowSecurity: ["forceRowSecurity"],
|
|
77
120
|
};
|
|
78
121
|
|
|
79
122
|
/** Props keys that hold constraints, by constraint kind. */
|
|
@@ -150,39 +193,57 @@ export async function observeResourcesDeep(
|
|
|
150
193
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
151
194
|
let target;
|
|
152
195
|
let live: LivePgObject[];
|
|
196
|
+
let client: PostgresClient;
|
|
153
197
|
const declared = options.entityNames.map((name) => ({ name, entity: options.entities.get(name) }));
|
|
154
198
|
try {
|
|
155
199
|
const bound = await bindPostgres(options);
|
|
156
200
|
target = bound.target;
|
|
201
|
+
client = bound.client;
|
|
157
202
|
try {
|
|
158
203
|
live = await readLiveSchema(bound.client, {
|
|
159
204
|
schemas: scopeFor(target, declared.filter((x) => x.entity?.entityType.startsWith("Postgres::")).map((x) => ({ type: x.entity!.entityType, props: x.entity!.props }))),
|
|
205
|
+
access: target.access === true,
|
|
206
|
+
roles: declared.filter((x) => x.entity?.entityType === POSTGRES_ENTITY_TYPES.role).map((x) => String(x.entity!.props.name)),
|
|
160
207
|
});
|
|
161
|
-
}
|
|
208
|
+
} catch (err) {
|
|
162
209
|
await bound.client.end();
|
|
210
|
+
throw err;
|
|
163
211
|
}
|
|
164
212
|
} catch (err) {
|
|
165
213
|
const why = classifyPostgresFailure(err);
|
|
166
214
|
for (const { name, entity } of declared) unobserved[name] = { type: entity?.entityType ?? "", reason: why.reason, detail: why.detail };
|
|
167
215
|
return deepObservation({}, unobserved);
|
|
168
216
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
217
|
+
try {
|
|
218
|
+
const byKey = new Map(live.map((o) => [liveKey(o.type, o.schema, o.name, o.signature), o]));
|
|
219
|
+
for (const { name, entity } of declared) {
|
|
220
|
+
if (!entity || !entity.entityType.startsWith("Postgres::")) {
|
|
221
|
+
unobserved[name] = { type: entity?.entityType ?? "", reason: "unsupported-kind" };
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const why = accessUnobserved(entity.entityType, target.access === true);
|
|
225
|
+
if (why) {
|
|
226
|
+
unobserved[name] = { type: entity.entityType, ...why };
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const { schema, name: objectName, signature } = declaredAddress({ type: entity.entityType, props: entity.props }, target.defaultSchema);
|
|
230
|
+
const o = byKey.get(liveKey(entity.entityType, schema, objectName, signature));
|
|
231
|
+
if (!o) continue;
|
|
232
|
+
try {
|
|
233
|
+
const lp = liveProps(o);
|
|
234
|
+
let d = canonicalPgObject(entity.entityType, entity.props, target.defaultSchema);
|
|
235
|
+
const l = canonicalPgObject(o.type, lp, target.defaultSchema);
|
|
236
|
+
// A trigger's WHEN and a policy's expressions as the server would print them, when the rules leave them different.
|
|
237
|
+
if ((d.kind === "trigger" && d.fields.when !== l.fields.when) || (d.kind === "policy" && (d.fields.using !== l.fields.using || d.fields.check !== l.fields.check))) {
|
|
238
|
+
d = await serverNormalized(client, d, target.defaultSchema);
|
|
239
|
+
}
|
|
240
|
+
resources[name] = { type: o.type, physicalId: o.oid, properties: inDeclaredVocabulary(entity.props, lp, d, l) };
|
|
241
|
+
} catch (err) {
|
|
242
|
+
unobserved[name] = { type: entity.entityType, reason: "read-failed", detail: `the server's definition does not parse: ${(err as Error).message}` };
|
|
243
|
+
}
|
|
185
244
|
}
|
|
245
|
+
} finally {
|
|
246
|
+
await client.end();
|
|
186
247
|
}
|
|
187
248
|
return deepObservation(resources, unobserved);
|
|
188
249
|
}
|
|
@@ -16,14 +16,21 @@
|
|
|
16
16
|
import { classifiedChange, type ChangeSet, type ClassifiedChange } from "../../core/classifier";
|
|
17
17
|
import { matchByIdentity } from "../../core/diff";
|
|
18
18
|
import { PG_CLASSIFIER_RULES, type PgChangeClass, type PgClassifierRuleId } from "./rules";
|
|
19
|
-
import { sameConstraint, serialBase, type CanonicalColumn, type CanonicalConstraint } from "./normalize";
|
|
20
|
-
import
|
|
19
|
+
import { ACCESS_KINDS, sameConstraint, serialBase, type CanonicalColumn, type CanonicalConstraint } from "./normalize";
|
|
20
|
+
import { builtinEntries, declaredAccess, diffAccess, entryKey, targetKey, type AccessChange } from "../access/acl";
|
|
21
|
+
import { pgNamespace, type PgDiffObject, type PgSchemaObject } from "./schema";
|
|
21
22
|
import { POSTGRES_LATEST_MAJOR } from "../../spec/postgres-pin";
|
|
22
23
|
import type { PostgresMigrationOpSuggestion } from "../migrate/handoff";
|
|
23
24
|
|
|
24
25
|
/** What the diff needs to know beyond the two schemas: the server's major (`sql.postgresMajor`, else the newest pinned). */
|
|
25
26
|
export interface PgDiffOptions {
|
|
26
27
|
major?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Whether to compare the access the two sides declare (grants, revokes,
|
|
30
|
+
* default privileges). On by default, for a diff between two builds; a plan
|
|
31
|
+
* against a server compares the server's access itself (`./commands.ts`).
|
|
32
|
+
*/
|
|
33
|
+
access?: boolean;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export type PgChange = ClassifiedChange<PgClassifierRuleId, PgChangeClass>;
|
|
@@ -33,12 +40,14 @@ export interface PgSchemaDiff extends ChangeSet<PgChange> {
|
|
|
33
40
|
refused: PgChange[];
|
|
34
41
|
/** The `PostgresMigrationOp` to run for each refused column rename or type change (#3281). */
|
|
35
42
|
migrationOps?: PostgresMigrationOpSuggestion[];
|
|
43
|
+
/** The access changes among `changes`, each with its GRANT, REVOKE or ALTER DEFAULT PRIVILEGES statements (#3681). */
|
|
44
|
+
access?: AccessChange[];
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
const change = (object: string, field: string, rule: PgClassifierRuleId, before?: unknown, after?: unknown, extra: Partial<PgChange> = {}): PgChange =>
|
|
39
48
|
classifiedChange(PG_CLASSIFIER_RULES, object, field, rule, before, after, extra);
|
|
40
49
|
|
|
41
|
-
const qualified = (o: PgDiffObject) => `${o.schema ? `${o.schema}.` : ""}${o.name}`;
|
|
50
|
+
const qualified = (o: PgDiffObject) => `${o.schema ? `${o.schema}.` : ""}${o.name}${o.kind === "trigger" || o.kind === "policy" ? "" : (o.signature ?? "")}`;
|
|
42
51
|
|
|
43
52
|
// ── Columns ────────────────────────────────────────────────────────────
|
|
44
53
|
|
|
@@ -258,7 +267,67 @@ function diffObject(key: string, before: PgDiffObject, after: PgDiffObject, out:
|
|
|
258
267
|
field("columnComments", "SQLPG216");
|
|
259
268
|
break;
|
|
260
269
|
}
|
|
270
|
+
case "function":
|
|
271
|
+
case "procedure": {
|
|
272
|
+
// CREATE OR REPLACE cannot change the result, the OUT parameters, an input parameter's name, a removed default or a window function's kind.
|
|
273
|
+
const ba = (before.fields.args as string[] | undefined) ?? [];
|
|
274
|
+
const aa = (after.fields.args as string[] | undefined) ?? [];
|
|
275
|
+
const inputName = (a: string) => (/^(in|inout|variadic) /.test(a) ? a.split(" ")[1] : undefined);
|
|
276
|
+
const reasons: string[] = [];
|
|
277
|
+
// Between two builds, the same export with other parameter types is another function on the server.
|
|
278
|
+
if (before.signature !== after.signature) reasons.push(`the parameter types ${before.signature} -> ${after.signature}`);
|
|
279
|
+
if (!same(before.fields.returns, after.fields.returns)) reasons.push(`the result ${show(before.fields.returns) ?? "-"} -> ${show(after.fields.returns) ?? "-"}`);
|
|
280
|
+
const outs = (l: string[]) => l.filter((a) => /^(out|inout) /.test(a));
|
|
281
|
+
if (!same(outs(ba), outs(aa))) reasons.push("the output parameters");
|
|
282
|
+
if (ba.some((a, i) => aa[i] !== undefined && inputName(a) !== inputName(aa[i]!) && /^(in|inout|variadic) /.test(aa[i]!))) reasons.push("a parameter's name");
|
|
283
|
+
if (ba.some((a, i) => / default /.test(a) && aa[i] !== undefined && !/ default /.test(aa[i]!))) reasons.push("a parameter's default is removed");
|
|
284
|
+
if (!same(before.fields.window, after.fields.window)) reasons.push("WINDOW");
|
|
285
|
+
const definition = ["args", "returns", "language", "body", "link", "securityDefiner", "set", "transform", "volatility", "strict", "leakproof", "parallel", "cost", "rows", "support", "window"];
|
|
286
|
+
const changed = definition.filter((k) => !same(before.fields[k], after.fields[k]));
|
|
287
|
+
if (changed.length === 0 && reasons.length === 0) break;
|
|
288
|
+
if (changed.length === 0) changed.push("args");
|
|
289
|
+
const what = changed.join(", ");
|
|
290
|
+
if (reasons.length === 0) out.push(change(key, what, "SQLPG280", undefined, undefined, { note: `CREATE OR REPLACE ${after.kind.toUpperCase()}` }));
|
|
291
|
+
else if (before.dependents && before.dependents.length > 0) {
|
|
292
|
+
out.push(change(key, what, "SQLPG282", undefined, undefined, { note: `${reasons.join(", ")} changes, and ${before.dependents.join(", ")} depend${before.dependents.length === 1 ? "s" : ""} on it` }));
|
|
293
|
+
} else out.push(change(key, what, "SQLPG281", undefined, undefined, { note: `${reasons.join(", ")} changes, which CREATE OR REPLACE refuses` }));
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "trigger": {
|
|
297
|
+
const definition = ["table", "constraint", "timing", "events", "forEach", "when", "function", "args", "from", "deferrable", "initiallyDeferred", "referencing"];
|
|
298
|
+
const changed = definition.filter((k) => !same(before.fields[k], after.fields[k]));
|
|
299
|
+
if (changed.length > 0) {
|
|
300
|
+
out.push(
|
|
301
|
+
change(key, changed.join(", "), "SQLPG284", changed.map((k) => `${k} ${show(before.fields[k]) ?? "-"}`).join("; "), changed.map((k) => `${k} ${show(after.fields[k]) ?? "-"}`).join("; "), after.fields.constraint || before.fields.constraint || !same(before.fields.table, after.fields.table) ? { note: "dropped and created in one transaction: a constraint trigger has no OR REPLACE, and OR REPLACE keeps a trigger on its table" } : {}),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case "policy": {
|
|
307
|
+
// ALTER POLICY sets the roles, USING and WITH CHECK; the command and AS RESTRICTIVE are fixed, and neither expression can be taken away.
|
|
308
|
+
const definition = ["table", "restrictive", "command", "roles", "using", "check"];
|
|
309
|
+
const changed = definition.filter((k) => !same(before.fields[k], after.fields[k]));
|
|
310
|
+
if (changed.length === 0) break;
|
|
311
|
+
const recreate =
|
|
312
|
+
!same(before.fields.table, after.fields.table) ||
|
|
313
|
+
!same(before.fields.restrictive, after.fields.restrictive) ||
|
|
314
|
+
!same(before.fields.command, after.fields.command) ||
|
|
315
|
+
(before.fields.using !== undefined && after.fields.using === undefined) ||
|
|
316
|
+
(before.fields.check !== undefined && after.fields.check === undefined);
|
|
317
|
+
out.push(
|
|
318
|
+
change(key, changed.join(", "), "SQLPG291", changed.map((k) => `${k} ${show(before.fields[k]) ?? "-"}`).join("; "), changed.map((k) => `${k} ${show(after.fields[k]) ?? "-"}`).join("; "), recreate ? { note: "dropped and created in one transaction: ALTER POLICY cannot change its command, AS RESTRICTIVE or table, or take an expression away" } : {}),
|
|
319
|
+
);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case "role":
|
|
323
|
+
field("attributes", "SQLPG294");
|
|
324
|
+
field("connectionLimit", "SQLPG294");
|
|
325
|
+
break;
|
|
261
326
|
case "table":
|
|
327
|
+
if (!same(before.fields.rowSecurity, after.fields.rowSecurity) || !same(before.fields.forceRowSecurity, after.fields.forceRowSecurity)) {
|
|
328
|
+
const state = (o: PgDiffObject) => (o.fields.rowSecurity ? (o.fields.forceRowSecurity ? "enabled, forced" : "enabled") : o.fields.forceRowSecurity ? "disabled, forced" : "disabled");
|
|
329
|
+
out.push(change(key, "rowSecurity", "SQLPG293", state(before), state(after), after.fields.rowSecurity && !before.fields.rowSecurity ? { note: "with row-level security on, a role the table's policies do not let in sees no rows" } : {}));
|
|
330
|
+
}
|
|
262
331
|
diffColumns(key, before, after, out, hints, false, major);
|
|
263
332
|
diffConstraints(key, before, after, out, false);
|
|
264
333
|
field("with", "SQLPG224");
|
|
@@ -288,17 +357,21 @@ function createdTables(matches: ReturnType<typeof matchByIdentity<PgDiffObject>>
|
|
|
288
357
|
*/
|
|
289
358
|
export function matchPgObjects(before: readonly PgSchemaObject[], after: readonly PgSchemaObject[]): ReturnType<typeof matchByIdentity<PgDiffObject>> {
|
|
290
359
|
return matchByIdentity(before, after, {
|
|
291
|
-
qualified: (o) => `${o.kind
|
|
360
|
+
qualified: (o) => `${pgNamespace(o.kind)} ${o.schema ? `${o.schema}.` : ""}${o.name}${o.signature ?? ""}`,
|
|
292
361
|
previously: (o) => o.previously,
|
|
293
362
|
previousNames: (o, prev) => {
|
|
294
|
-
const space = o.kind
|
|
295
|
-
|
|
363
|
+
const space = pgNamespace(o.kind);
|
|
364
|
+
const sig = o.signature ?? "";
|
|
365
|
+
return [`${space} ${prev}${sig}`, ...(o.schema && !prev.includes(".") ? [`${space} ${o.schema}.${prev}${sig}`] : [])];
|
|
296
366
|
},
|
|
297
367
|
});
|
|
298
368
|
}
|
|
299
369
|
|
|
300
|
-
export function diffPgSchemas(
|
|
370
|
+
export function diffPgSchemas(beforeAll: readonly PgSchemaObject[], afterAll: readonly PgSchemaObject[], options: PgDiffOptions = {}): PgSchemaDiff {
|
|
301
371
|
const major = options.major ?? POSTGRES_LATEST_MAJOR;
|
|
372
|
+
// Grants and default privileges are compared as the access they add up to, below, not one declaration against another.
|
|
373
|
+
const before = beforeAll.filter((o) => !ACCESS_KINDS.has(o.canonical.kind));
|
|
374
|
+
const after = afterAll.filter((o) => !ACCESS_KINDS.has(o.canonical.kind));
|
|
302
375
|
const matches = matchPgObjects(before, after);
|
|
303
376
|
const newTables = createdTables(matches);
|
|
304
377
|
const changes: PgChange[] = [];
|
|
@@ -308,6 +381,10 @@ export function diffPgSchemas(before: readonly PgSchemaObject[], after: readonly
|
|
|
308
381
|
const o = m.after.canonical;
|
|
309
382
|
if (o.kind === "index" && !newTables.has(String(o.fields.table))) {
|
|
310
383
|
changes.push(change(m.after.key, "index", o.concurrently ? "SQLPG240" : "SQLPG241", undefined, qualified(o)));
|
|
384
|
+
} else if (o.kind === "trigger" && !newTables.has(String(o.fields.table))) {
|
|
385
|
+
changes.push(change(m.after.key, "trigger", "SQLPG283", undefined, `${o.name}${o.signature ?? ""}`));
|
|
386
|
+
} else if (o.kind === "policy" && !newTables.has(String(o.fields.table))) {
|
|
387
|
+
changes.push(change(m.after.key, "policy", "SQLPG290", undefined, `${o.name}${o.signature ?? ""}`));
|
|
311
388
|
} else changes.push(change(m.after.key, o.kind, "SQLPG200", undefined, qualified(o)));
|
|
312
389
|
} else if (m.kind === "dropped") {
|
|
313
390
|
const o = m.before.canonical;
|
|
@@ -315,9 +392,37 @@ export function diffPgSchemas(before: readonly PgSchemaObject[], after: readonly
|
|
|
315
392
|
hints.push(`${m.before.key} is kept by ${o.foreign}; it is not chant's to drop and is left alone`);
|
|
316
393
|
continue;
|
|
317
394
|
}
|
|
395
|
+
if (o.kind === "role") {
|
|
396
|
+
hints.push(`role ${o.name} is no longer declared; a role is the cluster's and is never dropped`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
318
399
|
const destructive = o.kind === "table" || o.kind === "materializedView" || o.kind === "sequence";
|
|
319
|
-
|
|
400
|
+
const rule = o.kind === "index" ? "SQLPG242" : o.kind === "trigger" ? "SQLPG285" : o.kind === "policy" ? "SQLPG292" : "SQLPG270";
|
|
401
|
+
changes.push(change(m.before.key, o.kind, rule, o.kind === "trigger" || o.kind === "policy" ? `${o.name}${o.signature ?? ""}` : qualified(o), undefined, destructive ? { destructive: true } : {}));
|
|
320
402
|
} else diffObject(m.after.key, m.before.canonical, m.after.canonical, changes, hints, major);
|
|
321
403
|
}
|
|
322
|
-
|
|
404
|
+
const access = options.access === false ? [] : buildAccessChanges(beforeAll, afterAll, major);
|
|
405
|
+
changes.push(...access.map((a) => a.change));
|
|
406
|
+
return { changes, hints, refused: changes.filter((c) => c.class === "expand"), ...(access.length > 0 ? { access } : {}) };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The access change between two builds: on what the newer build decides. An
|
|
411
|
+
* object only it declares starts from what Postgres gives a new one; an
|
|
412
|
+
* object only the older one declared is dropped with its privileges.
|
|
413
|
+
*/
|
|
414
|
+
function buildAccessChanges(before: readonly PgSchemaObject[], after: readonly PgSchemaObject[], major: number): AccessChange[] {
|
|
415
|
+
const b = declaredAccess(before, { major });
|
|
416
|
+
const a = declaredAccess(after, { major });
|
|
417
|
+
const decided = new Set(a.targets.map(targetKey));
|
|
418
|
+
const known = new Set(b.targets.map(targetKey));
|
|
419
|
+
const from = new Map([...b.state].filter(([, e]) => decided.has(targetKey(e.target))));
|
|
420
|
+
for (const t of a.targets) {
|
|
421
|
+
if (known.has(targetKey(t)) || t.kind === "default" || t.kind === "column") continue;
|
|
422
|
+
for (const e of builtinEntries(t.kind)) {
|
|
423
|
+
const key = entryKey(t, e.grantee);
|
|
424
|
+
from.set(key, { target: t, grantee: e.grantee, privileges: new Map([...(from.get(key)?.privileges ?? []), [e.privilege, false]]) });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return diffAccess(from, a.state, a.exportsOf);
|
|
323
428
|
}
|