@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.
Files changed (259) hide show
  1. package/dist/clickhouse/access.d.ts +132 -0
  2. package/dist/clickhouse/access.d.ts.map +1 -0
  3. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  4. package/dist/clickhouse/apply/statements.d.ts +2 -0
  5. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  6. package/dist/clickhouse/entities.d.ts +171 -2
  7. package/dist/clickhouse/entities.d.ts.map +1 -1
  8. package/dist/clickhouse/http.d.ts +3 -0
  9. package/dist/clickhouse/http.d.ts.map +1 -1
  10. package/dist/clickhouse/import/generator.d.ts +28 -0
  11. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  12. package/dist/clickhouse/import/ir.d.ts.map +1 -1
  13. package/dist/clickhouse/import/parser.d.ts +3 -3
  14. package/dist/clickhouse/import/parser.d.ts.map +1 -1
  15. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  16. package/dist/clickhouse/live/catalog.d.ts +41 -6
  17. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  18. package/dist/clickhouse/live/describe-resources.d.ts +3 -2
  19. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  20. package/dist/clickhouse/parser.d.ts +54 -3
  21. package/dist/clickhouse/parser.d.ts.map +1 -1
  22. package/dist/clickhouse/plan/commands.d.ts +3 -1
  23. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  24. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  25. package/dist/clickhouse/plan/diff.d.ts +12 -0
  26. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  27. package/dist/clickhouse/plan/normalize.d.ts +61 -1
  28. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  29. package/dist/clickhouse/plan/report.d.ts.map +1 -1
  30. package/dist/clickhouse/plan/rules.d.ts +7 -0
  31. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  32. package/dist/clickhouse/plan/schema.d.ts +8 -4
  33. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  34. package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
  35. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  36. package/dist/clickhouse/rebuild/observe.d.ts +7 -3
  37. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  38. package/dist/clickhouse/rebuild/op.d.ts +8 -0
  39. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  40. package/dist/clickhouse/rebuild/steps.d.ts +48 -0
  41. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  42. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  43. package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
  44. package/dist/clickhouse/topology.d.ts.map +1 -1
  45. package/dist/clickhouse.d.ts +1 -1
  46. package/dist/clickhouse.d.ts.map +1 -1
  47. package/dist/codegen/classifier-page.d.ts.map +1 -1
  48. package/dist/codegen/registry.d.ts.map +1 -1
  49. package/dist/config.d.ts +70 -7
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/core/references.d.ts +4 -2
  52. package/dist/core/references.d.ts.map +1 -1
  53. package/dist/core/statements.d.ts +11 -0
  54. package/dist/core/statements.d.ts.map +1 -1
  55. package/dist/files/build-root.d.ts +26 -0
  56. package/dist/files/build-root.d.ts.map +1 -0
  57. package/dist/files/clickhouse.d.ts +19 -0
  58. package/dist/files/clickhouse.d.ts.map +1 -0
  59. package/dist/files/common.d.ts +25 -0
  60. package/dist/files/common.d.ts.map +1 -0
  61. package/dist/files/postgres.d.ts +36 -0
  62. package/dist/files/postgres.d.ts.map +1 -0
  63. package/dist/import-parser.d.ts +25 -0
  64. package/dist/import-parser.d.ts.map +1 -0
  65. package/dist/index.d.ts +2 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/integrity.json +8 -8
  68. package/dist/lint/rules/sqlch001.d.ts +1 -1
  69. package/dist/lint/rules/sqlch001.d.ts.map +1 -1
  70. package/dist/lint/rules/sqlch002.d.ts.map +1 -1
  71. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  72. package/dist/lint/rules/templates.d.ts +1 -1
  73. package/dist/lint/rules/templates.d.ts.map +1 -1
  74. package/dist/lsp/postgres-template.d.ts.map +1 -1
  75. package/dist/manifest.json +1 -1
  76. package/dist/meta.json +50 -0
  77. package/dist/okf/index.md +11 -1
  78. package/dist/okf/rules/SQLCH001.md +2 -2
  79. package/dist/okf/types/Dictionary.md +9 -0
  80. package/dist/okf/types/Function.md +9 -0
  81. package/dist/okf/types/Grant.md +9 -0
  82. package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
  83. package/dist/okf/types/PostgresGrant.md +9 -0
  84. package/dist/okf/types/PostgresPolicy.md +9 -0
  85. package/dist/okf/types/PostgresRole.md +9 -0
  86. package/dist/okf/types/Role.md +9 -0
  87. package/dist/okf/types/RowPolicy.md +9 -0
  88. package/dist/okf/types/User.md +9 -0
  89. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  90. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  91. package/dist/op/activity-contracts.d.ts +19 -0
  92. package/dist/op/activity-contracts.d.ts.map +1 -1
  93. package/dist/plugin.d.ts.map +1 -1
  94. package/dist/postgres/access/acl.d.ts +119 -0
  95. package/dist/postgres/access/acl.d.ts.map +1 -0
  96. package/dist/postgres/access/import.d.ts +13 -0
  97. package/dist/postgres/access/import.d.ts.map +1 -0
  98. package/dist/postgres/access/live.d.ts +24 -0
  99. package/dist/postgres/access/live.d.ts.map +1 -0
  100. package/dist/postgres/apply/apply.d.ts.map +1 -1
  101. package/dist/postgres/apply/statements.d.ts +71 -0
  102. package/dist/postgres/apply/statements.d.ts.map +1 -1
  103. package/dist/postgres/entities.d.ts +106 -4
  104. package/dist/postgres/entities.d.ts.map +1 -1
  105. package/dist/postgres/entity-types.d.ts +4 -0
  106. package/dist/postgres/entity-types.d.ts.map +1 -1
  107. package/dist/postgres/import/generator.d.ts +37 -1
  108. package/dist/postgres/import/generator.d.ts.map +1 -1
  109. package/dist/postgres/import/live-export.d.ts +6 -0
  110. package/dist/postgres/import/live-export.d.ts.map +1 -1
  111. package/dist/postgres/live/bind.d.ts +2 -0
  112. package/dist/postgres/live/bind.d.ts.map +1 -1
  113. package/dist/postgres/live/catalog.d.ts +11 -2
  114. package/dist/postgres/live/catalog.d.ts.map +1 -1
  115. package/dist/postgres/live/client.d.ts +3 -0
  116. package/dist/postgres/live/client.d.ts.map +1 -1
  117. package/dist/postgres/live/describe-resources.d.ts +9 -0
  118. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  119. package/dist/postgres/migrate/op.d.ts +29 -0
  120. package/dist/postgres/migrate/op.d.ts.map +1 -1
  121. package/dist/postgres/parser.d.ts +76 -3
  122. package/dist/postgres/parser.d.ts.map +1 -1
  123. package/dist/postgres/plan/commands.d.ts +12 -0
  124. package/dist/postgres/plan/commands.d.ts.map +1 -1
  125. package/dist/postgres/plan/deep.d.ts.map +1 -1
  126. package/dist/postgres/plan/diff.d.ts +10 -1
  127. package/dist/postgres/plan/diff.d.ts.map +1 -1
  128. package/dist/postgres/plan/normalize.d.ts +8 -1
  129. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  130. package/dist/postgres/plan/rules.d.ts +8 -0
  131. package/dist/postgres/plan/rules.d.ts.map +1 -1
  132. package/dist/postgres/plan/schema.d.ts +2 -1
  133. package/dist/postgres/plan/schema.d.ts.map +1 -1
  134. package/dist/postgres/plan/server-normalize.d.ts +4 -1
  135. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  136. package/dist/postgres.d.ts +1 -1
  137. package/dist/postgres.d.ts.map +1 -1
  138. package/dist/rules/postgres-templates.ts +2 -2
  139. package/dist/rules/sqlch001.ts +26 -4
  140. package/dist/rules/sqlch002.ts +3 -2
  141. package/dist/rules/sqlpg001.ts +7 -4
  142. package/dist/rules/templates.ts +2 -2
  143. package/dist/serializer.d.ts +2 -1
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/sql-files.d.ts +79 -0
  146. package/dist/sql-files.d.ts.map +1 -0
  147. package/dist/token-source.d.ts +88 -0
  148. package/dist/token-source.d.ts.map +1 -0
  149. package/dist/validate.d.ts.map +1 -1
  150. package/package.json +2 -2
  151. package/src/clickhouse/access.test.ts +203 -0
  152. package/src/clickhouse/access.ts +595 -0
  153. package/src/clickhouse/apply/apply.ts +29 -20
  154. package/src/clickhouse/apply/statements.ts +105 -8
  155. package/src/clickhouse/dictionary.test.ts +177 -0
  156. package/src/clickhouse/entities.ts +356 -6
  157. package/src/clickhouse/function.test.ts +91 -0
  158. package/src/clickhouse/http.ts +11 -2
  159. package/src/clickhouse/import/generator.ts +81 -46
  160. package/src/clickhouse/import/import.test.ts +9 -3
  161. package/src/clickhouse/import/ir.ts +6 -2
  162. package/src/clickhouse/import/parser.ts +10 -18
  163. package/src/clickhouse/live/access.e2e.test.ts +218 -0
  164. package/src/clickhouse/live/bind.ts +17 -6
  165. package/src/clickhouse/live/catalog.ts +106 -10
  166. package/src/clickhouse/live/describe-resources.ts +23 -10
  167. package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
  168. package/src/clickhouse/live/functions.e2e.test.ts +155 -0
  169. package/src/clickhouse/live/live.test.ts +2 -2
  170. package/src/clickhouse/parser.ts +136 -4
  171. package/src/clickhouse/plan/commands.ts +18 -7
  172. package/src/clickhouse/plan/deep.ts +71 -11
  173. package/src/clickhouse/plan/diff.ts +65 -2
  174. package/src/clickhouse/plan/normalize.ts +200 -6
  175. package/src/clickhouse/plan/report.ts +14 -3
  176. package/src/clickhouse/plan/rules.ts +44 -1
  177. package/src/clickhouse/plan/schema.ts +40 -17
  178. package/src/clickhouse/plan/server-format.ts +1 -1
  179. package/src/clickhouse/rebuild/backfill.ts +5 -25
  180. package/src/clickhouse/rebuild/observe.ts +7 -3
  181. package/src/clickhouse/rebuild/op.ts +9 -0
  182. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
  183. package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
  184. package/src/clickhouse/rebuild/steps.ts +136 -1
  185. package/src/clickhouse/testing/fake-server.ts +2 -0
  186. package/src/clickhouse/testing/writable-server.ts +2 -0
  187. package/src/clickhouse/topology.ts +17 -2
  188. package/src/clickhouse.ts +19 -0
  189. package/src/codegen/classifier-page.ts +2 -0
  190. package/src/codegen/registry.ts +10 -0
  191. package/src/config.ts +22 -3
  192. package/src/core/references.ts +5 -3
  193. package/src/core/statements.ts +11 -1
  194. package/src/files/build-root.ts +62 -0
  195. package/src/files/clickhouse.ts +52 -0
  196. package/src/files/common.ts +54 -0
  197. package/src/files/postgres.ts +371 -0
  198. package/src/generated/lexicon-sql.json +50 -0
  199. package/src/import-parser.ts +61 -0
  200. package/src/index.ts +14 -1
  201. package/src/lint/rules/postgres-templates.ts +2 -2
  202. package/src/lint/rules/sqlch001.ts +26 -4
  203. package/src/lint/rules/sqlch002.ts +3 -2
  204. package/src/lint/rules/sqlpg001.ts +7 -4
  205. package/src/lint/rules/templates.ts +2 -2
  206. package/src/lsp/postgres-template.ts +2 -0
  207. package/src/lsp/template.ts +3 -3
  208. package/src/mcp/index.ts +6 -6
  209. package/src/mcp/postgres.ts +1 -1
  210. package/src/migration-statements.test.ts +25 -0
  211. package/src/migration-statements.ts +8 -0
  212. package/src/op/activities/clickhouse-rebuild.ts +1 -0
  213. package/src/op/activities/postgres-migration.ts +27 -2
  214. package/src/op/activity-contracts.ts +2 -0
  215. package/src/plugin.ts +29 -5
  216. package/src/postgres/access/access.e2e.test.ts +191 -0
  217. package/src/postgres/access/access.test.ts +147 -0
  218. package/src/postgres/access/acl.ts +400 -0
  219. package/src/postgres/access/import.ts +64 -0
  220. package/src/postgres/access/live.ts +123 -0
  221. package/src/postgres/apply/apply.ts +117 -4
  222. package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
  223. package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
  224. package/src/postgres/apply/statements.test.ts +138 -6
  225. package/src/postgres/apply/statements.ts +210 -18
  226. package/src/postgres/entities.ts +287 -11
  227. package/src/postgres/entity-types.ts +4 -0
  228. package/src/postgres/import/generator.ts +129 -46
  229. package/src/postgres/import/live-export.ts +12 -2
  230. package/src/postgres/live/bind.ts +20 -6
  231. package/src/postgres/live/catalog.ts +79 -3
  232. package/src/postgres/live/client.ts +10 -2
  233. package/src/postgres/live/describe-resources.ts +27 -3
  234. package/src/postgres/migrate/migrate.test.ts +24 -1
  235. package/src/postgres/migrate/op.ts +80 -35
  236. package/src/postgres/migrate/outer.e2e.test.ts +163 -0
  237. package/src/postgres/parser.ts +247 -8
  238. package/src/postgres/plan/commands.ts +60 -7
  239. package/src/postgres/plan/deep.ts +24 -3
  240. package/src/postgres/plan/diff.ts +71 -6
  241. package/src/postgres/plan/normalize.ts +115 -2
  242. package/src/postgres/plan/plan.test.ts +2 -2
  243. package/src/postgres/plan/rules.ts +49 -5
  244. package/src/postgres/plan/schema.ts +4 -2
  245. package/src/postgres/plan/server-normalize.ts +29 -1
  246. package/src/postgres.ts +11 -0
  247. package/src/serializer.ts +14 -9
  248. package/src/skill-defs.ts +1 -1
  249. package/src/skills/chant-sql-postgres-migration.md +3 -1
  250. package/src/skills/chant-sql-postgres-plan.md +3 -3
  251. package/src/skills/chant-sql-postgres.md +9 -2
  252. package/src/skills/chant-sql-rebuild.md +3 -2
  253. package/src/skills/chant-sql.md +7 -1
  254. package/src/sql-files.test.ts +188 -0
  255. package/src/sql-files.ts +218 -0
  256. package/src/token-source.e2e.test.ts +142 -0
  257. package/src/token-source.test.ts +119 -0
  258. package/src/token-source.ts +189 -0
  259. package/src/validate.ts +10 -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 declaredRaw = keyedByQualifiedName(build);
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 liveObjects = markProviderOwned(await (options.readLive ?? readLiveSchema)(client, { schemas: scope }), target.provider);
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" || o.canonical.kind === "trigger")) {
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. */
@@ -116,7 +169,7 @@ export async function planPgAgainstServer(environment: string, buildFile: string
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
171
  const label = new Map(declared.map((o) => [o.key, `${o.canonical.exportName} (${o.key.slice(o.key.indexOf(" ") + 1)})`]));
119
- const changes = diff.changes.map((c) => ({ ...c, object: label.get(c.object) ?? c.object }));
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,7 +17,7 @@ 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";
@@ -39,6 +39,10 @@ const TAG: Record<string, (s: TemplateStringsArray) => { props: object }> = {
39
39
  [POSTGRES_ENTITY_TYPES.function]: tags.func,
40
40
  [POSTGRES_ENTITY_TYPES.procedure]: tags.procedure,
41
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,
42
46
  };
43
47
 
44
48
  /** The props of what the server printed, parsed with the declaration's tag. */
@@ -105,6 +109,14 @@ const PROP_OF_FIELD: Record<string, string[]> = {
105
109
  deferrable: ["deferrable"],
106
110
  initiallyDeferred: ["initiallyDeferred"],
107
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"],
108
120
  };
109
121
 
110
122
  /** Props keys that hold constraints, by constraint kind. */
@@ -190,6 +202,8 @@ export async function observeResourcesDeep(
190
202
  try {
191
203
  live = await readLiveSchema(bound.client, {
192
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)),
193
207
  });
194
208
  } catch (err) {
195
209
  await bound.client.end();
@@ -207,6 +221,11 @@ export async function observeResourcesDeep(
207
221
  unobserved[name] = { type: entity?.entityType ?? "", reason: "unsupported-kind" };
208
222
  continue;
209
223
  }
224
+ const why = accessUnobserved(entity.entityType, target.access === true);
225
+ if (why) {
226
+ unobserved[name] = { type: entity.entityType, ...why };
227
+ continue;
228
+ }
210
229
  const { schema, name: objectName, signature } = declaredAddress({ type: entity.entityType, props: entity.props }, target.defaultSchema);
211
230
  const o = byKey.get(liveKey(entity.entityType, schema, objectName, signature));
212
231
  if (!o) continue;
@@ -214,8 +233,10 @@ export async function observeResourcesDeep(
214
233
  const lp = liveProps(o);
215
234
  let d = canonicalPgObject(entity.entityType, entity.props, target.defaultSchema);
216
235
  const l = canonicalPgObject(o.type, lp, target.defaultSchema);
217
- // A trigger's WHEN as the server would print it, when the rules leave it different.
218
- if (d.kind === "trigger" && d.fields.when !== l.fields.when) d = await serverNormalized(client, d, 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
+ }
219
240
  resources[name] = { type: o.type, physicalId: o.oid, properties: inDeclaredVocabulary(entity.props, lp, d, l) };
220
241
  } catch (err) {
221
242
  unobserved[name] = { type: entity.entityType, reason: "read-failed", detail: `the server's definition does not parse: ${(err as Error).message}` };
@@ -16,7 +16,8 @@
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";
19
+ import { ACCESS_KINDS, sameConstraint, serialBase, type CanonicalColumn, type CanonicalConstraint } from "./normalize";
20
+ import { builtinEntries, declaredAccess, diffAccess, entryKey, targetKey, type AccessChange } from "../access/acl";
20
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";
@@ -24,6 +25,12 @@ import type { PostgresMigrationOpSuggestion } from "../migrate/handoff";
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}${o.kind === "trigger" ? "" : (o.signature ?? "")}`;
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
 
@@ -294,7 +303,31 @@ function diffObject(key: string, before: PgDiffObject, after: PgDiffObject, out:
294
303
  }
295
304
  break;
296
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;
297
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
+ }
298
331
  diffColumns(key, before, after, out, hints, false, major);
299
332
  diffConstraints(key, before, after, out, false);
300
333
  field("with", "SQLPG224");
@@ -334,8 +367,11 @@ export function matchPgObjects(before: readonly PgSchemaObject[], after: readonl
334
367
  });
335
368
  }
336
369
 
337
- export function diffPgSchemas(before: readonly PgSchemaObject[], after: readonly PgSchemaObject[], options: PgDiffOptions = {}): PgSchemaDiff {
370
+ export function diffPgSchemas(beforeAll: readonly PgSchemaObject[], afterAll: readonly PgSchemaObject[], options: PgDiffOptions = {}): PgSchemaDiff {
338
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));
339
375
  const matches = matchPgObjects(before, after);
340
376
  const newTables = createdTables(matches);
341
377
  const changes: PgChange[] = [];
@@ -347,6 +383,8 @@ export function diffPgSchemas(before: readonly PgSchemaObject[], after: readonly
347
383
  changes.push(change(m.after.key, "index", o.concurrently ? "SQLPG240" : "SQLPG241", undefined, qualified(o)));
348
384
  } else if (o.kind === "trigger" && !newTables.has(String(o.fields.table))) {
349
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 ?? ""}`));
350
388
  } else changes.push(change(m.after.key, o.kind, "SQLPG200", undefined, qualified(o)));
351
389
  } else if (m.kind === "dropped") {
352
390
  const o = m.before.canonical;
@@ -354,10 +392,37 @@ export function diffPgSchemas(before: readonly PgSchemaObject[], after: readonly
354
392
  hints.push(`${m.before.key} is kept by ${o.foreign}; it is not chant's to drop and is left alone`);
355
393
  continue;
356
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
+ }
357
399
  const destructive = o.kind === "table" || o.kind === "materializedView" || o.kind === "sequence";
358
- const rule = o.kind === "index" ? "SQLPG242" : o.kind === "trigger" ? "SQLPG285" : "SQLPG270";
359
- changes.push(change(m.before.key, o.kind, rule, o.kind === "trigger" ? `${o.name}${o.signature ?? ""}` : qualified(o), undefined, destructive ? { destructive: true } : {}));
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 } : {}));
360
402
  } else diffObject(m.after.key, m.before.canonical, m.after.canonical, changes, hints, major);
361
403
  }
362
- return { changes, hints, refused: changes.filter((c) => c.class === "expand") };
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);
363
428
  }
@@ -60,6 +60,10 @@ import type {
60
60
  IndexProps,
61
61
  KeyDef,
62
62
  SchemaProps,
63
+ DefaultPrivilegesProps,
64
+ GrantProps,
65
+ PolicyProps,
66
+ RoleProps,
63
67
  RoutineProps,
64
68
  SequenceProps,
65
69
  TableProps,
@@ -67,6 +71,8 @@ import type {
67
71
  ViewProps,
68
72
  } from "../entities";
69
73
  import { stringValue } from "../entities";
74
+ import { parseStatements } from "../parser";
75
+ import type { RoutineNode } from "../parser";
70
76
 
71
77
  // ── Types ──────────────────────────────────────────────────────────────
72
78
 
@@ -319,7 +325,29 @@ export interface CanonicalConstraint {
319
325
  comment?: string;
320
326
  }
321
327
 
322
- export type CanonicalKind = "schema" | "table" | "index" | "view" | "materializedView" | "sequence" | "enum" | "domain" | "extension" | "function" | "procedure" | "trigger";
328
+ export type CanonicalKind =
329
+ | "schema"
330
+ | "table"
331
+ | "index"
332
+ | "view"
333
+ | "materializedView"
334
+ | "sequence"
335
+ | "enum"
336
+ | "domain"
337
+ | "extension"
338
+ | "function"
339
+ | "procedure"
340
+ | "trigger"
341
+ | "policy"
342
+ | "role"
343
+ | "grant"
344
+ | "defaultPrivileges";
345
+
346
+ /** The kinds whose declarations are privileges, compared as the access they add up to (`../access/`), not one by one. */
347
+ export const ACCESS_KINDS: ReadonlySet<CanonicalKind> = new Set(["grant", "defaultPrivileges"]);
348
+
349
+ /** The kinds a profile manages only when it says so (`sql.profiles.<env>.access`). */
350
+ export const ACCESS_MANAGED_KINDS: ReadonlySet<CanonicalKind> = new Set(["policy", "role", "grant", "defaultPrivileges"]);
323
351
 
324
352
  export interface CanonicalPgObject {
325
353
  kind: CanonicalKind;
@@ -350,6 +378,10 @@ const kindOf: Record<string, CanonicalKind> = {
350
378
  "Postgres::Function": "function",
351
379
  "Postgres::Procedure": "procedure",
352
380
  "Postgres::Trigger": "trigger",
381
+ "Postgres::Policy": "policy",
382
+ "Postgres::Role": "role",
383
+ "Postgres::Grant": "grant",
384
+ "Postgres::DefaultPrivileges": "defaultPrivileges",
353
385
  };
354
386
 
355
387
  const comment = (c: string | undefined): string | undefined => {
@@ -500,6 +532,38 @@ function routineFields(kind: "function" | "procedure", p: RoutineProps, defaultS
500
532
  fields.window = p.window === true;
501
533
  }
502
534
 
535
+ /** A routine named with its parameters as a GRANT writes it (`app.f(int, text)`), as the target the catalog names: `app.f(integer,text)`. */
536
+ export function canonicalRoutineTarget(text: string, defaultSchema: string): string {
537
+ const open = text.indexOf("(");
538
+ const name = canonicalName(open < 0 ? text : text.slice(0, open), defaultSchema);
539
+ if (open < 0) return name;
540
+ try {
541
+ // The parameter list read the way a CREATE FUNCTION reads it: modes, names and types.
542
+ const tokens = tokenizeText(`CREATE FUNCTION x${text.slice(open)} RETURNS void LANGUAGE sql AS ''`, 0);
543
+ const [node] = parseStatements(tokens);
544
+ const args = (node as RoutineNode).args.map((a) => ({ mode: (a.mode?.toLowerCase() ?? "in") as RoutineProps["args"][number]["mode"], type: tokens.slice(a.type.from, a.type.to).map((t) => t.text).join("") }));
545
+ return `${name}${routineSignature({ args }, defaultSchema)}`;
546
+ } catch {
547
+ return `${name}${text.slice(open).replace(/\s+/g, "")}`;
548
+ }
549
+ }
550
+
551
+ /** A role's attributes that differ from `CREATE ROLE`'s defaults (NOLOGIN, INHERIT and every other NO...), sorted. */
552
+ const ROLE_DEFAULTS: Readonly<Record<string, boolean>> = { superuser: false, createdb: false, createrole: false, inherit: true, login: false, replication: false, bypassrls: false };
553
+
554
+ export function canonicalRoleAttributes(options: readonly string[]): string[] {
555
+ const set: Record<string, boolean> = { ...ROLE_DEFAULTS };
556
+ for (const o of options) {
557
+ const word = o.toLowerCase();
558
+ if (word.startsWith("no") && word.slice(2) in ROLE_DEFAULTS) set[word.slice(2)] = false;
559
+ else if (word in ROLE_DEFAULTS) set[word] = true;
560
+ }
561
+ return Object.entries(set)
562
+ .filter(([k, v]) => ROLE_DEFAULTS[k] !== v)
563
+ .map(([k, v]) => (v ? k : `no${k}`))
564
+ .sort();
565
+ }
566
+
503
567
  /** The order the catalog prints a trigger's events in. */
504
568
  const EVENT_ORDER = ["insert", "delete", "update", "truncate"];
505
569
 
@@ -538,7 +602,7 @@ function triggerFields(p: TriggerProps, defaultSchema: string, fields: Record<st
538
602
  export function canonicalPgObject(entityType: string, props: Record<string, unknown>, defaultSchema = "public"): CanonicalPgObject {
539
603
  const kind = kindOf[entityType];
540
604
  if (!kind) throw new Error(`not a Postgres entity type: ${entityType}`);
541
- const schemaOf = (p: { schema?: string }) => (kind === "schema" || kind === "extension" ? undefined : (p.schema ?? defaultSchema));
605
+ const schemaOf = (p: { schema?: string }) => (kind === "schema" || kind === "extension" || kind === "role" || ACCESS_KINDS.has(kind) ? undefined : (p.schema ?? defaultSchema));
542
606
  const base: Pick<CanonicalPgObject, "kind" | "schema" | "name" | "signature"> = { kind, schema: schemaOf(props as { schema?: string }), name: String(props.name) };
543
607
  const fields: Record<string, unknown> = { comment: comment(props.comment as string | undefined) };
544
608
  const columns: CanonicalColumn[] = [];
@@ -572,6 +636,53 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
572
636
  base.signature = ` ON ${String(fields.table)}`;
573
637
  break;
574
638
  }
639
+ case "policy": {
640
+ const p = props as unknown as PolicyProps;
641
+ fields.table = canonicalName(p.tableName, defaultSchema);
642
+ base.signature = ` ON ${String(fields.table)}`;
643
+ fields.restrictive = p.permissive === false;
644
+ fields.command = p.command && p.command !== "all" ? p.command : undefined;
645
+ const roles = [...new Set(p.roles.map((r) => r.toLowerCase() === "public" ? "public" : quoteIdent(r)))].sort();
646
+ fields.roles = roles.length === 1 && roles[0] === "public" ? undefined : roles;
647
+ fields.using = canonicalExpr(p.using);
648
+ fields.check = canonicalExpr(p.check);
649
+ break;
650
+ }
651
+ case "role": {
652
+ const p = props as unknown as RoleProps;
653
+ const attributes = canonicalRoleAttributes(p.options);
654
+ fields.attributes = attributes.length > 0 ? attributes : undefined;
655
+ fields.connectionLimit = p.connectionLimit !== undefined && Number(p.connectionLimit) !== -1 ? String(Number(p.connectionLimit)) : undefined;
656
+ break;
657
+ }
658
+ case "grant": {
659
+ const p = props as unknown as GrantProps;
660
+ const routine = p.on === "function" || p.on === "procedure" || p.on === "routine";
661
+ fields.grant = {
662
+ action: p.action,
663
+ on: p.on,
664
+ objects: p.objectNames.map((n) => (routine ? canonicalRoutineTarget(n, defaultSchema) : p.on === "schema" ? canonicalName(n) : canonicalName(n, defaultSchema))),
665
+ privileges: p.privileges.map((x) => ({ privilege: x.privilege.toLowerCase(), ...(x.columns ? { columns: x.columns } : {}) })),
666
+ grantees: p.grantees,
667
+ ...(p.withGrantOption ? { withGrantOption: true } : {}),
668
+ ...(p.grantOptionFor ? { grantOptionFor: true } : {}),
669
+ };
670
+ break;
671
+ }
672
+ case "defaultPrivileges": {
673
+ const p = props as unknown as DefaultPrivilegesProps;
674
+ fields.grant = {
675
+ action: p.action,
676
+ on: p.on,
677
+ privileges: p.privileges.map((x) => ({ privilege: x.toLowerCase() })),
678
+ grantees: p.grantees,
679
+ ...(p.forRoles ? { forRoles: p.forRoles } : {}),
680
+ ...(p.inSchemas ? { inSchemas: p.inSchemas } : {}),
681
+ ...(p.withGrantOption ? { withGrantOption: true } : {}),
682
+ ...(p.grantOptionFor ? { grantOptionFor: true } : {}),
683
+ };
684
+ break;
685
+ }
575
686
  case "domain": {
576
687
  const p = props as unknown as DomainProps;
577
688
  fields.dataType = canonicalType(p.dataType, defaultSchema);
@@ -681,6 +792,8 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
681
792
  fields.using = p.using && p.using.toLowerCase() !== "heap" ? p.using.toLowerCase() : undefined;
682
793
  fields.with = canonicalOptions(p.with);
683
794
  fields.tablespace = p.tablespace;
795
+ fields.rowSecurity = p.rowSecurity === true;
796
+ fields.forceRowSecurity = p.forceRowSecurity === true;
684
797
  break;
685
798
  }
686
799
  }
@@ -82,7 +82,7 @@ describe("columns", () => {
82
82
  expect(rules(diff([table(ORDERS)], [table("CREATE TABLE app.orders (id bigint PRIMARY KEY, amount numeric(12,2) DEFAULT 0, note varchar(20) NOT NULL, status text)")]))).toEqual([
83
83
  ["columns.amount.default", "SQLPG209", "metadata"],
84
84
  ["columns.amount.notNull", "SQLPG211", "metadata"],
85
- ["columns.note.notNull", "SQLPG210", "rewrite"],
85
+ ["columns.note.notNull", "SQLPG210", "validate"],
86
86
  ]);
87
87
  const renamed = diff([table(ORDERS)], [table(`CREATE TABLE app.orders (id bigint PRIMARY KEY, amount numeric(12,2) NOT NULL, note varchar(20),
88
88
  state text -- previously: status
@@ -113,7 +113,7 @@ describe("constraints", () => {
113
113
  );
114
114
  expect(rules(d)).toEqual([
115
115
  ["constraints.unique", "SQLPG221", "concurrently"],
116
- ["constraints.a", "SQLPG218", "rewrite"],
116
+ ["constraints.a", "SQLPG218", "validate"],
117
117
  ["constraints.b", "SQLPG217", "metadata"],
118
118
  ["constraints.c", "SQLPG219", "validate"],
119
119
  ["constraints.exclude", "SQLPG222", "rewrite"],
@@ -109,9 +109,9 @@ export const PG_CLASSIFIER_RULES = {
109
109
  SQLPG209: rule("SQLPG209", "metadata", "Change a column's default", "SET DEFAULT and DROP DEFAULT change only the catalog; existing rows keep their values.", ALTER_TABLE),
110
110
  SQLPG210: rule(
111
111
  "SQLPG210",
112
- "rewrite",
112
+ "validate",
113
113
  "Set NOT NULL",
114
- "SET NOT NULL reads the whole table under ACCESS EXCLUSIVE, unless a valid CHECK (column IS NOT NULL) constraint already proves it (since 12). Declare that check NOT VALID, validate it, then set NOT NULL; 18 can also add NOT NULL ... NOT VALID.",
114
+ "SET NOT NULL alone reads the whole table under ACCESS EXCLUSIVE, unless a valid CHECK (column IS NOT NULL) constraint already proves it (since 12). So it is made as four statements: that check added NOT VALID, validated under SHARE UPDATE EXCLUSIVE while writes go on, SET NOT NULL, which the check proves without a scan, and the check dropped. Rows where the column is NULL fail the validation; the first statement's pre-check counts them.",
115
115
  ALTER_TABLE,
116
116
  ),
117
117
  SQLPG211: rule("SQLPG211", "metadata", "Drop NOT NULL", "DROP NOT NULL changes only the catalog.", ALTER_TABLE),
@@ -135,16 +135,16 @@ export const PG_CLASSIFIER_RULES = {
135
135
  ),
136
136
  SQLPG218: rule(
137
137
  "SQLPG218",
138
- "rewrite",
138
+ "validate",
139
139
  "Add a CHECK constraint",
140
- "ADD CONSTRAINT ... CHECK reads every row under ACCESS EXCLUSIVE. Declare it NOT VALID, then remove NOT VALID to validate it under SHARE UPDATE EXCLUSIVE (SQLPG220).",
140
+ "ADD CONSTRAINT ... CHECK alone reads every row under ACCESS EXCLUSIVE. On a table that exists it is made as two statements: the check added NOT VALID (SQLPG217), which reads no rows, then VALIDATE CONSTRAINT (SQLPG220) in a transaction of its own, which reads them under SHARE UPDATE EXCLUSIVE while writes go on. Rows the check refuses fail the validation; the first statement's pre-check counts them.",
141
141
  ALTER_TABLE,
142
142
  ),
143
143
  SQLPG219: rule(
144
144
  "SQLPG219",
145
145
  "validate",
146
146
  "Add a foreign key",
147
- "ADD FOREIGN KEY reads every row under SHARE ROW EXCLUSIVE on both tables, which blocks writes to them for the scan. Declare it NOT VALID, then remove NOT VALID to validate it under SHARE UPDATE EXCLUSIVE (SQLPG220).",
147
+ "ADD FOREIGN KEY alone reads every row under SHARE ROW EXCLUSIVE on both tables, which blocks writes to them for the scan. On a table that exists it is made as two statements: the key added NOT VALID (SQLPG217), which takes that lock briefly and reads no rows, then VALIDATE CONSTRAINT (SQLPG220) in a transaction of its own, which reads them under SHARE UPDATE EXCLUSIVE while writes go on. Orphan rows fail the validation; the first statement's pre-check counts them.",
148
148
  ALTER_TABLE,
149
149
  ),
150
150
  SQLPG220: rule(
@@ -306,6 +306,50 @@ export const PG_CLASSIFIER_RULES = {
306
306
  `${DOCS}/sql-createtrigger.html`,
307
307
  ),
308
308
  SQLPG285: rule("SQLPG285", "drop", "Drop a trigger", "DROP TRIGGER takes ACCESS EXCLUSIVE on its table, briefly; no row is read or lost, and the table's writes no longer fire it.", `${DOCS}/sql-droptrigger.html`),
309
+ SQLPG290: rule(
310
+ "SQLPG290",
311
+ "metadata",
312
+ "Create a policy on an existing table",
313
+ "CREATE POLICY takes ACCESS EXCLUSIVE on its table, briefly; no row is read. Where row-level security is on, the rows each role sees change when the transaction commits.",
314
+ `${DOCS}/sql-createpolicy.html`,
315
+ ),
316
+ SQLPG291: rule(
317
+ "SQLPG291",
318
+ "metadata",
319
+ "Change a policy",
320
+ "ALTER POLICY changes its roles, USING and WITH CHECK under ACCESS EXCLUSIVE on the table, briefly. Its command, AS RESTRICTIVE and table cannot be altered, nor an expression taken away: the policy is dropped and created in one transaction, so no session sees the table without it.",
321
+ `${DOCS}/sql-alterpolicy.html`,
322
+ ),
323
+ SQLPG292: rule("SQLPG292", "drop", "Drop a policy", "DROP POLICY takes ACCESS EXCLUSIVE on its table, briefly; the rows it let a role see are hidden from that role at once, or, if it was the last policy, every non-owner sees none.", `${DOCS}/sql-droppolicy.html`),
324
+ SQLPG293: rule(
325
+ "SQLPG293",
326
+ "metadata",
327
+ "Turn row-level security on or off, or force it on the owner",
328
+ "ALTER TABLE ... ENABLE, DISABLE, FORCE or NO FORCE ROW LEVEL SECURITY changes only the catalog, under ACCESS EXCLUSIVE, briefly. With it on and no policy that lets a role in, that role sees no rows; FORCE subjects the table's owner too.",
329
+ `${DOCS}/ddl-rowsecurity.html`,
330
+ ),
331
+ SQLPG294: rule("SQLPG294", "metadata", "Change a role's attributes", "ALTER ROLE changes the role in the cluster's catalog, for every database on the server, at once; sessions already open keep the attributes they started with until they reconnect.", `${DOCS}/sql-alterrole.html`),
332
+ SQLPG296: rule(
333
+ "SQLPG296",
334
+ "metadata",
335
+ "Grant a privilege",
336
+ "GRANT adds to the object's privileges in the catalog; no row is read and the role can use it from its next statement.",
337
+ `${DOCS}/sql-grant.html`,
338
+ ),
339
+ SQLPG297: rule(
340
+ "SQLPG297",
341
+ "metadata",
342
+ "Revoke a privilege",
343
+ "REVOKE removes from the object's privileges in the catalog. The role loses it at once: its next statement that needs it fails, so move every reader off it first.",
344
+ `${DOCS}/sql-revoke.html`,
345
+ ),
346
+ SQLPG298: rule(
347
+ "SQLPG298",
348
+ "metadata",
349
+ "Change default privileges",
350
+ "ALTER DEFAULT PRIVILEGES changes what objects created afterwards are given; objects that exist keep their privileges.",
351
+ `${DOCS}/sql-alterdefaultprivileges.html`,
352
+ ),
309
353
  SQLPG270: rule("SQLPG270", "drop", "Drop an object", "The object and, for a table, a materialized view or a sequence, its data are gone.", `${DOCS}/sql-droptable.html`),
310
354
  } as const satisfies Record<string, PgClassifierRule>;
311
355
 
@@ -100,10 +100,12 @@ export function pgSchemaFromBuildFile(path: string, defaultSchema = "public"): P
100
100
  * The namespace an object's name is unique in: relations (tables, views,
101
101
  * sequences, indexes) share one per schema, types and domains another,
102
102
  * functions and procedures a third (told apart by their parameter types),
103
- * and a trigger's name is unique on its table.
103
+ * a trigger's or a policy's name is unique on its table, and a role's in the
104
+ * cluster.
104
105
  */
105
106
  export function pgNamespace(kind: string): string {
106
- if (kind === "schema" || kind === "extension" || kind === "trigger") return kind;
107
+ if (kind === "schema" || kind === "extension" || kind === "trigger" || kind === "policy" || kind === "role") return kind;
108
+ if (kind === "grant" || kind === "defaultPrivileges") return "grant";
107
109
  if (kind === "enum" || kind === "domain") return "type";
108
110
  if (kind === "function" || kind === "procedure") return "routine";
109
111
  return "relation";