@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
@@ -0,0 +1,595 @@
1
+ /**
2
+ * ClickHouse access control as declarations (#3682): users, roles, row
3
+ * policies and grants.
4
+ *
5
+ * Each is a statement the server keeps outside any table: `CREATE USER`,
6
+ * `CREATE ROLE`, `CREATE ROW POLICY` and `GRANT`. This module parses them
7
+ * (the tags in `./entities.ts`, the lint rule, the plan), reduces each to
8
+ * the fields a plan compares, and writes the statements that make a change.
9
+ *
10
+ * What a declaration owns and what the environment owns:
11
+ *
12
+ * - A password is the environment's. `IDENTIFIED BY` is refused in a
13
+ * declaration (only `ssh_key BY KEY`, a public key, is taken), so no
14
+ * secret is ever in a build. A user declared with no `IDENTIFIED` clause
15
+ * is compared without it, and is not created by chant: create it with its
16
+ * password, and the next apply manages the rest of it. A user declared
17
+ * with a method that holds no secret (`ssl_certificate`, `ldap`,
18
+ * `kerberos`, `http`, `ssh_key`, `no_password`) is created and kept so.
19
+ * - Grants are compared per grantee, for each grantee the build declares a
20
+ * grant to: every grant declaration naming it adds up to its complete
21
+ * list, and a grant made by hand to it is revoked. A grant another one
22
+ * covers (`SELECT ON db.t` beside `SELECT ON db.*`) is folded away, as the
23
+ * server folds it.
24
+ * - None of these objects has a comment, so none carries chant's ownership
25
+ * marker. A plan reads only the ones the build declares; chant creates and
26
+ * changes them, and never drops one.
27
+ */
28
+
29
+ import { isTrivia, tokenizeText, type Token } from "./tokens";
30
+ import { kw, SqlCursor, type Span } from "../core/cursor";
31
+ import { unquote } from "./parser";
32
+
33
+ export type AccessStatement = "user" | "role" | "rowPolicy" | "grant";
34
+
35
+ /** One parsed access statement. Clause spans are token spans, keywords left out. */
36
+ export interface AccessNode {
37
+ statement: AccessStatement;
38
+ orReplace: boolean;
39
+ ifNotExists: boolean;
40
+ /** A user's, role's or row policy's name; none for a grant. */
41
+ name?: Span;
42
+ onCluster?: Span;
43
+ /** A row policy's table, `[db.]table`. */
44
+ table?: Span;
45
+ /** Each clause after its keywords, by key: `identified`, `host`, `defaultRole`, `defaultDatabase`, `grantees`, `settings`, `validUntil`, `as`, `for`, `using`, `to`. */
46
+ clauses: Partial<Record<string, Span>>;
47
+ /** A grant's privileges or roles, one span each. */
48
+ items: Span[];
49
+ /** A grant's `ON` target; none for a grant of roles. */
50
+ target?: Span;
51
+ /** A grant's grantees. */
52
+ grantees: Span[];
53
+ /** `WITH GRANT OPTION` or `WITH ADMIN OPTION`. */
54
+ withOption?: "GRANT" | "ADMIN";
55
+ /** A `REVOKE` line, as `SHOW GRANTS` prints a partial revoke. Never declared. */
56
+ revoke?: boolean;
57
+ }
58
+
59
+ const isName = (t: Token | undefined): boolean => t !== undefined && (t.kind === "ident" || t.kind === "qident" || t.kind === "ref");
60
+
61
+ class AccessParser extends SqlCursor {
62
+ private name(what: string): Span {
63
+ const t = this.next();
64
+ if (!isName(t)) this.fail(`expected ${what}`, t);
65
+ const i = this.idx(-1);
66
+ return { from: i, to: i + 1, refs: t.kind === "ref" ? [t.part] : [] };
67
+ }
68
+
69
+ /** `name` or `db.name`, a `*` in either place. */
70
+ private qualified(): Span {
71
+ const from = this.idx();
72
+ const refs: number[] = [];
73
+ const one = () => {
74
+ const t = this.next();
75
+ if (!(isName(t) || (t.kind === "op" && t.text === "*"))) this.fail("expected a name", t);
76
+ if (t.kind === "ref") refs.push(t.part);
77
+ };
78
+ one();
79
+ while (this.isPunct(".")) {
80
+ this.p++;
81
+ one();
82
+ }
83
+ return this.span(from, refs);
84
+ }
85
+
86
+ private orReplace = false;
87
+ private ifNotExists = false;
88
+
89
+ /** `IF NOT EXISTS` or `OR REPLACE`, which ClickHouse takes after USER, ROLE and ROW POLICY. */
90
+ private head(): void {
91
+ if (kw(this.peek(), "IF") && kw(this.peek(1), "NOT") && kw(this.peek(2), "EXISTS")) {
92
+ this.p += 3;
93
+ this.ifNotExists = true;
94
+ } else if (kw(this.peek(), "OR") && kw(this.peek(1), "REPLACE")) {
95
+ this.p += 2;
96
+ this.orReplace = true;
97
+ }
98
+ }
99
+
100
+ private onClusterClause(): Span | undefined {
101
+ if (kw(this.peek(), "ON") && kw(this.peek(1), "CLUSTER")) {
102
+ this.p += 2;
103
+ const from = this.idx();
104
+ const t = this.next();
105
+ if (!isName(t) && t.kind !== "string") this.fail("expected a cluster name", t);
106
+ return this.span(from, t.kind === "ref" ? [t.part] : []);
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ /** Clauses in any order: each starts with one of `starts` (a keyword sequence) and runs to the next clause. */
112
+ private clauses(starts: Record<string, string[]>, node: AccessNode): void {
113
+ const stopWords = [...new Set(Object.values(starts).map((w) => w[0]!))];
114
+ for (;;) {
115
+ if (this.atEnd()) return;
116
+ const hit = Object.entries(starts).find(([, words]) => words.every((w, i) => kw(this.peek(i), w)));
117
+ if (!hit) this.fail(`unexpected ${this.peek()?.text}: expected ${Object.values(starts).map((w) => w.join(" ")).join(", ")}`);
118
+ const [key, words] = hit;
119
+ this.p += words.length;
120
+ if (node.clauses[key]) this.fail(`${words.join(" ")} is written twice`);
121
+ // `NOT IDENTIFIED` has no value; every other clause has one.
122
+ node.clauses[key] = key === "notIdentified" ? { from: this.idx(-1), to: this.idx(-1) + 1, refs: [] } : this.expr(stopWords, false);
123
+ }
124
+ }
125
+
126
+ parse(): AccessNode {
127
+ if (this.accept("GRANT")) return this.grant(false);
128
+ if (this.accept("REVOKE")) return this.grant(true);
129
+ this.expect("CREATE");
130
+ const node = (statement: AccessStatement): AccessNode => ({ statement, orReplace: this.orReplace, ifNotExists: this.ifNotExists, clauses: {}, items: [], grantees: [] });
131
+ if (this.accept("USER")) {
132
+ this.head();
133
+ const n = node("user");
134
+ n.name = this.name("a user name");
135
+ if (this.isPunct(",")) this.fail("declare one user per template");
136
+ n.onCluster = this.onClusterClause();
137
+ this.clauses(
138
+ {
139
+ notIdentified: ["NOT", "IDENTIFIED"],
140
+ identified: ["IDENTIFIED"],
141
+ host: ["HOST"],
142
+ validUntil: ["VALID", "UNTIL"],
143
+ in: ["IN"],
144
+ defaultRole: ["DEFAULT", "ROLE"],
145
+ defaultDatabase: ["DEFAULT", "DATABASE"],
146
+ grantees: ["GRANTEES"],
147
+ settings: ["SETTINGS"],
148
+ },
149
+ n,
150
+ );
151
+ refusePassword(this, this.tokens, n);
152
+ return n;
153
+ }
154
+ if (this.accept("ROLE")) {
155
+ this.head();
156
+ const n = node("role");
157
+ n.name = this.name("a role name");
158
+ if (this.isPunct(",")) this.fail("declare one role per template");
159
+ n.onCluster = this.onClusterClause();
160
+ this.clauses({ in: ["IN"], settings: ["SETTINGS"] }, n);
161
+ return n;
162
+ }
163
+ if (this.accept("ROW")) this.expect("POLICY");
164
+ else if (!this.accept("POLICY")) this.fail("expected USER, ROLE or ROW POLICY");
165
+ this.head();
166
+ const n = node("rowPolicy");
167
+ n.name = this.name("a policy name");
168
+ n.onCluster = this.onClusterClause();
169
+ this.expect("ON");
170
+ n.table = this.qualified();
171
+ if (this.isPunct(",")) this.fail("declare one row policy, on one table, per template");
172
+ this.clauses({ as: ["AS"], for: ["FOR"], using: ["USING"], to: ["TO"] }, n);
173
+ if (!n.clauses.using) this.fail("expected USING and the policy's condition");
174
+ return n;
175
+ }
176
+
177
+ private grant(revoke: boolean): AccessNode {
178
+ const n: AccessNode = { statement: "grant", orReplace: false, ifNotExists: false, clauses: {}, items: [], grantees: [], ...(revoke ? { revoke: true } : {}) };
179
+ n.onCluster = this.onClusterClause();
180
+ if (revoke && (this.acceptSeq("GRANT", "OPTION", "FOR") || this.acceptSeq("ADMIN", "OPTION", "FOR"))) this.fail("REVOKE ... OPTION FOR is not read");
181
+ if (kw(this.peek(), "CURRENT") && kw(this.peek(1), "GRANTS")) this.fail("GRANT CURRENT GRANTS grants whatever the applying user holds; name the privileges");
182
+ const to = revoke ? "FROM" : "TO";
183
+ for (;;) {
184
+ n.items.push(this.expr(["ON", to]));
185
+ if (this.acceptPunct(",")) continue;
186
+ break;
187
+ }
188
+ if (this.accept("ON")) n.target = this.qualified();
189
+ this.expect(to);
190
+ for (;;) {
191
+ n.grantees.push(this.expr(["WITH"]));
192
+ if (this.acceptPunct(",")) continue;
193
+ break;
194
+ }
195
+ if (this.accept("WITH")) {
196
+ if (this.acceptSeq("GRANT", "OPTION")) n.withOption = "GRANT";
197
+ else if (this.acceptSeq("ADMIN", "OPTION")) n.withOption = "ADMIN";
198
+ else if (kw(this.peek(), "REPLACE")) this.fail("WITH REPLACE OPTION revokes whatever else the grantee holds; declare the complete list instead");
199
+ else this.fail("expected GRANT OPTION or ADMIN OPTION");
200
+ }
201
+ if (!n.target && n.withOption === "GRANT") this.fail("WITH GRANT OPTION is for privileges; a role takes WITH ADMIN OPTION");
202
+ this.acceptPunct(";");
203
+ if (this.peek() !== undefined) this.fail("unexpected token after the grant");
204
+ return n;
205
+ }
206
+ }
207
+
208
+ /** The authentication methods that hold no secret: a user declared with one is created by chant. */
209
+ export const SECRETLESS = new Set(["no_password", "ssl_certificate", "ldap", "kerberos", "http", "ssh_key"]);
210
+
211
+ function refusePassword(p: { fail(message: string, t?: Token): never }, tokens: Token[], node: AccessNode): void {
212
+ const span = node.clauses.identified;
213
+ if (!span) return;
214
+ const sig = tokens.slice(span.from, span.to).filter((t) => !isTrivia(t));
215
+ const words = sig.map((t) => t.text.toLowerCase());
216
+ const method = words[0] === "with" ? words[1] : undefined;
217
+ // `SHOW CREATE USER` prints a password's method without the password (`IDENTIFIED WITH sha256_password`): that much is read.
218
+ const secret = words.includes("by") || words.includes("hash");
219
+ if (secret && method !== "ssh_key") {
220
+ p.fail(
221
+ method === undefined
222
+ ? "IDENTIFIED BY holds a password, which is the environment's: declare the user without IDENTIFIED, create it with its password, and chant manages the rest"
223
+ : `IDENTIFIED WITH ${method} holds a password or its hash, which is the environment's: declare the user without IDENTIFIED, create it with its password, and chant manages the rest`,
224
+ sig[0],
225
+ );
226
+ }
227
+ }
228
+
229
+ /** Parse one access statement. Throws `SqlSyntaxError` located in the statement. */
230
+ export function parseAccess(tokens: Token[]): AccessNode {
231
+ return new AccessParser(tokens).parse();
232
+ }
233
+
234
+ /** Whether a statement's text is an access statement this module reads. */
235
+ export function isAccessStatement(ddl: string): boolean {
236
+ const words = ddl
237
+ .replace(/--[^\n]*\n/g, " ")
238
+ .trim()
239
+ .split(/\s+/, 4)
240
+ .map((w) => w.toUpperCase());
241
+ if (words[0] === "GRANT" || words[0] === "REVOKE") return true;
242
+ if (words[0] !== "CREATE") return false;
243
+ return words[1] === "USER" || words[1] === "ROLE" || words[1] === "ROW" || words[1] === "POLICY";
244
+ }
245
+
246
+ // ── canonical forms ───────────────────────────────────────────────────
247
+
248
+ const text = (tokens: Token[], span: Span | undefined): string | undefined =>
249
+ span && span.to > span.from
250
+ ? tokens
251
+ .slice(span.from, span.to)
252
+ .map((t) => t.text)
253
+ .join("")
254
+ .trim()
255
+ : undefined;
256
+
257
+ const sigOf = (tokens: Token[], span: Span): Token[] => tokens.slice(span.from, span.to).filter((t) => !isTrivia(t));
258
+
259
+ /** Tokens joined by single spaces: identifiers unquoted, bare words as `word` says. */
260
+ function join(sig: Token[], word: (t: string) => string): string {
261
+ return sig.map((t) => (t.kind === "qident" ? unquote(t.text) : t.kind === "ident" ? word(t.text) : t.text)).join(" ");
262
+ }
263
+
264
+ /** A comma-separated list of names, sorted, with the words of `keywords` upper case: `DEFAULT ROLE b, a` and `TO ALL EXCEPT r`. */
265
+ function nameList(tokens: Token[], span: Span, keywords: string[]): string {
266
+ const sig = sigOf(tokens, span);
267
+ const out: string[] = [];
268
+ let cur: Token[] = [];
269
+ const flush = () => {
270
+ if (cur.length) out.push(join(cur, (w) => (keywords.includes(w.toUpperCase()) ? w.toUpperCase() : w)));
271
+ cur = [];
272
+ };
273
+ for (const t of sig) {
274
+ if (t.kind === "punct" && t.text === ",") flush();
275
+ else if (t.kind === "ident" && keywords.includes(t.text.toUpperCase()) && cur.length > 0 && !keywords.includes(cur[cur.length - 1]!.text.toUpperCase())) {
276
+ // `ALL EXCEPT a`: the keyword starts a new part.
277
+ flush();
278
+ cur.push(t);
279
+ } else cur.push(t);
280
+ }
281
+ flush();
282
+ const keys = out.filter((x) => keywords.includes(x.split(" ")[0]!));
283
+ const names = out.filter((x) => !keywords.includes(x.split(" ")[0]!)).sort();
284
+ return [...keys, ...names].join(" , ");
285
+ }
286
+
287
+ /** `SETTINGS a = 1 READONLY, b MIN 0 MAX 2`: each setting, sorted by name, `READONLY` as the server prints it (`CONST`). */
288
+ function settingsList(tokens: Token[], span: Span): string {
289
+ const sig = sigOf(tokens, span);
290
+ const parts: Token[][] = [[]];
291
+ let depth = 0;
292
+ for (const t of sig) {
293
+ if (t.text === "(") depth++;
294
+ if (t.text === ")") depth--;
295
+ if (depth === 0 && t.kind === "punct" && t.text === ",") parts.push([]);
296
+ else parts[parts.length - 1]!.push(t);
297
+ }
298
+ const words = new Set(["MIN", "MAX", "CONST", "READONLY", "WRITABLE", "CHANGEABLE_IN_READONLY", "PROFILE", "NONE"]);
299
+ return parts
300
+ .filter((p) => p.length > 0)
301
+ .map((p) => join(p, (w) => (words.has(w.toUpperCase()) ? w.toUpperCase() : w)).replace(/\bREADONLY$/, "CONST"))
302
+ .sort()
303
+ .join(" , ");
304
+ }
305
+
306
+ /** `HOST LOCAL, IP '10.0.0.0/8'`: each host, sorted, the kind of each upper case. */
307
+ function hostList(tokens: Token[], span: Span): string {
308
+ const words = new Set(["LOCAL", "ANY", "NONE", "IP", "NAME", "REGEXP", "LIKE"]);
309
+ const sig = sigOf(tokens, span);
310
+ const parts: string[] = [];
311
+ let cur: Token[] = [];
312
+ for (const t of [...sig, undefined]) {
313
+ if (!t || (t.kind === "punct" && t.text === ",")) {
314
+ if (cur.length) parts.push(join(cur, (w) => (words.has(w.toUpperCase()) ? w.toUpperCase() : w)));
315
+ cur = [];
316
+ } else cur.push(t);
317
+ }
318
+ return parts.sort().join(" , ");
319
+ }
320
+
321
+ /** `[db.]name` with a table's database filled in. */
322
+ function qualifiedName(tokens: Token[], span: Span, defaultDatabase: string): { database: string; name: string } {
323
+ const parts = sigOf(tokens, span)
324
+ .filter((t) => !(t.kind === "punct" && t.text === "."))
325
+ .map((t) => unquote(t.text));
326
+ return parts.length >= 2 ? { database: parts[parts.length - 2]!, name: parts[parts.length - 1]! } : { database: defaultDatabase, name: parts[0] ?? "" };
327
+ }
328
+
329
+ /** What a plan compares of a user, role or row policy: one string per field, absent at its default. */
330
+ export interface AccessCanonical {
331
+ kind: "user" | "role" | "rowPolicy";
332
+ name: string;
333
+ /** A row policy's table's database. */
334
+ database?: string;
335
+ /** A row policy's table. */
336
+ table?: string;
337
+ fields: Record<string, string>;
338
+ }
339
+
340
+ /**
341
+ * A user, role or row policy as a plan compares it. A field at the server's
342
+ * default is left out, so a declaration that leaves out `HOST ANY` and the
343
+ * server that does not print it agree. A user's `IDENTIFIED` is kept only
344
+ * when written; a plan compares it only when the declaration has one.
345
+ */
346
+ export function accessCanonical(ddl: string, defaultDatabase = "default"): AccessCanonical {
347
+ const tokens = tokenizeText(ddl, 0);
348
+ const node = parseAccess(tokens);
349
+ if (node.statement === "grant") throw new Error("a grant is compared as its grantee's grants (grantsCanonical)");
350
+ const name = unquote(text(tokens, node.name) ?? "");
351
+ const fields: Record<string, string> = {};
352
+ const put = (key: string, value: string | undefined, def?: string) => {
353
+ if (value !== undefined && value !== "" && value !== def) fields[key] = value;
354
+ };
355
+ const c = node.clauses;
356
+ if (node.statement === "user") {
357
+ if (c.notIdentified) fields.identified = "no_password";
358
+ else if (c.identified) {
359
+ // `WITH method` lower case, as the server prints it; key words upper case; strings as written.
360
+ const sig = sigOf(tokens, c.identified);
361
+ const out = sig.map((t, i) => (t.kind === "ident" ? (i === 1 && sig[0]!.text.toUpperCase() === "WITH" ? t.text.toLowerCase() : t.text.toUpperCase()) : t.text));
362
+ fields.identified = out.join(" ").replace(/^WITH no_password$/, "no_password").replace(/^WITH /, "");
363
+ }
364
+ put("host", c.host && hostList(tokens, c.host), "ANY");
365
+ put("validUntil", text(tokens, c.validUntil));
366
+ put("defaultRole", c.defaultRole && nameList(tokens, c.defaultRole, ["ALL", "NONE", "EXCEPT"]), "ALL");
367
+ put("defaultDatabase", c.defaultDatabase && unquote(text(tokens, c.defaultDatabase) ?? ""), "NONE");
368
+ put("grantees", c.grantees && nameList(tokens, c.grantees, ["ANY", "NONE", "EXCEPT"]), "ANY");
369
+ put("settings", c.settings && settingsList(tokens, c.settings), "NONE");
370
+ return { kind: "user", name, fields };
371
+ }
372
+ if (node.statement === "role") {
373
+ put("settings", c.settings && settingsList(tokens, c.settings), "NONE");
374
+ return { kind: "role", name, fields };
375
+ }
376
+ const table = qualifiedName(tokens, node.table!, defaultDatabase);
377
+ put("as", c.as && text(tokens, c.as)!.toUpperCase(), "PERMISSIVE");
378
+ put("for", c.for && join(sigOf(tokens, c.for), (w) => w.toUpperCase()), "SELECT");
379
+ put("using", text(tokens, c.using));
380
+ put("to", c.to && nameList(tokens, c.to, ["ALL", "NONE", "EXCEPT"]), "NONE");
381
+ return { kind: "rowPolicy", name, database: table.database, table: table.name, fields };
382
+ }
383
+
384
+ // ── grants ────────────────────────────────────────────────────────────
385
+
386
+ /** One grant, the smallest thing a plan adds or takes away. */
387
+ export interface GrantAtom {
388
+ /** A privilege, upper case (`SELECT`, `SHOW TABLES`), or the role granted. */
389
+ privilege?: string;
390
+ role?: string;
391
+ /** One column of a column-level privilege. */
392
+ column?: string;
393
+ /** `db.table`, `db.*`, `*.*` or `*`, unquoted. */
394
+ target?: string;
395
+ /** `WITH GRANT OPTION` (a privilege) or `WITH ADMIN OPTION` (a role). */
396
+ option?: boolean;
397
+ /** A partial revoke the server holds (`REVOKE SELECT(b) ON db.t`). Never declared. */
398
+ revoke?: boolean;
399
+ }
400
+
401
+ /** An atom's key, which is also how a plan prints it: `SELECT(a) ON shop.t`, `ROLE reader WITH ADMIN OPTION`. */
402
+ export function atomKey(a: GrantAtom): string {
403
+ if (a.role !== undefined) return `ROLE ${a.role}${a.option ? " WITH ADMIN OPTION" : ""}`;
404
+ return `${a.revoke ? "REVOKE " : ""}${a.privilege}${a.column !== undefined ? `(${a.column})` : ""} ON ${a.target}${a.option ? " WITH GRANT OPTION" : ""}`;
405
+ }
406
+
407
+ function grantAtoms(ddl: string, defaultDatabase: string): Array<{ grantees: string[]; atoms: GrantAtom[] }> {
408
+ const out: Array<{ grantees: string[]; atoms: GrantAtom[] }> = [];
409
+ for (const statement of splitLines(ddl)) {
410
+ const tokens = tokenizeText(statement, 0);
411
+ const node = parseAccess(tokens);
412
+ if (node.statement !== "grant") throw new Error(`not a grant: ${statement}`);
413
+ const grantees = node.grantees.map((g) => unquote(text(tokens, g) ?? ""));
414
+ const atoms: GrantAtom[] = [];
415
+ let target: string | undefined;
416
+ if (node.target) {
417
+ const parts = sigOf(tokens, node.target)
418
+ .filter((t) => !(t.kind === "punct" && t.text === "."))
419
+ .map((t) => unquote(t.text));
420
+ target = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0] === "*" ? "*" : `${defaultDatabase}.${parts[0]}`;
421
+ }
422
+ for (const item of node.items) {
423
+ const sig = sigOf(tokens, item);
424
+ if (!target) {
425
+ atoms.push({ role: unquote(text(tokens, item) ?? ""), ...(node.withOption === "ADMIN" ? { option: true } : {}) });
426
+ continue;
427
+ }
428
+ const open = sig.findIndex((t) => t.text === "(");
429
+ const words = (open < 0 ? sig : sig.slice(0, open)).map((t) => t.text.toUpperCase()).join(" ");
430
+ const columns =
431
+ open < 0
432
+ ? [undefined]
433
+ : sig
434
+ .slice(open + 1, -1)
435
+ .filter((t) => !(t.kind === "punct" && t.text === ","))
436
+ .map((t) => unquote(t.text));
437
+ for (const column of columns) {
438
+ atoms.push({ privilege: words, ...(column !== undefined ? { column } : {}), target, ...(node.withOption === "GRANT" ? { option: true } : {}), ...(node.revoke ? { revoke: true } : {}) });
439
+ }
440
+ }
441
+ out.push({ grantees, atoms });
442
+ }
443
+ return out;
444
+ }
445
+
446
+ /** Statements on their own lines, or separated by `;`, as `SHOW GRANTS` prints them and a group of declarations is joined. */
447
+ function splitLines(ddl: string): string[] {
448
+ return ddl
449
+ .split(/;\s*\n|\n(?=\s*(?:GRANT|REVOKE)\b)/i)
450
+ .map((s) => s.trim().replace(/;$/, ""))
451
+ .filter((s) => s.length > 0 && !/^--/.test(s));
452
+ }
453
+
454
+ /** Whether `outer` covers `inner`'s target: the same table, its database's `*`, or `*.*`. */
455
+ function covers(outer: string, inner: string): boolean {
456
+ if (outer === inner) return true;
457
+ if (outer === "*.*") return true;
458
+ const [db] = inner.split(".");
459
+ return outer === `${db}.*` && inner !== outer;
460
+ }
461
+
462
+ /**
463
+ * The atoms a grantee holds, with each atom another covers left out, as the
464
+ * server folds them: a column's privilege under the table's, a table's under
465
+ * its database's, any under `*.*`, the same privilege, with a grant option at
466
+ * least as wide.
467
+ */
468
+ export function foldAtoms(atoms: readonly GrantAtom[]): GrantAtom[] {
469
+ const keyed = new Map(atoms.map((a) => [atomKey(a), a]));
470
+ const all = [...keyed.values()];
471
+ return all.filter(
472
+ (a) =>
473
+ a.role !== undefined ||
474
+ a.revoke ||
475
+ !all.some(
476
+ (b) =>
477
+ b !== a &&
478
+ !b.revoke &&
479
+ b.role === undefined &&
480
+ b.privilege === a.privilege &&
481
+ b.column === undefined &&
482
+ (b.option ?? false) >= (a.option ?? false) &&
483
+ covers(b.target!, a.target!) &&
484
+ (a.column !== undefined || b.target !== a.target),
485
+ ),
486
+ );
487
+ }
488
+
489
+ /** A grantee's grants as a plan compares them: each atom's key, mapped to the atom. */
490
+ export function grantsCanonical(ddl: string, grantee: string, defaultDatabase = "default"): Record<string, string> {
491
+ const atoms = grantAtoms(ddl, defaultDatabase)
492
+ .filter((g) => g.grantees.includes(grantee))
493
+ .flatMap((g) => g.atoms);
494
+ return Object.fromEntries(
495
+ foldAtoms(atoms)
496
+ .map((a) => [atomKey(a), JSON.stringify(a)] as const)
497
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
498
+ );
499
+ }
500
+
501
+ /** Every grantee a grant statement names. */
502
+ export function granteesOf(ddl: string): string[] {
503
+ return [...new Set(grantAtoms(ddl, "default").flatMap((g) => g.grantees))];
504
+ }
505
+
506
+ // ── statements ────────────────────────────────────────────────────────
507
+
508
+ const ident = (name: string): string => `\`${name.replace(/`/g, "``")}\``;
509
+ const targetSql = (target: string): string =>
510
+ target === "*" || target === "*.*"
511
+ ? target
512
+ : target
513
+ .split(".")
514
+ .map((p) => (p === "*" ? p : ident(p)))
515
+ .join(".");
516
+
517
+ /** The statement that gives `grantee` one atom. */
518
+ export function grantStatement(atom: GrantAtom, grantee: string): string {
519
+ if (atom.role !== undefined) return `GRANT ${ident(atom.role)} TO ${ident(grantee)}${atom.option ? " WITH ADMIN OPTION" : ""}`;
520
+ const priv = `${atom.privilege}${atom.column !== undefined ? `(${ident(atom.column)})` : ""}`;
521
+ // Undoing a partial revoke grants the privilege back.
522
+ return `GRANT ${priv} ON ${targetSql(atom.target!)} TO ${ident(grantee)}${atom.option && !atom.revoke ? " WITH GRANT OPTION" : ""}`;
523
+ }
524
+
525
+ /** The statement that takes one atom from `grantee`. */
526
+ export function revokeStatement(atom: GrantAtom, grantee: string): string {
527
+ if (atom.role !== undefined) return `REVOKE ${ident(atom.role)} FROM ${ident(grantee)}`;
528
+ const priv = `${atom.privilege}${atom.column !== undefined ? `(${ident(atom.column)})` : ""}`;
529
+ return `REVOKE ${priv} ON ${targetSql(atom.target!)} FROM ${ident(grantee)}`;
530
+ }
531
+
532
+ /**
533
+ * The `ALTER` that sets one field of a user or role to its declared value,
534
+ * or back to the server's default when the declaration leaves it out.
535
+ */
536
+ export function alterAccessField(kind: "user" | "role", name: string, field: string, declared: AccessCanonical["fields"], declaredText: Partial<Record<string, string>>): string {
537
+ const head = `ALTER ${kind === "user" ? "USER" : "ROLE"} ${ident(name)}`;
538
+ const value = declaredText[field];
539
+ switch (field) {
540
+ case "identified":
541
+ return value === undefined || value === "NOT IDENTIFIED" ? `${head} NOT IDENTIFIED` : `${head} IDENTIFIED ${value}`;
542
+ case "host":
543
+ return `${head} HOST ${value ?? "ANY"}`;
544
+ case "validUntil":
545
+ return `${head} VALID UNTIL ${value ?? "'infinity'"}`;
546
+ case "defaultRole":
547
+ return `${head} DEFAULT ROLE ${value ?? "ALL"}`;
548
+ case "defaultDatabase":
549
+ return `${head} DEFAULT DATABASE ${value ?? "NONE"}`;
550
+ case "grantees":
551
+ return `${head} GRANTEES ${value ?? "ANY"}`;
552
+ case "settings":
553
+ return `${head} SETTINGS ${value ?? "NONE"}`;
554
+ default:
555
+ throw new Error(`no ALTER for ${kind} field ${field}`);
556
+ }
557
+ }
558
+
559
+ /** A user's or role's clauses as written, by field, for the `ALTER` that sets one. */
560
+ export function accessClauseText(ddl: string): Partial<Record<string, string>> {
561
+ const tokens = tokenizeText(ddl, 0);
562
+ const node = parseAccess(tokens);
563
+ const out: Partial<Record<string, string>> = {};
564
+ for (const [key, span] of Object.entries(node.clauses)) {
565
+ if (!span) continue;
566
+ out[key === "notIdentified" ? "identified" : key] = key === "notIdentified" ? "NOT IDENTIFIED" : text(tokens, span);
567
+ }
568
+ return out;
569
+ }
570
+
571
+ /**
572
+ * The grant declarations of a build, one entry per grantee they name: the
573
+ * export names that grant to it, their statements on their own lines, and
574
+ * what they depend on. A plan compares each entry with what the server
575
+ * holds for that grantee.
576
+ */
577
+ export function grantsByGrantee(objects: ReadonlyArray<{ export: string; type: string; ddl: string; dependsOn?: readonly string[] }>): Array<{
578
+ grantee: string;
579
+ exports: string[];
580
+ ddl: string;
581
+ dependsOn: string[];
582
+ }> {
583
+ const by = new Map<string, { exports: string[]; statements: string[]; dependsOn: Set<string> }>();
584
+ for (const o of objects) {
585
+ if (o.type !== "ClickHouse::Grant") continue;
586
+ for (const grantee of granteesOf(o.ddl)) {
587
+ const entry = by.get(grantee) ?? { exports: [], statements: [], dependsOn: new Set<string>() };
588
+ entry.exports.push(o.export);
589
+ entry.statements.push(o.ddl.trim().replace(/;$/, ""));
590
+ for (const d of o.dependsOn ?? []) entry.dependsOn.add(d);
591
+ by.set(grantee, entry);
592
+ }
593
+ }
594
+ return [...by].map(([grantee, e]) => ({ grantee, exports: e.exports, ddl: e.statements.join(";\n"), dependsOn: [...e.dependsOn] }));
595
+ }
@@ -43,8 +43,9 @@ import {
43
43
  } from "../../core/apply";
44
44
  import { clickhouseQuery } from "../http";
45
45
  import type { ClickHouseTarget } from "../live/bind";
46
- import { readLiveSchema, type LiveObject } from "../live/catalog";
47
- import { canonicalObject, type CanonicalObject } from "../plan/normalize";
46
+ import { declaredAccess, readLiveAccess, readLiveSchema, type LiveObject } from "../live/catalog";
47
+ import { canonicalObject, grantsObject, liveCanonical, objectKey, scopeOf, type CanonicalObject } from "../plan/normalize";
48
+ import { grantsByGrantee } from "../access";
48
49
  import { diffSchemas, type Change } from "../plan/diff";
49
50
  import { dropFormattingOnly } from "../plan/server-format";
50
51
  import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
@@ -65,18 +66,25 @@ export type { FailedObject } from "../../core/apply";
65
66
  * is what the changes compare and every `CREATE` sends.
66
67
  */
67
68
  export function declaredObjects(json: string, defaultDatabase = "default", topology?: Topology): DeclaredObject[] {
68
- return readBuildObjects(json, "clickhouse", (o) => {
69
- const ddl = renderFor(o.ddl, topology);
70
- const canonical = canonicalObject(ddl, defaultDatabase);
71
- return {
72
- exportName: o.export,
73
- type: o.type as ClickHouseEntityType,
74
- key: canonical.database !== undefined ? `${canonical.database}.${canonical.name}` : canonical.name,
75
- ddl,
76
- canonical,
77
- dependsOn: o.dependsOn,
78
- };
79
- });
69
+ const objects = readBuildObjects(json, "clickhouse", (o) => o);
70
+ const declared: DeclaredObject[] = objects
71
+ .filter((o) => o.type !== CLICKHOUSE_ENTITY_TYPES.grant)
72
+ .map((o) => {
73
+ const ddl = renderFor(o.ddl, topology);
74
+ const canonical = canonicalObject(ddl, defaultDatabase);
75
+ return { exportName: o.export, type: o.type as ClickHouseEntityType, key: objectKey(canonical), ddl, canonical, dependsOn: o.dependsOn };
76
+ });
77
+ // Grants are compared per grantee (`../access.ts`), once every other object exists, and wait for the
78
+ // grantee when the build declares it: a user the environment has still to create gets none.
79
+ const principals = new Map(declared.filter((o) => o.canonical.kind === "user" || o.canonical.kind === "role").map((o) => [o.canonical.name, o.exportName]));
80
+ for (const g of grantsByGrantee(objects)) {
81
+ const canonical = grantsObject(g.ddl, g.grantee, defaultDatabase);
82
+ const key = objectKey(canonical);
83
+ const grantee = principals.get(g.grantee);
84
+ const dependsOn = grantee !== undefined && !g.dependsOn.includes(grantee) ? [...g.dependsOn, grantee] : g.dependsOn;
85
+ declared.push({ exportName: key, type: CLICKHOUSE_ENTITY_TYPES.grant, key, ddl: g.ddl, canonical, dependsOn });
86
+ }
87
+ return declared;
80
88
  }
81
89
 
82
90
  /** The plan axis: one ref per declared object, by its kind and its name on the server. */
@@ -108,18 +116,18 @@ export class ClickHouseApplyError extends SqlApplyError<ClickHouseApplyOutcome>
108
116
  }
109
117
  }
110
118
 
111
- const liveKey = (o: LiveObject) => (o.database !== undefined ? `${o.database}.${o.name}` : o.name);
119
+ const liveKey = (o: LiveObject) => objectKey(o);
112
120
 
113
121
  /**
114
122
  * The server's objects in the databases the declarations use, as the diff
115
123
  * compares them. The `default` database itself is never part of a schema.
116
124
  */
117
125
  async function liveSchema(target: ClickHouseTarget, declared: readonly DeclaredObject[]): Promise<{ objects: LiveObject[]; canonical: Map<string, CanonicalObject> }> {
118
- const databases = new Set(declared.map((o) => o.canonical.database ?? o.canonical.name));
119
- const objects = (await readLiveSchema(target))
126
+ const scope = new Set(declared.map((o) => scopeOf(o.canonical)));
127
+ const objects = [...(await readLiveSchema(target)), ...(await readLiveAccess(target, declaredAccess(declared.map((o) => o.canonical))))]
120
128
  .filter((o) => !(o.type === CLICKHOUSE_ENTITY_TYPES.database && o.name === "default"))
121
- .filter((o) => databases.has(o.database ?? o.name));
122
- return { objects, canonical: new Map(objects.map((o) => [liveKey(o), canonicalObject(o.statement, target.defaultDatabase)])) };
129
+ .filter((o) => scope.has(scopeOf(o)));
130
+ return { objects, canonical: new Map(objects.map((o) => [liveKey(o), liveCanonical(o, target.defaultDatabase)])) };
123
131
  }
124
132
 
125
133
  /**
@@ -187,7 +195,8 @@ export async function applyClickHouse(target: ClickHouseTarget, declared: readon
187
195
  continue;
188
196
  }
189
197
  if (entry.verdict === "withheld") {
190
- onServer.add(obj.exportName);
198
+ // A user the environment has still to create is not on the server, so nothing that needs it is attempted.
199
+ if (liveObject) onServer.add(obj.exportName);
191
200
  outcome.notAttempted.push({ ...ref, reason: "filtered", detail: entry.detail });
192
201
  continue;
193
202
  }