@intentius/chant-lexicon-sql 0.116.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.
Files changed (107) hide show
  1. package/dist/codegen/registry.d.ts.map +1 -1
  2. package/dist/config.d.ts +20 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/integrity.json +5 -5
  7. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  8. package/dist/lsp/postgres-template.d.ts.map +1 -1
  9. package/dist/manifest.json +1 -1
  10. package/dist/meta.json +20 -0
  11. package/dist/okf/index.md +4 -0
  12. package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
  13. package/dist/okf/types/PostgresGrant.md +9 -0
  14. package/dist/okf/types/PostgresPolicy.md +9 -0
  15. package/dist/okf/types/PostgresRole.md +9 -0
  16. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  17. package/dist/op/activity-contracts.d.ts +10 -0
  18. package/dist/op/activity-contracts.d.ts.map +1 -1
  19. package/dist/plugin.d.ts.map +1 -1
  20. package/dist/postgres/access/acl.d.ts +119 -0
  21. package/dist/postgres/access/acl.d.ts.map +1 -0
  22. package/dist/postgres/access/import.d.ts +13 -0
  23. package/dist/postgres/access/import.d.ts.map +1 -0
  24. package/dist/postgres/access/live.d.ts +24 -0
  25. package/dist/postgres/access/live.d.ts.map +1 -0
  26. package/dist/postgres/apply/apply.d.ts.map +1 -1
  27. package/dist/postgres/apply/statements.d.ts +3 -0
  28. package/dist/postgres/apply/statements.d.ts.map +1 -1
  29. package/dist/postgres/entities.d.ts +106 -4
  30. package/dist/postgres/entities.d.ts.map +1 -1
  31. package/dist/postgres/entity-types.d.ts +4 -0
  32. package/dist/postgres/entity-types.d.ts.map +1 -1
  33. package/dist/postgres/import/generator.d.ts +4 -1
  34. package/dist/postgres/import/generator.d.ts.map +1 -1
  35. package/dist/postgres/import/live-export.d.ts +6 -0
  36. package/dist/postgres/import/live-export.d.ts.map +1 -1
  37. package/dist/postgres/live/bind.d.ts +2 -0
  38. package/dist/postgres/live/bind.d.ts.map +1 -1
  39. package/dist/postgres/live/catalog.d.ts +11 -2
  40. package/dist/postgres/live/catalog.d.ts.map +1 -1
  41. package/dist/postgres/live/describe-resources.d.ts +9 -0
  42. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  43. package/dist/postgres/migrate/op.d.ts +29 -0
  44. package/dist/postgres/migrate/op.d.ts.map +1 -1
  45. package/dist/postgres/parser.d.ts +76 -3
  46. package/dist/postgres/parser.d.ts.map +1 -1
  47. package/dist/postgres/plan/commands.d.ts +12 -0
  48. package/dist/postgres/plan/commands.d.ts.map +1 -1
  49. package/dist/postgres/plan/deep.d.ts.map +1 -1
  50. package/dist/postgres/plan/diff.d.ts +10 -1
  51. package/dist/postgres/plan/diff.d.ts.map +1 -1
  52. package/dist/postgres/plan/normalize.d.ts +8 -1
  53. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  54. package/dist/postgres/plan/rules.d.ts +8 -0
  55. package/dist/postgres/plan/rules.d.ts.map +1 -1
  56. package/dist/postgres/plan/schema.d.ts +2 -1
  57. package/dist/postgres/plan/schema.d.ts.map +1 -1
  58. package/dist/postgres/plan/server-normalize.d.ts +4 -1
  59. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  60. package/dist/postgres.d.ts +1 -1
  61. package/dist/postgres.d.ts.map +1 -1
  62. package/dist/rules/postgres-templates.ts +2 -2
  63. package/dist/rules/sqlpg001.ts +7 -4
  64. package/dist/validate.d.ts.map +1 -1
  65. package/package.json +2 -2
  66. package/src/codegen/registry.ts +4 -0
  67. package/src/config.ts +10 -0
  68. package/src/generated/lexicon-sql.json +20 -0
  69. package/src/index.ts +1 -1
  70. package/src/lint/rules/postgres-templates.ts +2 -2
  71. package/src/lint/rules/sqlpg001.ts +7 -4
  72. package/src/lsp/postgres-template.ts +2 -0
  73. package/src/mcp/postgres.ts +1 -1
  74. package/src/migration-statements.ts +7 -0
  75. package/src/op/activities/postgres-migration.ts +27 -2
  76. package/src/op/activity-contracts.ts +1 -0
  77. package/src/plugin.ts +3 -0
  78. package/src/postgres/access/access.e2e.test.ts +191 -0
  79. package/src/postgres/access/access.test.ts +147 -0
  80. package/src/postgres/access/acl.ts +400 -0
  81. package/src/postgres/access/import.ts +64 -0
  82. package/src/postgres/access/live.ts +123 -0
  83. package/src/postgres/apply/apply.ts +117 -4
  84. package/src/postgres/apply/statements.ts +55 -9
  85. package/src/postgres/entities.ts +287 -11
  86. package/src/postgres/entity-types.ts +4 -0
  87. package/src/postgres/import/generator.ts +14 -4
  88. package/src/postgres/import/live-export.ts +12 -2
  89. package/src/postgres/live/bind.ts +3 -0
  90. package/src/postgres/live/catalog.ts +79 -3
  91. package/src/postgres/live/describe-resources.ts +27 -3
  92. package/src/postgres/migrate/migrate.test.ts +24 -1
  93. package/src/postgres/migrate/op.ts +80 -35
  94. package/src/postgres/migrate/outer.e2e.test.ts +163 -0
  95. package/src/postgres/parser.ts +247 -8
  96. package/src/postgres/plan/commands.ts +60 -7
  97. package/src/postgres/plan/deep.ts +24 -3
  98. package/src/postgres/plan/diff.ts +71 -6
  99. package/src/postgres/plan/normalize.ts +115 -2
  100. package/src/postgres/plan/rules.ts +44 -0
  101. package/src/postgres/plan/schema.ts +4 -2
  102. package/src/postgres/plan/server-normalize.ts +29 -1
  103. package/src/postgres.ts +11 -0
  104. package/src/skill-defs.ts +1 -1
  105. package/src/skills/chant-sql-postgres-migration.md +3 -1
  106. package/src/skills/chant-sql-postgres.md +9 -2
  107. package/src/validate.ts +4 -0
@@ -84,6 +84,9 @@ import type { PgChangeClass } from "../plan/rules";
84
84
  import type { CanonicalKind } from "../plan/normalize";
85
85
  import { quoteIdent } from "../keywords";
86
86
  import { planPgStatements, scansRows, type DeclaredPgObject, type PgDropStatement, type PgStep } from "./statements";
87
+ import { ACCESS_KINDS, ACCESS_MANAGED_KINDS } from "../plan/normalize";
88
+ import { declaredAccess, diffAccess, targetKey } from "../access/acl";
89
+ import { readLiveAccess } from "../access/live";
87
90
 
88
91
  /** The timeouts a statement runs with, in milliseconds; 0 is no limit. */
89
92
  export interface PostgresApplyTimeouts {
@@ -144,9 +147,9 @@ export class PostgresApplyError extends SqlApplyError<PostgresApplyOutcome> {
144
147
  * (`app.touch()`), a trigger's with its table (`touch ON app.users`).
145
148
  */
146
149
  export const refName = (o: { kind: CanonicalKind; schema?: string; name: string; signature?: string }): string =>
147
- o.kind === "trigger"
150
+ o.kind === "trigger" || o.kind === "policy"
148
151
  ? `${o.name}${o.signature ?? ""}`
149
- : o.kind === "schema" || o.kind === "extension" || o.schema === undefined
152
+ : o.kind === "schema" || o.kind === "extension" || o.kind === "role" || o.schema === undefined
150
153
  ? o.name
151
154
  : `${o.schema}.${o.name}${o.kind === "function" || o.kind === "procedure" ? (o.signature ?? "") : ""}`;
152
155
 
@@ -259,7 +262,21 @@ export async function applyPostgres(
259
262
  });
260
263
  const normalizedByKey = new Map(plan.declared.map((o) => [o.key, o.canonical]));
261
264
  const liveRawByKey = new Map(plan.liveObjects.map((o) => [qualifiedKey(liveCanonicalAddress(o)), o]));
262
- const objects = declared.map((original) => ({ ...original, canonical: { ...original.canonical, ...normalizedByKey.get(original.key) } }));
265
+ const managed = target.access === true;
266
+ // Access declarations a profile does not manage are not applied; grants and default privileges run after every object, as access.
267
+ for (const o of declared) {
268
+ if (!managed && ACCESS_MANAGED_KINDS.has(o.canonical.kind)) {
269
+ outcome.notAttempted.push({
270
+ kind: o.type,
271
+ name: o.name,
272
+ reason: "filtered",
273
+ detail: `access is not managed in this environment: set sql.profiles.<env>.access to plan and apply policies, roles, grants and row-level security`,
274
+ });
275
+ }
276
+ }
277
+ const objects = declared
278
+ .filter((o) => !ACCESS_KINDS.has(o.canonical.kind) && (managed || !ACCESS_MANAGED_KINDS.has(o.canonical.kind)))
279
+ .map((original) => ({ ...original, canonical: { ...original.canonical, ...normalizedByKey.get(original.key) } }));
263
280
  const matchedLive = new Set<string>();
264
281
  for (const m of matchPgObjects(plan.live, plan.declared)) if (m.kind === "matched") matchedLive.add(m.after.key);
265
282
 
@@ -275,6 +292,7 @@ export async function applyPostgres(
275
292
  allowDestructive: opts.prune === true,
276
293
  carriesMarker: (_live, key) => carriesMarker(liveRawByKey.get(key)?.comment, opts.marker),
277
294
  extensionComment: (name) => extensionComments.get(name),
295
+ access: managed,
278
296
  ...(opts.marker ? { marker: opts.marker } : {}),
279
297
  });
280
298
 
@@ -436,11 +454,102 @@ export async function applyPostgres(
436
454
  }
437
455
  await commit();
438
456
 
457
+ if (managed) await applyAccess(client, declared, onServer, exportNames, { ...opts, major: plan.major ?? opts.major ?? POSTGRES_LATEST_MAJOR, ...(plan.scope ? { schemas: plan.scope } : {}) }, timeouts, outcome, log);
458
+
439
459
  if (opts.prune) await prune(client, statements.drops, liveRawByKey, opts, timeouts, outcome, log);
440
460
  if (outcome.failed.length > 0) throw new PostgresApplyError(outcome);
441
461
  return outcome;
442
462
  }
443
463
 
464
+ /**
465
+ * Access, after every object (#3681): the privileges and default privileges
466
+ * the declarations add up to, against what the server holds now that the
467
+ * objects exist, made in one transaction. A grant or default-privileges
468
+ * declaration is `updated` when a statement ran for it and `unchanged`
469
+ * otherwise; a privilege nothing declares is revoked under the object's name.
470
+ */
471
+ async function applyAccess(
472
+ client: PostgresClient,
473
+ declared: readonly DeclaredPgObject[],
474
+ onServer: ReadonlySet<string>,
475
+ exportNames: ReadonlySet<string>,
476
+ opts: PostgresApplyOptions & { major: number; schemas?: string[] },
477
+ timeouts: PostgresApplyTimeouts,
478
+ outcome: PostgresApplyOutcome,
479
+ log: (line: string) => void,
480
+ ): Promise<void> {
481
+ const decls = declared.filter((o) => ACCESS_KINDS.has(o.canonical.kind));
482
+ const ref = (o: DeclaredPgObject) => ({ kind: o.type, name: o.canonical.name });
483
+ const ready: DeclaredPgObject[] = [];
484
+ for (const o of decls) {
485
+ const missing = missingDependencies(o.exportName, o.dependsOn, exportNames, onServer);
486
+ if (missing.length > 0) outcome.notAttempted.push({ ...ref(o), reason: "dependency-failed", detail: dependencyFailedDetail(missing) });
487
+ else ready.push(o);
488
+ }
489
+ const skipped = new Set(decls.filter((o) => !ready.includes(o)).map((o) => o.exportName));
490
+ const [me] = await client.query<{ self: string }>("SELECT current_user AS self");
491
+ const want = declaredAccess(
492
+ declared.filter((o) => !skipped.has(o.exportName)).map((o) => ({ key: o.exportName, canonical: o.canonical })),
493
+ { major: opts.major, ...(me?.self ? { self: me.self } : {}) },
494
+ );
495
+ const have = await readLiveAccess(client, want.targets, {
496
+ ...(opts.schemas ? { schemas: opts.schemas } : {}),
497
+ forRoles: want.forRoles,
498
+ declaredDefaults: want.targets.filter((t) => t.kind === "default"),
499
+ });
500
+ // A target that is not on the server (its object failed) is left for the next apply.
501
+ const changes = diffAccess(have.state, want.state, want.exportsOf).filter(
502
+ (c) => c.target.kind === "default" || have.present.has(targetKey(c.target.kind === "column" ? { kind: "table", name: c.target.name } : c.target)),
503
+ );
504
+ const ranFor = new Map<string, string[]>();
505
+ if (changes.length > 0) {
506
+ const t = { lockTimeoutMs: timeouts.lockTimeoutMs, statementTimeoutMs: timeouts.statementTimeoutMs };
507
+ const number = outcome.transactions.length;
508
+ const members = new Set<string>();
509
+ log("BEGIN");
510
+ await client.query("BEGIN");
511
+ try {
512
+ for (const set of [`SET LOCAL lock_timeout = ${ms(t.lockTimeoutMs)}`, `SET LOCAL statement_timeout = ${ms(t.statementTimeoutMs)}`]) {
513
+ log(set);
514
+ await client.query(set);
515
+ }
516
+ for (const c of changes) {
517
+ const object = c.exports[0] !== undefined ? (declared.find((o) => o.exportName === c.exports[0])?.canonical.name ?? c.change.object) : c.change.object.slice(4);
518
+ members.add(object);
519
+ for (const sql of c.sql) {
520
+ opts.signal?.throwIfAborted();
521
+ log(sql);
522
+ outcome.statements.push({ object, sql, class: c.change.class, ...t, transaction: number });
523
+ await client.query(sql);
524
+ for (const e of c.exports) ranFor.set(e, [...(ranFor.get(e) ?? []), sql]);
525
+ }
526
+ }
527
+ log("COMMIT");
528
+ await client.query("COMMIT");
529
+ outcome.transactions.push({ objects: [...members], result: "committed" });
530
+ } catch (err) {
531
+ if (opts.signal?.aborted) {
532
+ await client.query("ROLLBACK").catch(() => undefined);
533
+ throw err;
534
+ }
535
+ const error = errorText(err, t);
536
+ const failedAt = outcome.statements[outcome.statements.length - 1]?.sql ?? "BEGIN";
537
+ log("ROLLBACK");
538
+ await client.query("ROLLBACK").catch(() => undefined);
539
+ outcome.transactions.push({ objects: [...members], result: "rolled-back", failedAt: { sql: failedAt, error } });
540
+ for (const o of ready) {
541
+ if (changes.some((c) => c.exports.includes(o.exportName))) outcome.failed.push({ ...ref(o), error: `${error}, in the access transaction at ${failedAt}`, statements: [] });
542
+ else outcome.applied.push({ ...ref(o), action: "unchanged", statements: [] });
543
+ }
544
+ return;
545
+ }
546
+ }
547
+ for (const o of ready) {
548
+ const ran = ranFor.get(o.exportName) ?? [];
549
+ outcome.applied.push({ ...ref(o), action: ran.length > 0 ? "updated" : "unchanged", statements: ran });
550
+ }
551
+ }
552
+
444
553
  /** The address a live object is keyed by in the diff. */
445
554
  function liveCanonicalAddress(o: LivePgObject): { kind: CanonicalKind; schema?: string; name: string; signature?: string } {
446
555
  const kind = (
@@ -457,11 +566,15 @@ function liveCanonicalAddress(o: LivePgObject): { kind: CanonicalKind; schema?:
457
566
  "Postgres::Function": "function",
458
567
  "Postgres::Procedure": "procedure",
459
568
  "Postgres::Trigger": "trigger",
569
+ "Postgres::Policy": "policy",
570
+ "Postgres::Role": "role",
571
+ "Postgres::Grant": "grant",
572
+ "Postgres::DefaultPrivileges": "defaultPrivileges",
460
573
  } as const
461
574
  )[o.type];
462
575
  return {
463
576
  kind,
464
- ...(o.schema !== undefined && kind !== "schema" && kind !== "extension" ? { schema: o.schema } : {}),
577
+ ...(o.schema !== undefined && kind !== "schema" && kind !== "extension" && kind !== "role" ? { schema: o.schema } : {}),
465
578
  name: o.name,
466
579
  ...(o.signature !== undefined ? { signature: o.signature } : {}),
467
580
  };
@@ -29,10 +29,12 @@ import { POSTGRES_ENTITY_TYPES, type PostgresEntityType } from "../entity-types"
29
29
  import { addConstraintRule, matchPgObjects } from "../plan/diff";
30
30
  import type { PgChange } from "../plan/diff";
31
31
  import { PG_CLASSIFIER_RULES, type PgChangeClass, type PgClassifierRuleId } from "../plan/rules";
32
- import { canonicalOptions, sameConstraint, type CanonicalConstraint, type CanonicalKind } from "../plan/normalize";
32
+ import { ACCESS_KINDS, canonicalOptions, sameConstraint, type CanonicalConstraint, type CanonicalKind } from "../plan/normalize";
33
33
  import type { PgDiffObject, PgSchemaObject } from "../plan/schema";
34
34
  import { migrationTarget } from "../migrate/handoff";
35
- import type { CheckDef, ColumnDef, DomainProps, ExclusionDef, ForeignKeyDef, KeyDef, SequenceProps, TableProps } from "../entities";
35
+ import type { CheckDef, ColumnDef, DomainProps, ExclusionDef, ForeignKeyDef, KeyDef, PolicyProps, SequenceProps, TableProps } from "../entities";
36
+
37
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
36
38
 
37
39
  /** One object as a build declares it, in the shape the applier works with. */
38
40
  export interface DeclaredPgObject {
@@ -86,9 +88,9 @@ export const pgQualified = (schema: string | undefined, name: string): string =>
86
88
  * trigger with its table (`touch ON app.users`).
87
89
  */
88
90
  export const objectIdent = (o: { kind: CanonicalKind; schema?: string; name: string; signature?: string }): string =>
89
- o.kind === "schema" || o.kind === "extension"
91
+ o.kind === "schema" || o.kind === "extension" || o.kind === "role"
90
92
  ? quoteIdent(o.name)
91
- : o.kind === "trigger"
93
+ : o.kind === "trigger" || o.kind === "policy"
92
94
  ? `${quoteIdent(o.name)}${o.signature ?? ""}`
93
95
  : `${pgQualified(o.schema, o.name)}${o.signature ?? ""}`;
94
96
 
@@ -106,6 +108,10 @@ export const KIND_WORDS: Readonly<Record<CanonicalKind, string>> = {
106
108
  function: "FUNCTION",
107
109
  procedure: "PROCEDURE",
108
110
  trigger: "TRIGGER",
111
+ policy: "POLICY",
112
+ role: "ROLE",
113
+ grant: "GRANT",
114
+ defaultPrivileges: "DEFAULT PRIVILEGES",
109
115
  };
110
116
 
111
117
  /** The canonical kind of an entity type. */
@@ -172,12 +178,14 @@ export function commentStatement(obj: Pick<DeclaredPgObject, "canonical">, marke
172
178
  export function createSteps(
173
179
  obj: Pick<DeclaredPgObject, "ddl" | "canonical" | "props">,
174
180
  marker: OwnershipMarker | undefined,
175
- opts: { base?: string; orReplace?: boolean; cls?: PgChangeClass; rule?: PgClassifierRuleId } = {},
181
+ opts: { base?: string; orReplace?: boolean; cls?: PgChangeClass; rule?: PgClassifierRuleId; access?: boolean } = {},
176
182
  ): PgStep[] {
177
183
  const cls = opts.cls ?? "create";
178
184
  const out: PgStep[] = [];
179
185
  splitStatements(obj.ddl).forEach((s, i) => {
180
186
  if (i > 0 && isOwnComment(s, obj.canonical.kind)) return;
187
+ // A table's row-level security is access, which only a profile that manages access applies.
188
+ if (i > 0 && opts.access === false && firstNode(s)?.statement === "rowSecurity") return;
181
189
  let sql = s.text;
182
190
  const replaceable = obj.canonical.kind === "view" || obj.canonical.kind === "function" || obj.canonical.kind === "procedure" || (obj.canonical.kind === "trigger" && obj.canonical.fields.constraint !== true);
183
191
  if (i === 0 && opts.orReplace && replaceable && !/^\s*CREATE\s+OR\s+REPLACE\b/i.test(sql)) sql = sql.replace(/\bCREATE\s+/i, "CREATE OR REPLACE ");
@@ -192,7 +200,7 @@ export function createSteps(
192
200
  export function dropStatement(kind: CanonicalKind, schema: string | undefined, name: string, signature?: string): PgStep {
193
201
  const what = objectIdent({ kind, ...(schema !== undefined ? { schema } : {}), name, ...(signature !== undefined ? { signature } : {}) });
194
202
  if (kind === "index") return step(`DROP INDEX CONCURRENTLY ${what}`, "concurrently", "SQLPG242", false);
195
- return step(`DROP ${KIND_WORDS[kind]} ${what}`, "drop", kind === "trigger" ? "SQLPG285" : "SQLPG270");
203
+ return step(`DROP ${KIND_WORDS[kind]} ${what}`, "drop", kind === "trigger" ? "SQLPG285" : kind === "policy" ? "SQLPG292" : "SQLPG270");
196
204
  }
197
205
 
198
206
  // ── Columns ────────────────────────────────────────────────────────────
@@ -553,6 +561,36 @@ export function alterSteps(
553
561
  case "SQLPG280":
554
562
  steps.push(...createSteps(obj, opts.marker, { orReplace: true, cls: c.class, rule: c.rule }).slice(0, 1));
555
563
  break;
564
+ case "SQLPG291": {
565
+ const p = obj.props as unknown as PolicyProps;
566
+ const recreate =
567
+ ["table", "restrictive", "command"].some((k) => !same(d.fields[k], live.fields[k])) ||
568
+ (live.fields.using !== undefined && d.fields.using === undefined) ||
569
+ (live.fields.check !== undefined && d.fields.check === undefined);
570
+ if (recreate) {
571
+ steps.push(step(`DROP POLICY ${objectIdent(live)}`, c.class, c.rule));
572
+ steps.push(...createSteps(obj, opts.marker, { cls: c.class, rule: c.rule }));
573
+ } else {
574
+ const roles = p.roles.map((r) => (r === "public" ? "PUBLIC" : quoteIdent(r))).join(", ");
575
+ steps.push(step([`ALTER POLICY ${objectIdent(d)} TO ${roles}`, p.using ? `USING (${p.using})` : "", p.check ? `WITH CHECK (${p.check})` : ""].filter(Boolean).join(" "), c.class, c.rule));
576
+ }
577
+ break;
578
+ }
579
+ case "SQLPG293":
580
+ if (!same(d.fields.rowSecurity, live.fields.rowSecurity)) steps.push(step(`${alterKind} ${d.fields.rowSecurity ? "ENABLE" : "DISABLE"} ROW LEVEL SECURITY`, c.class, c.rule));
581
+ if (!same(d.fields.forceRowSecurity, live.fields.forceRowSecurity)) steps.push(step(`${alterKind} ${d.fields.forceRowSecurity ? "FORCE" : "NO FORCE"} ROW LEVEL SECURITY`, c.class, c.rule));
582
+ break;
583
+ case "SQLPG294": {
584
+ if (steps.some((x) => x.rule === "SQLPG294")) break;
585
+ const want = new Set((d.fields.attributes as string[] | undefined) ?? []);
586
+ const have = new Set((live.fields.attributes as string[] | undefined) ?? []);
587
+ const flip = (a: string) => (a.startsWith("no") ? a.slice(2) : `no${a}`);
588
+ // An attribute at its default is in neither set: write the side that changes.
589
+ const words = [...[...want].filter((a) => !have.has(a)), ...[...have].filter((a) => !want.has(a)).map(flip)];
590
+ const limit = d.fields.connectionLimit !== live.fields.connectionLimit ? [`CONNECTION LIMIT ${String(d.fields.connectionLimit ?? -1)}`] : [];
591
+ steps.push(step(`ALTER ROLE ${objectIdent(d)} WITH ${[...words.map((w) => w.toUpperCase()), ...limit].join(" ")}`, c.class, c.rule));
592
+ break;
593
+ }
556
594
  case "SQLPG284":
557
595
  if (d.fields.constraint === true || live.fields.constraint === true || d.signature !== live.signature) {
558
596
  steps.push(step(`DROP TRIGGER ${objectIdent(live)}`, c.class, c.rule));
@@ -674,6 +712,8 @@ export interface PgStatementPlanInput {
674
712
  carriesMarker?: (live: PgDiffObject, currentKey: string) => boolean;
675
713
  /** The comment an extension's control file sets, kept under the trailer when the declaration sets none. */
676
714
  extensionComment?: (name: string) => string | undefined;
715
+ /** Whether access is managed: off, a table is created without its row-level security statements. Default: on. */
716
+ access?: boolean;
677
717
  }
678
718
 
679
719
  export interface PgStatementPlan {
@@ -685,6 +725,9 @@ export interface PgStatementPlan {
685
725
 
686
726
  /** Drop order: what reads from a table before the table, types after the tables using them, a schema last. */
687
727
  const DROP_ORDER: Record<CanonicalKind, number> = {
728
+ grant: 0,
729
+ defaultPrivileges: 0,
730
+ policy: 0,
688
731
  trigger: 0,
689
732
  materializedView: 1,
690
733
  view: 2,
@@ -698,6 +741,8 @@ const DROP_ORDER: Record<CanonicalKind, number> = {
698
741
  enum: 8,
699
742
  extension: 9,
700
743
  schema: 10,
744
+ // A role is the cluster's: never dropped by a prune (`../live/catalog.ts` reads only the declared ones).
745
+ role: 11,
701
746
  };
702
747
 
703
748
  /**
@@ -718,7 +763,8 @@ export function planPgStatements(input: PgStatementPlanInput): PgStatementPlan {
718
763
  }
719
764
 
720
765
  const recreatedRelations = new Set<string>();
721
- const objects = input.declared.map((obj): PgObjectStatements => {
766
+ // Grants and default privileges are access, made by their own statements (`../access/acl.ts`).
767
+ const objects = input.declared.filter((obj) => !ACCESS_KINDS.has(obj.canonical.kind)).map((obj): PgObjectStatements => {
722
768
  const key = keyOf(obj);
723
769
  const currentKey = matched.get(key);
724
770
  const live = currentKey !== undefined ? currentByKey.get(currentKey) : undefined;
@@ -746,7 +792,7 @@ export function planPgStatements(input: PgStatementPlanInput): PgStatementPlan {
746
792
  const base = obj.canonical.kind === "extension" && !live ? input.extensionComment?.(obj.canonical.name) : undefined;
747
793
  // An index on a table that exists is built under the scan timeout (SQLPG240, SQLPG241); one on a new table is part of the create.
748
794
  const how = mine.find((c) => c.rule === "SQLPG200" || c.rule === "SQLPG240" || c.rule === "SQLPG241");
749
- const steps = createSteps(obj, input.marker, { ...(base !== undefined ? { base } : {}), ...(how ? { cls: how.class, rule: how.rule } : {}) });
795
+ const steps = createSteps(obj, input.marker, { ...(base !== undefined ? { base } : {}), ...(how ? { cls: how.class, rule: how.rule } : {}), ...(input.access === false ? { access: false } : {}) });
750
796
  return { verdict: "create", obj, changes: mine, steps, ...withLive };
751
797
  }
752
798
 
@@ -770,7 +816,7 @@ export function planPgStatements(input: PgStatementPlanInput): PgStatementPlan {
770
816
 
771
817
  const drops: PgDropStatement[] = [];
772
818
  for (const c of input.changes) {
773
- if (!((c.rule === "SQLPG270" || c.rule === "SQLPG242" || c.rule === "SQLPG285") && c.after === undefined)) continue;
819
+ if (!((c.rule === "SQLPG270" || c.rule === "SQLPG242" || c.rule === "SQLPG285" || c.rule === "SQLPG292") && c.after === undefined)) continue;
774
820
  const o = currentByKey.get(c.object);
775
821
  if (!o || o.foreign) continue;
776
822
  const schema = o.kind === "schema" || o.kind === "extension" ? undefined : o.schema;