@intentius/chant-lexicon-sql 0.117.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 (183) 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 +50 -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 +3 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/integrity.json +6 -6
  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/templates.d.ts +1 -1
  72. package/dist/lint/rules/templates.d.ts.map +1 -1
  73. package/dist/manifest.json +1 -1
  74. package/dist/meta.json +30 -0
  75. package/dist/okf/index.md +7 -1
  76. package/dist/okf/rules/SQLCH001.md +2 -2
  77. package/dist/okf/types/Dictionary.md +9 -0
  78. package/dist/okf/types/Function.md +9 -0
  79. package/dist/okf/types/Grant.md +9 -0
  80. package/dist/okf/types/Role.md +9 -0
  81. package/dist/okf/types/RowPolicy.md +9 -0
  82. package/dist/okf/types/User.md +9 -0
  83. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  84. package/dist/op/activity-contracts.d.ts +9 -0
  85. package/dist/op/activity-contracts.d.ts.map +1 -1
  86. package/dist/plugin.d.ts.map +1 -1
  87. package/dist/postgres/apply/statements.d.ts +68 -0
  88. package/dist/postgres/apply/statements.d.ts.map +1 -1
  89. package/dist/postgres/import/generator.d.ts +33 -0
  90. package/dist/postgres/import/generator.d.ts.map +1 -1
  91. package/dist/postgres/live/bind.d.ts.map +1 -1
  92. package/dist/postgres/live/client.d.ts +3 -0
  93. package/dist/postgres/live/client.d.ts.map +1 -1
  94. package/dist/rules/sqlch001.ts +26 -4
  95. package/dist/rules/sqlch002.ts +3 -2
  96. package/dist/rules/templates.ts +2 -2
  97. package/dist/serializer.d.ts +2 -1
  98. package/dist/serializer.d.ts.map +1 -1
  99. package/dist/sql-files.d.ts +79 -0
  100. package/dist/sql-files.d.ts.map +1 -0
  101. package/dist/token-source.d.ts +88 -0
  102. package/dist/token-source.d.ts.map +1 -0
  103. package/dist/validate.d.ts.map +1 -1
  104. package/package.json +2 -2
  105. package/src/clickhouse/access.test.ts +203 -0
  106. package/src/clickhouse/access.ts +595 -0
  107. package/src/clickhouse/apply/apply.ts +29 -20
  108. package/src/clickhouse/apply/statements.ts +105 -8
  109. package/src/clickhouse/dictionary.test.ts +177 -0
  110. package/src/clickhouse/entities.ts +356 -6
  111. package/src/clickhouse/function.test.ts +91 -0
  112. package/src/clickhouse/http.ts +11 -2
  113. package/src/clickhouse/import/generator.ts +81 -46
  114. package/src/clickhouse/import/import.test.ts +9 -3
  115. package/src/clickhouse/import/ir.ts +6 -2
  116. package/src/clickhouse/import/parser.ts +10 -18
  117. package/src/clickhouse/live/access.e2e.test.ts +218 -0
  118. package/src/clickhouse/live/bind.ts +17 -6
  119. package/src/clickhouse/live/catalog.ts +106 -10
  120. package/src/clickhouse/live/describe-resources.ts +23 -10
  121. package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
  122. package/src/clickhouse/live/functions.e2e.test.ts +155 -0
  123. package/src/clickhouse/live/live.test.ts +2 -2
  124. package/src/clickhouse/parser.ts +136 -4
  125. package/src/clickhouse/plan/commands.ts +18 -7
  126. package/src/clickhouse/plan/deep.ts +71 -11
  127. package/src/clickhouse/plan/diff.ts +65 -2
  128. package/src/clickhouse/plan/normalize.ts +200 -6
  129. package/src/clickhouse/plan/report.ts +14 -3
  130. package/src/clickhouse/plan/rules.ts +44 -1
  131. package/src/clickhouse/plan/schema.ts +40 -17
  132. package/src/clickhouse/plan/server-format.ts +1 -1
  133. package/src/clickhouse/rebuild/backfill.ts +5 -25
  134. package/src/clickhouse/rebuild/observe.ts +7 -3
  135. package/src/clickhouse/rebuild/op.ts +9 -0
  136. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
  137. package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
  138. package/src/clickhouse/rebuild/steps.ts +136 -1
  139. package/src/clickhouse/testing/fake-server.ts +2 -0
  140. package/src/clickhouse/testing/writable-server.ts +2 -0
  141. package/src/clickhouse/topology.ts +17 -2
  142. package/src/clickhouse.ts +19 -0
  143. package/src/codegen/classifier-page.ts +2 -0
  144. package/src/codegen/registry.ts +6 -0
  145. package/src/config.ts +12 -3
  146. package/src/core/references.ts +5 -3
  147. package/src/core/statements.ts +11 -1
  148. package/src/files/build-root.ts +62 -0
  149. package/src/files/clickhouse.ts +52 -0
  150. package/src/files/common.ts +54 -0
  151. package/src/files/postgres.ts +371 -0
  152. package/src/generated/lexicon-sql.json +30 -0
  153. package/src/import-parser.ts +61 -0
  154. package/src/index.ts +15 -2
  155. package/src/lint/rules/sqlch001.ts +26 -4
  156. package/src/lint/rules/sqlch002.ts +3 -2
  157. package/src/lint/rules/templates.ts +2 -2
  158. package/src/lsp/template.ts +3 -3
  159. package/src/mcp/index.ts +6 -6
  160. package/src/migration-statements.test.ts +25 -0
  161. package/src/migration-statements.ts +1 -0
  162. package/src/op/activities/clickhouse-rebuild.ts +1 -0
  163. package/src/op/activity-contracts.ts +1 -0
  164. package/src/plugin.ts +29 -8
  165. package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
  166. package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
  167. package/src/postgres/apply/statements.test.ts +138 -6
  168. package/src/postgres/apply/statements.ts +156 -10
  169. package/src/postgres/import/generator.ts +118 -45
  170. package/src/postgres/live/bind.ts +17 -6
  171. package/src/postgres/live/client.ts +10 -2
  172. package/src/postgres/plan/plan.test.ts +2 -2
  173. package/src/postgres/plan/rules.ts +5 -5
  174. package/src/serializer.ts +14 -9
  175. package/src/skills/chant-sql-postgres-plan.md +3 -3
  176. package/src/skills/chant-sql-rebuild.md +3 -2
  177. package/src/skills/chant-sql.md +7 -1
  178. package/src/sql-files.test.ts +188 -0
  179. package/src/sql-files.ts +218 -0
  180. package/src/token-source.e2e.test.ts +142 -0
  181. package/src/token-source.test.ts +119 -0
  182. package/src/token-source.ts +189 -0
  183. package/src/validate.ts +6 -0
@@ -24,16 +24,24 @@
24
24
  import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
25
25
  import type { TemplateIR } from "@intentius/chant/import/parser";
26
26
  import { isTrivia, tokenizeText, type Token } from "../tokens";
27
- import { identValue } from "../parser";
27
+ import { identValue, parseStatements, type NameNode, type StatementNode } from "../parser";
28
28
  import { POSTGRES_ENTITY_TYPES } from "../entity-types";
29
29
 
30
- interface Item {
30
+ /** One object a template is written for, or (with no `ddl` read) one a template may reference. */
31
+ export interface PgTemplateItem {
31
32
  exportName: string;
32
33
  type: string;
33
34
  schema?: string;
34
35
  name: string;
35
36
  ddl: string;
36
37
  }
38
+ type Item = PgTemplateItem;
39
+
40
+ /** A template's text: `parts` (template-literal safe) around one interpolation per entry of `refs`, an export name. */
41
+ export interface TemplateBody {
42
+ parts: string[];
43
+ refs: string[];
44
+ }
37
45
 
38
46
  const TAG: Record<string, string> = {
39
47
  [POSTGRES_ENTITY_TYPES.schema]: "schema",
@@ -54,37 +62,79 @@ const TAG: Record<string, string> = {
54
62
  [POSTGRES_ENTITY_TYPES.defaultPrivileges]: "grant",
55
63
  };
56
64
 
65
+ /** The tag each entity type is declared with (`table`, `func`, ...). */
66
+ export const POSTGRES_TAG_OF: Readonly<Record<string, string>> = TAG;
67
+
57
68
  /** Text that sits inside a template literal: a backquote and `${` escaped. */
58
69
  const templateSafe = (s: string) => s.replace(/\\(?=`|\$\{)/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
59
70
 
60
71
  const isName = (t: Token | undefined) => t !== undefined && (t.kind === "ident" || t.kind === "qident");
61
72
 
62
- /** `'app.invoice_seq'` as the qualified name it holds. */
73
+ /** `'app.invoice_seq'` as the qualified name it holds, or the bare name when unqualified. */
63
74
  function regclassName(t: Token): string | undefined {
64
75
  if (t.kind !== "string" || !/^'.*'$/s.test(t.text)) return undefined;
65
76
  const inner = t.text.slice(1, -1).replace(/''/g, "'");
66
77
  const pieces = inner.match(/"(?:[^"]|"")*"|[^.]+/g)?.map((p) => identValue(p)) ?? [];
67
- return pieces.length === 2 ? `${pieces[0]}.${pieces[1]}` : pieces.length === 1 ? `public.${pieces[0]}` : undefined;
78
+ return pieces.length === 2 ? `${pieces[0]}.${pieces[1]}` : pieces.length === 1 ? pieces[0] : undefined;
68
79
  }
69
80
 
70
- function templateBody(item: Item, byQualified: Map<string, Item>, schemaExport: Map<string, string>): { body: string; refs: string[] } {
81
+ /** Words after which a bare name is a relation: `FROM t`, `JOIN t`, `REFERENCES t`. */
82
+ const RELATION_WORDS = new Set(["FROM", "JOIN", "REFERENCES"]);
83
+
84
+ /**
85
+ * The tokens where a bare (unqualified) name is another object's: a column's
86
+ * or a domain's type, the table of an index, trigger or policy, a trigger's
87
+ * function, a grant's objects. A `FROM`, `JOIN` or `REFERENCES` before a
88
+ * name is read off the tokens directly.
89
+ */
90
+ function bareSpots(tokens: Token[]): Set<number> {
91
+ const out = new Set<number>();
92
+ let nodes: StatementNode[];
93
+ try {
94
+ nodes = parseStatements(tokens);
95
+ } catch {
96
+ return out;
97
+ }
98
+ const first = (from: number, to: number) => {
99
+ for (let i = from; i < to; i++) if (!isTrivia(tokens[i]!)) return out.add(i);
100
+ return out;
101
+ };
102
+ const name = (n: NameNode | undefined) => n && n.span.to - n.span.from > 0 && first(n.span.from, n.span.to);
103
+ for (const node of nodes) {
104
+ if (node.statement === "table") for (const c of node.columns) if (c.type) first(c.type.from, c.type.to);
105
+ if (node.statement === "domain") first(node.type.from, node.type.to);
106
+ if (node.statement === "index" || node.statement === "policy") name(node.table);
107
+ if (node.statement === "trigger") {
108
+ name(node.table);
109
+ name(node.function);
110
+ }
111
+ if (node.statement === "grant") for (const o of node.objects) name(o.name);
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function templateBody(item: Item, byQualified: Map<string, Item>, schemaExport: Map<string, string>): TemplateBody {
71
117
  const tokens = tokenizeText(item.ddl, 0);
72
118
  const sig: number[] = [];
73
119
  tokens.forEach((t, i) => {
74
120
  if (!isTrivia(t)) sig.push(i);
75
121
  });
76
- const replace = new Map<number, { to: number; text: string; ref: string }>();
122
+ const spots = bareSpots(tokens);
123
+ const replace = new Map<number, { to: number; ref: string }>();
124
+ const isSelf = (target: Item) => target === item || (target.exportName === item.exportName && target.type === item.type);
77
125
  for (let k = 0; k < sig.length; k++) {
78
126
  const a = tokens[sig[k]!]!;
79
127
  const prev = tokens[sig[k - 1] ?? -1];
80
128
  if (prev?.kind === "punct" && prev.text === ".") continue;
81
129
  // A regclass literal: nextval('app.seq'::regclass) or 'app.t'::regclass.
82
130
  if (a.kind === "string" && tokens[sig[k + 1] ?? -1]?.text === "::" && tokens[sig[k + 2] ?? -1]?.text.toLowerCase() === "regclass") {
83
- const target = byQualified.get(regclassName(a) ?? "");
84
- if (target && target !== item) {
131
+ const held = regclassName(a) ?? "";
132
+ // An unqualified name is the object in `public`, as the server's own printing has it, else the unqualified object of that name.
133
+ const target = (!held.includes(".") ? byQualified.get(`public.${held}`) : undefined) ?? byQualified.get(held);
134
+ if (target && !isSelf(target)) {
85
135
  const fn = tokens[sig[k - 2] ?? -1];
86
136
  const inCall = prev?.text === "(" && fn?.kind === "ident" && /^(nextval|currval|setval)$/i.test(fn.text);
87
- replace.set(sig[k]!, { to: inCall ? sig[k + 2]! + 1 : sig[k]! + 1, text: `\${${target.exportName}}`, ref: target.exportName });
137
+ replace.set(sig[k]!, { to: inCall ? sig[k + 2]! + 1 : sig[k]! + 1, ref: target.exportName });
88
138
  k += inCall ? 2 : 0;
89
139
  continue;
90
140
  }
@@ -96,36 +146,85 @@ function templateBody(item: Item, byQualified: Map<string, Item>, schemaExport:
96
146
  const bySchemaWord = item.type === POSTGRES_ENTITY_TYPES.extension || item.type === POSTGRES_ENTITY_TYPES.grant || item.type === POSTGRES_ENTITY_TYPES.defaultPrivileges;
97
147
  if (bySchemaWord && isName(a) && prev?.kind === "ident" && prev.text.toUpperCase() === "SCHEMA") {
98
148
  const s = schemaExport.get(identValue(a));
99
- if (s) replace.set(sig[k]!, { to: sig[k]! + 1, text: `\${${s}}`, ref: s });
149
+ if (s) replace.set(sig[k]!, { to: sig[k]! + 1, ref: s });
150
+ continue;
100
151
  }
152
+ // A bare name where an object is named: `REFERENCES users`, `ON users`, a column of type `status`.
153
+ const bare = isName(a) && (spots.has(sig[k]!) || (prev?.kind === "ident" && RELATION_WORDS.has(prev.text.toUpperCase())));
154
+ const target = bare ? byQualified.get(identValue(a)) : undefined;
155
+ if (target && !isSelf(target)) replace.set(sig[k]!, { to: sig[k]! + 1, ref: target.exportName });
101
156
  continue;
102
157
  }
103
158
  const schema = identValue(a);
104
159
  const name = identValue(b);
105
160
  const self = schema === item.schema && name === item.name;
106
161
  const target = byQualified.get(`${schema}.${name}`);
107
- if (!self && target && target !== item) {
108
- replace.set(sig[k]!, { to: sig[k + 2]! + 1, text: `\${${target.exportName}}`, ref: target.exportName });
162
+ if (!self && target && !isSelf(target)) {
163
+ replace.set(sig[k]!, { to: sig[k + 2]! + 1, ref: target.exportName });
109
164
  k += 2;
110
165
  } else if (self && schemaExport.has(schema)) {
111
- replace.set(sig[k]!, { to: sig[k]! + 1, text: `\${${schemaExport.get(schema)}}`, ref: schemaExport.get(schema)! });
166
+ replace.set(sig[k]!, { to: sig[k]! + 1, ref: schemaExport.get(schema)! });
112
167
  k += 2;
113
168
  }
114
169
  }
115
- let body = "";
170
+ const parts: string[] = [""];
116
171
  const refs: string[] = [];
117
172
  for (let i = 0; i < tokens.length; ) {
118
173
  const r = replace.get(i);
119
174
  if (r) {
120
- body += r.text;
121
175
  refs.push(r.ref);
176
+ parts.push("");
122
177
  i = r.to;
123
178
  continue;
124
179
  }
125
- body += templateSafe(tokens[i]!.text);
180
+ parts[parts.length - 1] += templateSafe(tokens[i]!.text);
126
181
  i++;
127
182
  }
128
- return { body, refs };
183
+ return { parts, refs };
184
+ }
185
+
186
+ /** A template body as the text between the backquotes. */
187
+ export const bodyText = (body: TemplateBody): string => body.parts.map((p, i) => (i === 0 ? p : `\${${body.refs[i - 1]}}${p}`)).join("");
188
+
189
+ /**
190
+ * The template of each object in `items`, with the references to `targets`
191
+ * (the items themselves, and any object declared elsewhere that they may
192
+ * name) interpolated, and the items in the order a file declares them: each
193
+ * after what it references.
194
+ *
195
+ * A name is a reference where it is qualified (`app.users`), or bare where an
196
+ * object is named (`REFERENCES users`, `FROM users`, an index's table, a
197
+ * column's type) and an unqualified object has that name.
198
+ */
199
+ export function postgresTemplates(items: readonly Item[], targets: readonly Item[] = items): { order: Item[]; bodies: Map<string, TemplateBody> } {
200
+ const byQualified = new Map<string, Item>();
201
+ const schemaExport = new Map<string, string>();
202
+ for (const it of targets) {
203
+ if (it.type === POSTGRES_ENTITY_TYPES.schema) schemaExport.set(it.name, it.exportName);
204
+ // An index or a trigger is never referenced by name in another object's DDL.
205
+ else if (it.type !== POSTGRES_ENTITY_TYPES.index && it.type !== POSTGRES_ENTITY_TYPES.trigger && it.type !== POSTGRES_ENTITY_TYPES.policy && it.type !== POSTGRES_ENTITY_TYPES.grant && it.type !== POSTGRES_ENTITY_TYPES.defaultPrivileges && it.type !== POSTGRES_ENTITY_TYPES.role && it.type !== POSTGRES_ENTITY_TYPES.extension) {
206
+ byQualified.set(it.schema ? `${it.schema}.${it.name}` : it.name, it);
207
+ }
208
+ }
209
+
210
+ const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, schemaExport)]));
211
+ const order: Item[] = [];
212
+ const done = new Set<string>();
213
+ const visiting = new Set<string>();
214
+ const byExport = new Map(items.map((it) => [it.exportName, it]));
215
+ const visit = (it: Item) => {
216
+ if (done.has(it.exportName) || visiting.has(it.exportName)) return;
217
+ visiting.add(it.exportName);
218
+ for (const ref of bodies.get(it.exportName)!.refs) {
219
+ const dep = byExport.get(ref);
220
+ if (dep) visit(dep);
221
+ }
222
+ visiting.delete(it.exportName);
223
+ done.add(it.exportName);
224
+ order.push(it);
225
+ };
226
+ for (const it of items) visit(it);
227
+ return { order, bodies };
129
228
  }
130
229
 
131
230
  const indent = (body: string) =>
@@ -151,39 +250,13 @@ export class PostgresGenerator implements TypeScriptGenerator {
151
250
  }));
152
251
  if (items.length === 0) return [];
153
252
 
154
- const byQualified = new Map<string, Item>();
155
- const schemaExport = new Map<string, string>();
156
- for (const it of items) {
157
- if (it.type === POSTGRES_ENTITY_TYPES.schema) schemaExport.set(it.name, it.exportName);
158
- // An index or a trigger is never referenced by name in another object's DDL.
159
- else if (it.schema && it.type !== POSTGRES_ENTITY_TYPES.index && it.type !== POSTGRES_ENTITY_TYPES.trigger && it.type !== POSTGRES_ENTITY_TYPES.policy) {
160
- byQualified.set(`${it.schema}.${it.name}`, it);
161
- }
162
- }
163
-
164
- const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, schemaExport)]));
165
- const order: Item[] = [];
166
- const done = new Set<string>();
167
- const visiting = new Set<string>();
168
- const byExport = new Map(items.map((it) => [it.exportName, it]));
169
- const visit = (it: Item) => {
170
- if (done.has(it.exportName) || visiting.has(it.exportName)) return;
171
- visiting.add(it.exportName);
172
- for (const ref of bodies.get(it.exportName)!.refs) {
173
- const dep = byExport.get(ref);
174
- if (dep) visit(dep);
175
- }
176
- visiting.delete(it.exportName);
177
- done.add(it.exportName);
178
- order.push(it);
179
- };
180
- for (const it of items) visit(it);
253
+ const { order, bodies } = postgresTemplates(items);
181
254
 
182
255
  const tags = [...new Set(order.map((it) => TAG[it.type]!))].sort();
183
256
  const lines: string[] = [];
184
257
  if (this.header) lines.push(...this.header.split("\n").map((l) => `// ${l}`), "");
185
258
  lines.push(`import { ${tags.join(", ")} } from "@intentius/chant-lexicon-sql/postgres";`, "");
186
- for (const it of order) lines.push(`export const ${it.exportName} = ${TAG[it.type]}\`\n ${indent(bodies.get(it.exportName)!.body)}\`;`, "");
259
+ for (const it of order) lines.push(`export const ${it.exportName} = ${TAG[it.type]}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
187
260
  return [{ path: "schema.ts", content: `${lines.join("\n").trimEnd()}\n` }];
188
261
  }
189
262
  }
@@ -8,6 +8,7 @@ import type { ChantConfig } from "@intentius/chant/config";
8
8
  import type { UnobservedReason } from "@intentius/chant/observation";
9
9
  import { connectPostgres, PostgresQueryError, type PostgresClient, type PostgresEndpoint } from "./client";
10
10
  import type { PostgresProvider } from "../providers/types";
11
+ import { credentialSource, TokenSourceError } from "../../token-source";
11
12
 
12
13
  export interface PostgresTarget {
13
14
  endpoint: PostgresEndpoint;
@@ -58,12 +59,21 @@ export function resolvePostgresTarget(input: {
58
59
  if (profile && isPostgresUrl(profile.url)) {
59
60
  const source = `sql.profiles.${input.environment}`;
60
61
  const endpoint: PostgresEndpoint = { url: profile.url };
61
- for (const key of ["user", "password"] as const) {
62
- const ref = profile[key];
63
- if (!ref) continue;
64
- const value = env[ref.env];
65
- if (value === undefined) return { reason: "no-credentials", detail: `${source}.${key} names ${ref.env}, which is not set` };
66
- endpoint[key] = value;
62
+ if (profile.user) {
63
+ const value = env[profile.user.env];
64
+ if (value === undefined) return { reason: "no-credentials", detail: `${source}.user names ${profile.user.env}, which is not set` };
65
+ endpoint.user = value;
66
+ }
67
+ const password = profile.password;
68
+ if (password && "env" in password) {
69
+ const value = env[password.env];
70
+ if (value === undefined) return { reason: "no-credentials", detail: `${source}.password names ${password.env}, which is not set` };
71
+ endpoint.password = value;
72
+ } else if (password) {
73
+ if (password.token === "rds-iam" && endpoint.user === undefined) {
74
+ return { reason: "no-credentials", detail: `${source}.password is an rds-iam token, which is minted for one user, and ${source}.user is not set` };
75
+ }
76
+ endpoint.token = credentialSource(password, { url: profile.url, ...(endpoint.user !== undefined ? { user: endpoint.user } : {}), env, source: `${source}.password` });
67
77
  }
68
78
  const timeouts = Object.fromEntries(
69
79
  (["lockTimeoutMs", "statementTimeoutMs", "scanTimeoutMs"] as const).filter((k) => profile[k] !== undefined).map((k) => [k, profile[k]]),
@@ -142,6 +152,7 @@ const CREDENTIAL_STATES = new Set(["28000", "28P01", "42501"]);
142
152
  /** What a failed bind or read means, in the observation vocabulary. */
143
153
  export function classifyPostgresFailure(err: unknown): { reason: UnobservedReason; detail: string } {
144
154
  if (err instanceof PostgresBindingError) return { reason: err.unresolved.reason, detail: err.unresolved.detail };
155
+ if (err instanceof TokenSourceError) return { reason: "no-credentials", detail: err.message };
145
156
  if (err instanceof PostgresQueryError && err.code && CREDENTIAL_STATES.has(err.code)) {
146
157
  return { reason: "no-credentials", detail: `${err.message} (the server refused the credentials, SQLSTATE ${err.code})` };
147
158
  }
@@ -8,11 +8,15 @@
8
8
  * search path.
9
9
  */
10
10
 
11
+ import type { CredentialSource } from "../../token-source";
12
+
11
13
  export interface PostgresEndpoint {
12
14
  /** A `postgres://` connection URL, without a password. */
13
15
  url: string;
14
16
  user?: string;
15
17
  password?: string;
18
+ /** Mints the password at each connect, in place of `password` (a profile's token source, #3685). */
19
+ token?: CredentialSource;
16
20
  }
17
21
 
18
22
  /** What chant reads and writes through: one connection. */
@@ -54,7 +58,8 @@ export async function connectPostgres(endpoint: PostgresEndpoint, options: { app
54
58
  // node-postgres lets the connection string's fields win over the config's, so the credentials go into the URL.
55
59
  const url = new URL(endpoint.url);
56
60
  if (endpoint.user !== undefined) url.username = encodeURIComponent(endpoint.user);
57
- if (endpoint.password !== undefined) url.password = encodeURIComponent(endpoint.password);
61
+ const password = endpoint.token ? await endpoint.token.get() : endpoint.password;
62
+ if (password !== undefined) url.password = encodeURIComponent(password);
58
63
  const client = new pg.Client({ connectionString: url.toString(), application_name: options.applicationName ?? "chant" });
59
64
  // A server that goes away emits an error event; the next query fails with it instead of the process crashing.
60
65
  client.on("error", () => undefined);
@@ -63,7 +68,10 @@ export async function connectPostgres(endpoint: PostgresEndpoint, options: { app
63
68
  await client.query("SELECT pg_catalog.set_config('search_path', '', false)");
64
69
  } catch (err) {
65
70
  await client.end().catch(() => undefined);
66
- throw wrap(err);
71
+ const wrapped = wrap(err);
72
+ // A refused token is not reused: the next connect mints another.
73
+ if (endpoint.token && (wrapped.code === "28P01" || wrapped.code === "28000")) endpoint.token.invalidate();
74
+ throw wrapped;
67
75
  }
68
76
  let ended = false;
69
77
  return {
@@ -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(
package/src/serializer.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * The order is the dependency order of the references: a view after the
14
14
  * tables it reads, a materialized view after its target, a table after its
15
- * database. Ties break by export name, so the output is the same however the
15
+ * database. An object read from a `.sql` file (`./sql-files.ts`) that a
16
+ * template names in its text counts as referenced too. Ties break by export name, so the output is the same however the
16
17
  * files were discovered. A reference cycle is an error naming the cycle.
17
18
  *
18
19
  * One build holds one dialect: the dialect is the document's, and a project
@@ -35,6 +36,7 @@ import { isClickHouseObject, type ClickHouseObject } from "./clickhouse/entities
35
36
  import { isPostgresObject, type PostgresObject } from "./postgres/entities";
36
37
  import { POSTGRES_LATEST_MAJOR } from "./spec/postgres-pin";
37
38
  import { applyOrder, lineageJson, referenceName, type LineageEdge } from "./core/references";
39
+ import { sqlFileReferences } from "./sql-files";
38
40
 
39
41
  export { applyOrder } from "./core/references";
40
42
 
@@ -44,7 +46,7 @@ export const CLICKHOUSE_DDL_FILE = "clickhouse.sql";
44
46
  /** The Postgres statements' file, beside the primary output. */
45
47
  export const POSTGRES_DDL_FILE = "postgres.sql";
46
48
 
47
- function objectJson(name: string, obj: ClickHouseObject, names: Map<Declarable, string>): Record<string, unknown> {
49
+ function objectJson(name: string, obj: ClickHouseObject, names: Map<Declarable, string>, extra: readonly string[] = []): Record<string, unknown> {
48
50
  const props = { ...(obj.props as Record<string, unknown>) };
49
51
  delete props.source;
50
52
  const ddl = props.ddl;
@@ -54,7 +56,7 @@ function objectJson(name: string, obj: ClickHouseObject, names: Map<Declarable,
54
56
  if (props.lineage) {
55
57
  props.lineage = lineageJson(props.lineage as LineageEdge[], names);
56
58
  }
57
- const dependsOn = [...new Set(obj.dependsOn.map((r) => referenceName(r, names)).filter((n) => n !== undefined))];
59
+ const dependsOn = [...new Set([...obj.dependsOn.map((r) => referenceName(r, names)).filter((n) => n !== undefined), ...extra])];
58
60
  return JSON.parse(
59
61
  JSON.stringify({ export: name, type: obj.entityType, sqlName: obj.sqlName, ...props, dependsOn, ddl }),
60
62
  ) as Record<string, unknown>;
@@ -68,12 +70,12 @@ function postgresJsonValue(v: unknown, names: Map<Declarable, string>): unknown
68
70
  return v;
69
71
  }
70
72
 
71
- function postgresObjectJson(name: string, obj: PostgresObject, names: Map<Declarable, string>): Record<string, unknown> {
73
+ function postgresObjectJson(name: string, obj: PostgresObject, names: Map<Declarable, string>, extra: readonly string[] = []): Record<string, unknown> {
72
74
  const props = { ...(obj.props as Record<string, unknown>) };
73
75
  delete props.source;
74
76
  const ddl = props.ddl;
75
77
  delete props.ddl;
76
- const dependsOn = [...new Set(obj.dependsOn.map((r) => referenceName(r, names)).filter((n) => n !== undefined))];
78
+ const dependsOn = [...new Set([...obj.dependsOn.map((r) => referenceName(r, names)).filter((n) => n !== undefined), ...extra])];
77
79
  return JSON.parse(
78
80
  JSON.stringify({ export: name, type: obj.entityType, sqlName: obj.sqlName, ...(postgresJsonValue(props, names) as object), dependsOn, ddl }),
79
81
  ) as Record<string, unknown>;
@@ -92,12 +94,14 @@ function serializePostgres(
92
94
  ): SerializerResult {
93
95
  const names = new Map<Declarable, string>();
94
96
  for (const [name, entity] of entities) names.set(entity, name);
95
- const order = applyOrder(objects, names);
97
+ // A template names an object read from a `.sql` file as text: that is an edge too.
98
+ const extra = sqlFileReferences("postgres", objects);
99
+ const order = applyOrder(objects, names, extra);
96
100
  const doc = {
97
101
  dialect: "postgres",
98
102
  postgresMajor: targetMajor(config),
99
103
  applyOrder: order,
100
- objects: order.map((n) => postgresObjectJson(n, objects.get(n)!, names)),
104
+ objects: order.map((n) => postgresObjectJson(n, objects.get(n)!, names, extra.get(n))),
101
105
  };
102
106
  const ddl = order.map((n) => `${(objects.get(n)!.props as { ddl: string }).ddl};`).join("\n\n");
103
107
  return {
@@ -130,11 +134,12 @@ export const sqlSerializer: Serializer = {
130
134
  const names = new Map<Declarable, string>();
131
135
  for (const [name, entity] of entities) names.set(entity, name);
132
136
 
133
- const order = applyOrder(objects, names);
137
+ const extra = sqlFileReferences("clickhouse", objects);
138
+ const order = applyOrder(objects, names, extra);
134
139
  const doc = {
135
140
  dialect: "clickhouse",
136
141
  applyOrder: order,
137
- objects: order.map((n) => objectJson(n, objects.get(n)!, names)),
142
+ objects: order.map((n) => objectJson(n, objects.get(n)!, names, extra.get(n))),
138
143
  };
139
144
  const ddl = order
140
145
  .map((n) => `${(objects.get(n)!.props as { ddl: string }).ddl};`)
@@ -83,9 +83,9 @@ Every rule has an id in the SQLPG2xx range, names its class and cites the Postgr
83
83
  | column type to a binary-coercible type (longer varchar, text, wider numeric) | SQLPG206 | metadata | indexes on the column may still be rebuilt |
84
84
  | column type with a rewrite (integer to bigint, a shorter varchar) | SQLPG207 | rewrite | |
85
85
  | column type across kinds (text to integer) | SQLPG208 | expand | needs USING and breaks readers |
86
- | set NOT NULL | SQLPG210 | rewrite | a valid `CHECK (col IS NOT NULL)` already in place makes it metadata |
87
- | add a CHECK | SQLPG218 | rewrite | declare it `NOT VALID`, then validate |
88
- | add a foreign key | SQLPG219 | validate | `SHARE ROW EXCLUSIVE` on both tables for the scan; `NOT VALID` first avoids it |
86
+ | set NOT NULL | SQLPG210 | validate | made as a `NOT VALID` check, `VALIDATE`, `SET NOT NULL`, the check dropped; a valid `CHECK (col IS NOT NULL)` already in place makes it metadata |
87
+ | add a CHECK | SQLPG218 | validate | made as `ADD CONSTRAINT ... NOT VALID`, then `VALIDATE` in its own transaction; writes go on during the scan |
88
+ | add a foreign key | SQLPG219 | validate | made as `ADD CONSTRAINT ... NOT VALID`, then `VALIDATE` in its own transaction; writes go on during the scan |
89
89
  | add a constraint `NOT VALID` | SQLPG217 | metadata | checks new rows only |
90
90
  | validate a constraint | SQLPG220 | validate | `SHARE UPDATE EXCLUSIVE`, so reads and writes go on |
91
91
  | add a primary key or unique constraint | SQLPG221 | concurrently | build the unique index CONCURRENTLY, then `ADD CONSTRAINT ... USING INDEX` |
@@ -26,7 +26,7 @@ export const { op } = ClickHouseRebuildOp({
26
26
  });
27
27
  ```
28
28
 
29
- Options: `name`, `env`, `table`, `dualWrite` (required); `output` (default `dist/schema.json`), `path`, `build` (default true runs `chant build` first), `retain` (default `7d`), `gate` (the swap gate: `gate`, `timeout`, `description`, `approval`), `dropGate`, `writesGate` (app mode), `backfillTimeout` (default `6h`), `mutationTimeout` (default `10m`), `replicaTimeout` (default `2m`, Replicated databases only), `stack`, `ownershipEnv`.
29
+ Options: `name`, `env`, `table`, `dualWrite` (required); `output` (default `dist/schema.json`), `path`, `build` (default true runs `chant build` first), `retain` (default `7d`), `gate` (the swap gate: `gate`, `timeout`, `description`, `approval`), `dropGate`, `writesGate` (app mode), `backfillTimeout` (default `6h`), `mutationTimeout` (default `10m`), `replicaTimeout` (default `2m`, Replicated databases only), `cutoverTimeout` (default `10m`, materialized-view mode only), `stack`, `ownershipEnv`.
30
30
 
31
31
  On a `cluster:<name>` topology with more than one shard, the backfill and the verification work per shard through the profile's server, with a receipt per shard and partition; each shard needs its own `{shard}` macro.
32
32
 
@@ -34,7 +34,7 @@ Run as one step of a change approved elsewhere: `gates: "outer"` leaves out the
34
34
 
35
35
  Pick the dual-write mode:
36
36
 
37
- - `{ mode: "materialized-view", cutoverColumn: "ts", cutoverDelay?: "1m" }` when the table has a time column rows arrive in order of. Writes keep flowing: a materialized view sends rows at or after the cut-over to the new table, the backfill copies the rows before it.
37
+ - `{ mode: "materialized-view", cutoverColumn: "ts", cutoverDelay?: "5s" }` when the table has a time column rows arrive in order of. Writes keep flowing: a materialized view sends rows at or after the cut-over to the new table, the backfill copies the rows before it. The backfill starts once the server's clock has passed the cut-over and every INSERT into the old table begun before it has finished, asynchronous inserts still in their buffer included; `cutoverTimeout` bounds the second wait. Set `cutoverDelay` longer than the batches of a writer that holds rows before inserting them.
38
38
  - `{ mode: "app" }` otherwise. A gate, `<name>-writes-stopped`, waits for someone to confirm the application stopped writing to the table; writes resume after the swap.
39
39
 
40
40
  ## Run it
@@ -57,6 +57,7 @@ The swap gate is bound to a digest of the plan and the verification (row counts
57
57
  - A run stopped during the backfill (Ctrl-C, a killed job) is not a failure. The next run resumes from the receipts in `chant_receipts.receipts` on the same server (in `<db>.__chant_receipts` on a Replicated database), and a partition whose copy was cut off before its receipt is cleared and copied again, so no row is copied twice.
58
58
  - A failed step (a failed copy after its retries, a verification mismatch, a refused declaration) runs onFailure: the new table `<t>__chant_new` and the dual-write view `<t>__chant_dual` are dropped, and the next run starts from a new table. With `onFailure: "keep"` they stay, and the next run resumes; drop them by hand to start over. The original table is untouched until the swap.
59
59
  - A verification mismatch in materialized-view mode usually means rows arrived with a time before the cut-over later than `cutoverDelay`. Raise the delay and run again.
60
+ - A backfill that stops naming writes "begun before the cut-over" waited `cutoverTimeout` for INSERTs into the old table that were still running. Let them finish, or stop them, and run again.
60
61
  - The Op refuses a database that is neither Atomic nor Replicated (`EXCHANGE TABLES` needs one of them), a table whose change is not a rebuild (use the applier), and an object already under one of its working names that is not its own. In a Replicated database it also refuses a table, or a declaration, whose engine is not `Replicated*MergeTree`: a plain MergeTree keeps different rows on each replica and one replica's copy would lose the others'.
61
62
 
62
63
  ## On a Replicated database
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  skill: chant-sql
3
- description: Declare ClickHouse databases, tables, views and materialized views as SQL-shaped tagged templates, with references, lineage and the checks that run on them
3
+ description: Declare ClickHouse databases, tables, views, materialized views, dictionaries and functions as SQL-shaped tagged templates, with references, lineage and the checks that run on them
4
4
  user-invocable: true
5
5
  ---
6
6
  # Declaring a ClickHouse schema with chant
@@ -33,6 +33,12 @@ export const byKind = view`
33
33
 
34
34
  The export name (`events`) is the object's identity in chant. The name in the SQL (`analytics.events`) is its name in the database. A materialized view is a `view` template holding `CREATE MATERIALIZED VIEW`.
35
35
 
36
+ A dictionary is a `dictionary` template holding `CREATE DICTIONARY`, with its attributes, `PRIMARY KEY`, `SOURCE`, `LAYOUT` and `LIFETIME`. Its attributes are columns (`${ratesDict.columns.rate}`). Any change but its comment is SQLCH245, and the applier replaces it with `CREATE OR REPLACE DICTIONARY`. Keep passwords out of `SOURCE`: name a server-side named collection instead.
37
+
38
+ A SQL user-defined function is a `func` template holding `CREATE FUNCTION name AS (x) -> expr`, with no database in its name. A changed expression is SQLCH260 (`CREATE OR REPLACE FUNCTION`). It carries no ownership marker, so chant never drops one.
39
+
40
+ Users, roles, row policies and grants are `user`, `role`, `policy` and `grant` templates. Never write a password: `IDENTIFIED BY` is refused, a user declared without `IDENTIFIED` is created by the environment with its password, and chant manages the rest of it. Grants are compared per grantee: every `grant` naming a grantee adds up to its complete list, and anything else it holds is revoked (SQLCH274). None of these is ever dropped by chant.
41
+
36
42
  ## Interpolations
37
43
 
38
44
  | Value | Means | Renders as |