@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
@@ -11,7 +11,10 @@
11
11
  * (`nextval('app.invoice_seq'::regclass)` is `nextval(${invoiceSeq})`);
12
12
  * - the schema part of the object's own name, wherever the name appears,
13
13
  * becomes the schema's export (`CREATE TABLE ${appSchema}.users`);
14
- * - an extension's `WITH SCHEMA` names the schema's export.
14
+ * - an extension's `WITH SCHEMA`, a grant's `ON SCHEMA` and default
15
+ * privileges' `IN SCHEMA` name the schema's export;
16
+ * - a grant's object becomes the object's export (`GRANT SELECT ON TABLE
17
+ * ${orders} TO reader`), a policy's table its table's.
15
18
  *
16
19
  * Columns stay text, as they do for ClickHouse (#3236): which relation a bare
17
20
  * column belongs to is the server's to resolve. One file, because the
@@ -21,16 +24,24 @@
21
24
  import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
22
25
  import type { TemplateIR } from "@intentius/chant/import/parser";
23
26
  import { isTrivia, tokenizeText, type Token } from "../tokens";
24
- import { identValue } from "../parser";
27
+ import { identValue, parseStatements, type NameNode, type StatementNode } from "../parser";
25
28
  import { POSTGRES_ENTITY_TYPES } from "../entity-types";
26
29
 
27
- interface Item {
30
+ /** One object a template is written for, or (with no `ddl` read) one a template may reference. */
31
+ export interface PgTemplateItem {
28
32
  exportName: string;
29
33
  type: string;
30
34
  schema?: string;
31
35
  name: string;
32
36
  ddl: string;
33
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
+ }
34
45
 
35
46
  const TAG: Record<string, string> = {
36
47
  [POSTGRES_ENTITY_TYPES.schema]: "schema",
@@ -45,39 +56,85 @@ const TAG: Record<string, string> = {
45
56
  [POSTGRES_ENTITY_TYPES.function]: "func",
46
57
  [POSTGRES_ENTITY_TYPES.procedure]: "procedure",
47
58
  [POSTGRES_ENTITY_TYPES.trigger]: "trigger",
59
+ [POSTGRES_ENTITY_TYPES.policy]: "policy",
60
+ [POSTGRES_ENTITY_TYPES.role]: "role",
61
+ [POSTGRES_ENTITY_TYPES.grant]: "grant",
62
+ [POSTGRES_ENTITY_TYPES.defaultPrivileges]: "grant",
48
63
  };
49
64
 
65
+ /** The tag each entity type is declared with (`table`, `func`, ...). */
66
+ export const POSTGRES_TAG_OF: Readonly<Record<string, string>> = TAG;
67
+
50
68
  /** Text that sits inside a template literal: a backquote and `${` escaped. */
51
69
  const templateSafe = (s: string) => s.replace(/\\(?=`|\$\{)/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
52
70
 
53
71
  const isName = (t: Token | undefined) => t !== undefined && (t.kind === "ident" || t.kind === "qident");
54
72
 
55
- /** `'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. */
56
74
  function regclassName(t: Token): string | undefined {
57
75
  if (t.kind !== "string" || !/^'.*'$/s.test(t.text)) return undefined;
58
76
  const inner = t.text.slice(1, -1).replace(/''/g, "'");
59
77
  const pieces = inner.match(/"(?:[^"]|"")*"|[^.]+/g)?.map((p) => identValue(p)) ?? [];
60
- 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;
61
79
  }
62
80
 
63
- 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 {
64
117
  const tokens = tokenizeText(item.ddl, 0);
65
118
  const sig: number[] = [];
66
119
  tokens.forEach((t, i) => {
67
120
  if (!isTrivia(t)) sig.push(i);
68
121
  });
69
- 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);
70
125
  for (let k = 0; k < sig.length; k++) {
71
126
  const a = tokens[sig[k]!]!;
72
127
  const prev = tokens[sig[k - 1] ?? -1];
73
128
  if (prev?.kind === "punct" && prev.text === ".") continue;
74
129
  // A regclass literal: nextval('app.seq'::regclass) or 'app.t'::regclass.
75
130
  if (a.kind === "string" && tokens[sig[k + 1] ?? -1]?.text === "::" && tokens[sig[k + 2] ?? -1]?.text.toLowerCase() === "regclass") {
76
- const target = byQualified.get(regclassName(a) ?? "");
77
- 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)) {
78
135
  const fn = tokens[sig[k - 2] ?? -1];
79
136
  const inCall = prev?.text === "(" && fn?.kind === "ident" && /^(nextval|currval|setval)$/i.test(fn.text);
80
- 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 });
81
138
  k += inCall ? 2 : 0;
82
139
  continue;
83
140
  }
@@ -85,39 +142,89 @@ function templateBody(item: Item, byQualified: Map<string, Item>, schemaExport:
85
142
  const dot = tokens[sig[k + 1] ?? -1];
86
143
  const b = tokens[sig[k + 2] ?? -1];
87
144
  if (!isName(a) || dot?.kind !== "punct" || dot.text !== "." || !isName(b)) {
88
- // An extension's `WITH SCHEMA app`.
89
- if (item.type === POSTGRES_ENTITY_TYPES.extension && isName(a) && prev?.kind === "ident" && prev.text.toUpperCase() === "SCHEMA") {
145
+ // An extension's `WITH SCHEMA app`, a grant's `ON SCHEMA app`, default privileges' `IN SCHEMA app`.
146
+ const bySchemaWord = item.type === POSTGRES_ENTITY_TYPES.extension || item.type === POSTGRES_ENTITY_TYPES.grant || item.type === POSTGRES_ENTITY_TYPES.defaultPrivileges;
147
+ if (bySchemaWord && isName(a) && prev?.kind === "ident" && prev.text.toUpperCase() === "SCHEMA") {
90
148
  const s = schemaExport.get(identValue(a));
91
- 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;
92
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 });
93
156
  continue;
94
157
  }
95
158
  const schema = identValue(a);
96
159
  const name = identValue(b);
97
160
  const self = schema === item.schema && name === item.name;
98
161
  const target = byQualified.get(`${schema}.${name}`);
99
- if (!self && target && target !== item) {
100
- 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 });
101
164
  k += 2;
102
165
  } else if (self && schemaExport.has(schema)) {
103
- 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)! });
104
167
  k += 2;
105
168
  }
106
169
  }
107
- let body = "";
170
+ const parts: string[] = [""];
108
171
  const refs: string[] = [];
109
172
  for (let i = 0; i < tokens.length; ) {
110
173
  const r = replace.get(i);
111
174
  if (r) {
112
- body += r.text;
113
175
  refs.push(r.ref);
176
+ parts.push("");
114
177
  i = r.to;
115
178
  continue;
116
179
  }
117
- body += templateSafe(tokens[i]!.text);
180
+ parts[parts.length - 1] += templateSafe(tokens[i]!.text);
118
181
  i++;
119
182
  }
120
- 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 };
121
228
  }
122
229
 
123
230
  const indent = (body: string) =>
@@ -143,37 +250,13 @@ export class PostgresGenerator implements TypeScriptGenerator {
143
250
  }));
144
251
  if (items.length === 0) return [];
145
252
 
146
- const byQualified = new Map<string, Item>();
147
- const schemaExport = new Map<string, string>();
148
- for (const it of items) {
149
- if (it.type === POSTGRES_ENTITY_TYPES.schema) schemaExport.set(it.name, it.exportName);
150
- // An index or a trigger is never referenced by name in another object's DDL.
151
- else if (it.schema && it.type !== POSTGRES_ENTITY_TYPES.index && it.type !== POSTGRES_ENTITY_TYPES.trigger) byQualified.set(`${it.schema}.${it.name}`, it);
152
- }
153
-
154
- const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, schemaExport)]));
155
- const order: Item[] = [];
156
- const done = new Set<string>();
157
- const visiting = new Set<string>();
158
- const byExport = new Map(items.map((it) => [it.exportName, it]));
159
- const visit = (it: Item) => {
160
- if (done.has(it.exportName) || visiting.has(it.exportName)) return;
161
- visiting.add(it.exportName);
162
- for (const ref of bodies.get(it.exportName)!.refs) {
163
- const dep = byExport.get(ref);
164
- if (dep) visit(dep);
165
- }
166
- visiting.delete(it.exportName);
167
- done.add(it.exportName);
168
- order.push(it);
169
- };
170
- for (const it of items) visit(it);
253
+ const { order, bodies } = postgresTemplates(items);
171
254
 
172
255
  const tags = [...new Set(order.map((it) => TAG[it.type]!))].sort();
173
256
  const lines: string[] = [];
174
257
  if (this.header) lines.push(...this.header.split("\n").map((l) => `// ${l}`), "");
175
258
  lines.push(`import { ${tags.join(", ")} } from "@intentius/chant-lexicon-sql/postgres";`, "");
176
- 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)!))}\`;`, "");
177
260
  return [{ path: "schema.ts", content: `${lines.join("\n").trimEnd()}\n` }];
178
261
  }
179
262
  }
@@ -11,6 +11,12 @@
11
11
  * a declaration of them would have chant fight it.
12
12
  * - `verbatim` changes nothing yet: the printers already leave out what the
13
13
  * server would add by itself.
14
+ * - Where the profile manages access (`sql.profiles.<env>.access`), the
15
+ * policies and each table's row-level security come with the objects, and
16
+ * the privileges on them and the default privileges are written as `grant`
17
+ * declarations: the GRANT and REVOKE statements that take a new object's
18
+ * privileges to what the server holds. Roles are the environment's and are
19
+ * not imported.
14
20
  */
15
21
 
16
22
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
@@ -18,6 +24,7 @@ import { bindPostgres, type BindOptions } from "../live/bind";
18
24
  import { markProviderOwned, readLiveSchema } from "../live/catalog";
19
25
  import { objectsToIR, type ImportedPgObject } from "./ir";
20
26
  import { isChantManaged } from "../../core/ownership";
27
+ import { importedAccess } from "../access/import";
21
28
 
22
29
  export interface ExportOptions extends Omit<BindOptions, "environment"> {
23
30
  environment: string;
@@ -31,8 +38,11 @@ export interface ExportOptions extends Omit<BindOptions, "environment"> {
31
38
  export async function exportResources(options: ExportOptions): Promise<ExportedTemplate> {
32
39
  const { target, client } = await bindPostgres(options);
33
40
  let live;
41
+ let access: ImportedPgObject[] = [];
34
42
  try {
35
- live = markProviderOwned(await readLiveSchema(client, { schemas: target.schemas }), target.provider);
43
+ live = markProviderOwned(await readLiveSchema(client, { schemas: target.schemas, access: target.access === true }), target.provider);
44
+ // Where the profile manages access, the privileges on what is imported, and the default privileges, as grant declarations.
45
+ if (target.access === true && !options.selector) access = await importedAccess(client, live.filter((o) => !o.foreign && !o.unsupported && (!options.owned || isChantManaged(o.comment))), target.schemas);
36
46
  } finally {
37
47
  await client.end();
38
48
  }
@@ -53,5 +63,5 @@ export async function exportResources(options: ExportOptions): Promise<ExportedT
53
63
  }
54
64
  objects.push({ type: o.type, ...(o.schema ? { schema: o.schema } : {}), name: o.name, ddl: o.statement });
55
65
  }
56
- return objectsToIR(objects, warnings) as ExportedTemplate;
66
+ return objectsToIR([...objects, ...access], warnings) as ExportedTemplate;
57
67
  }
@@ -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;
@@ -21,6 +22,8 @@ export interface PostgresTarget {
21
22
  provider?: PostgresProvider;
22
23
  /** The apply's timeouts the profile sets (`lockTimeoutMs`, `statementTimeoutMs`, `scanTimeoutMs`); the applier's defaults otherwise. */
23
24
  timeouts?: { lockTimeoutMs?: number; statementTimeoutMs?: number; scanTimeoutMs?: number };
25
+ /** Whether chant manages access here (`sql.profiles.<env>.access`): policies, row-level security, the declared roles, grants and default privileges. */
26
+ access?: boolean;
24
27
  }
25
28
 
26
29
  export interface UnresolvedTarget {
@@ -56,12 +59,21 @@ export function resolvePostgresTarget(input: {
56
59
  if (profile && isPostgresUrl(profile.url)) {
57
60
  const source = `sql.profiles.${input.environment}`;
58
61
  const endpoint: PostgresEndpoint = { url: profile.url };
59
- for (const key of ["user", "password"] as const) {
60
- const ref = profile[key];
61
- if (!ref) continue;
62
- const value = env[ref.env];
63
- if (value === undefined) return { reason: "no-credentials", detail: `${source}.${key} names ${ref.env}, which is not set` };
64
- 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` });
65
77
  }
66
78
  const timeouts = Object.fromEntries(
67
79
  (["lockTimeoutMs", "statementTimeoutMs", "scanTimeoutMs"] as const).filter((k) => profile[k] !== undefined).map((k) => [k, profile[k]]),
@@ -72,6 +84,7 @@ export function resolvePostgresTarget(input: {
72
84
  ...(profile.schemas ? { schemas: profile.schemas } : {}),
73
85
  defaultSchema: profile.defaultSchema ?? "public",
74
86
  ...(Object.keys(timeouts).length > 0 ? { timeouts } : {}),
87
+ ...(profile.access === true ? { access: true } : {}),
75
88
  });
76
89
  }
77
90
  const url = env.POSTGRES_URL;
@@ -139,6 +152,7 @@ const CREDENTIAL_STATES = new Set(["28000", "28P01", "42501"]);
139
152
  /** What a failed bind or read means, in the observation vocabulary. */
140
153
  export function classifyPostgresFailure(err: unknown): { reason: UnobservedReason; detail: string } {
141
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 };
142
156
  if (err instanceof PostgresQueryError && err.code && CREDENTIAL_STATES.has(err.code)) {
143
157
  return { reason: "no-credentials", detail: `${err.message} (the server refused the credentials, SQLSTATE ${err.code})` };
144
158
  }
@@ -93,6 +93,15 @@ export interface LivePgObject {
93
93
  export interface SchemaScope {
94
94
  /** The schemas in scope; undefined is every schema but the server's own and `public`'s own objects included. */
95
95
  schemas?: readonly string[];
96
+ /**
97
+ * Whether access is read (`sql.profiles.<env>.access`): the policies on the
98
+ * tables in scope, each table's row-level security, and the roles named in
99
+ * `roles`. Off, none of it is read, so a project that manages access
100
+ * elsewhere sees none of it.
101
+ */
102
+ access?: boolean;
103
+ /** The roles to read, when access is: the declared ones. A role is the cluster's, never read otherwise. */
104
+ roles?: readonly string[];
96
105
  }
97
106
 
98
107
  type Row = Record<string, unknown>;
@@ -193,8 +202,8 @@ const seqParams = (r: Row): SequenceParams => ({
193
202
  /**
194
203
  * Every object in scope, in a stable order: schemas, extensions, enums,
195
204
  * domains, sequences, tables, views and materialized views, indexes,
196
- * functions and procedures, triggers; each kind by schema then name, in byte
197
- * order.
205
+ * functions and procedures, triggers, and where access is read, policies and
206
+ * the declared roles; each kind by schema then name, in byte order.
198
207
  */
199
208
  export async function readLiveSchema(client: PostgresClient, scope: SchemaScope = {}): Promise<LivePgObject[]> {
200
209
  const out: LivePgObject[] = [];
@@ -326,6 +335,7 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
326
335
  FROM pg_catalog.pg_inherits i JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace
327
336
  WHERE i.inhrelid = c.oid ORDER BY i.inhseqno) AS parents,
328
337
  c.reloptions AS reloptions, (SELECT a.amname FROM pg_catalog.pg_am a WHERE a.oid = c.relam) AS am,
338
+ c.relrowsecurity AS rowsecurity, c.relforcerowsecurity AS forcerowsecurity,
329
339
  (SELECT ts.spcname FROM pg_catalog.pg_tablespace ts WHERE ts.oid = c.reltablespace) AS tablespace,
330
340
  pg_catalog.obj_description(c.oid, 'pg_class') AS comment
331
341
  FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
@@ -492,7 +502,13 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
492
502
  oid: rel,
493
503
  ...(str(t.comment) ? { comment: str(t.comment) } : {}),
494
504
  ...(FOREIGN_TABLES[String(t.name)] ? { foreign: FOREIGN_TABLES[String(t.name)] } : {}),
495
- statement: [create, ...commentOn(`TABLE ${q}`, t.comment), ...commentLines].join(";\n"),
505
+ statement: [
506
+ create,
507
+ ...(scope.access && t.rowsecurity === true ? [`ALTER TABLE ${q} ENABLE ROW LEVEL SECURITY`] : []),
508
+ ...(scope.access && t.forcerowsecurity === true ? [`ALTER TABLE ${q} FORCE ROW LEVEL SECURITY`] : []),
509
+ ...commentOn(`TABLE ${q}`, t.comment),
510
+ ...commentLines,
511
+ ].join(";\n"),
496
512
  });
497
513
  }
498
514
 
@@ -630,6 +646,66 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
630
646
  }
631
647
  }
632
648
 
649
+ if (scope.access) {
650
+ // Policies, on the tables in scope.
651
+ const policies = await client.query<Row>(
652
+ `SELECT p.oid::text AS oid, n.nspname AS schema, c.relname AS table_name, p.polname AS name, p.polpermissive AS permissive, p.polcmd AS cmd,
653
+ ARRAY(SELECT CASE WHEN r = 0 THEN 'PUBLIC' ELSE pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(r)) END FROM pg_catalog.unnest(p.polroles) r ORDER BY 1) AS roles,
654
+ pg_catalog.pg_get_expr(p.polqual, p.polrelid, true) AS qual, pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid, true) AS withcheck,
655
+ pg_catalog.obj_description(p.oid, 'pg_policy') AS comment
656
+ FROM pg_catalog.pg_policy p JOIN pg_catalog.pg_class c ON c.oid = p.polrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
657
+ WHERE ${s.sql} AND ${notExtensionOwned("pg_class", "c.oid")}
658
+ ORDER BY n.nspname ${C}, c.relname ${C}, p.polname ${C}`,
659
+ s.params,
660
+ );
661
+ const COMMAND: Record<string, string> = { r: "SELECT", a: "INSERT", w: "UPDATE", d: "DELETE" };
662
+ for (const r of policies) {
663
+ const table = qname(String(r.schema), String(r.table_name));
664
+ const roles = (r.roles as string[] | null) ?? [];
665
+ const parts = [`CREATE POLICY ${quoteIdent(String(r.name))} ON ${table}`];
666
+ if (r.permissive === false) parts.push("AS RESTRICTIVE");
667
+ if (COMMAND[String(r.cmd)]) parts.push(`FOR ${COMMAND[String(r.cmd)]}`);
668
+ if (!(roles.length === 1 && roles[0] === "PUBLIC")) parts.push(`TO ${roles.join(", ")}`);
669
+ if (str(r.qual)) parts.push(`USING (${r.qual})`);
670
+ if (str(r.withcheck)) parts.push(`WITH CHECK (${r.withcheck})`);
671
+ out.push({
672
+ type: POSTGRES_ENTITY_TYPES.policy,
673
+ schema: String(r.schema),
674
+ name: String(r.name),
675
+ oid: String(r.oid),
676
+ signature: ` ON ${table}`,
677
+ ...(str(r.comment) ? { comment: str(r.comment) } : {}),
678
+ statement: [parts.join(" "), ...commentOn(`POLICY ${quoteIdent(String(r.name))} ON ${table}`, r.comment)].join(";\n"),
679
+ });
680
+ }
681
+
682
+ // The declared roles, by name: a role is the cluster's, and the ones the declarations do not name are the environment's.
683
+ if (scope.roles && scope.roles.length > 0) {
684
+ const roles = await client.query<Row>(
685
+ `SELECT r.oid::text AS oid, r.rolname AS name, r.rolsuper AS superuser, r.rolinherit AS inherit, r.rolcreaterole AS createrole, r.rolcreatedb AS createdb,
686
+ r.rolcanlogin AS login, r.rolreplication AS replication, r.rolbypassrls AS bypassrls, r.rolconnlimit AS connlimit,
687
+ pg_catalog.shobj_description(r.oid, 'pg_authid') AS comment
688
+ FROM pg_catalog.pg_roles r WHERE r.rolname = ANY($1::text[]) ORDER BY r.rolname ${C}`,
689
+ [[...scope.roles]],
690
+ );
691
+ for (const r of roles) {
692
+ const words: string[] = [];
693
+ for (const [k, dflt] of [["superuser", false], ["createdb", false], ["createrole", false], ["inherit", true], ["login", false], ["replication", false], ["bypassrls", false]] as const) {
694
+ if ((r[k] === true) !== dflt) words.push(r[k] === true ? k.toUpperCase() : `NO${k.toUpperCase()}`);
695
+ }
696
+ if (Number(r.connlimit) !== -1) words.push(`CONNECTION LIMIT ${Number(r.connlimit)}`);
697
+ const name = quoteIdent(String(r.name));
698
+ out.push({
699
+ type: POSTGRES_ENTITY_TYPES.role,
700
+ name: String(r.name),
701
+ oid: String(r.oid),
702
+ ...(str(r.comment) ? { comment: str(r.comment) } : {}),
703
+ statement: [`CREATE ROLE ${name}${words.length > 0 ? ` WITH ${words.join(" ")}` : ""}`, ...commentOn(`ROLE ${name}`, r.comment)].join(";\n"),
704
+ });
705
+ }
706
+ }
707
+ }
708
+
633
709
  return out;
634
710
  }
635
711
 
@@ -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 {
@@ -41,15 +41,30 @@ interface Bound {
41
41
  */
42
42
  export function declaredAddress(entity: { type: string; props: Record<string, unknown> }, defaultSchema: string): { schema?: string; name: string; signature?: string } {
43
43
  const name = String(entity.props.name ?? "");
44
- if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension) return { name };
44
+ if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension || entity.type === POSTGRES_ENTITY_TYPES.role) return { name };
45
45
  const schema = typeof entity.props.schema === "string" ? entity.props.schema : identValue(defaultSchema);
46
- if (entity.type === POSTGRES_ENTITY_TYPES.function || entity.type === POSTGRES_ENTITY_TYPES.procedure || entity.type === POSTGRES_ENTITY_TYPES.trigger) {
46
+ if (entity.type === POSTGRES_ENTITY_TYPES.function || entity.type === POSTGRES_ENTITY_TYPES.procedure || entity.type === POSTGRES_ENTITY_TYPES.trigger || entity.type === POSTGRES_ENTITY_TYPES.policy) {
47
47
  const signature = canonicalPgObject(entity.type, entity.props, defaultSchema).signature;
48
48
  return { schema, name, ...(signature !== undefined ? { signature } : {}) };
49
49
  }
50
50
  return { schema, name };
51
51
  }
52
52
 
53
+ /**
54
+ * Why an access declaration is not observed one by one: a grant or default
55
+ * privileges, which `chant sql plan` compares as the access they add up to;
56
+ * a policy or role where the profile does not manage access.
57
+ */
58
+ export function accessUnobserved(type: string, managed: boolean): { reason: "unsupported-kind" | "filtered"; detail: string } | undefined {
59
+ if (type === POSTGRES_ENTITY_TYPES.grant || type === POSTGRES_ENTITY_TYPES.defaultPrivileges) {
60
+ return { reason: "unsupported-kind", detail: "a grant is compared as the privileges the declarations add up to, by chant sql plan and the applier, not one statement at a time" };
61
+ }
62
+ if (!managed && (type === POSTGRES_ENTITY_TYPES.policy || type === POSTGRES_ENTITY_TYPES.role)) {
63
+ return { reason: "filtered", detail: "access is not managed in this environment (sql.profiles.<env>.access)" };
64
+ }
65
+ return undefined;
66
+ }
67
+
53
68
  /** A schema scope for the catalog read: the profile's schemas, else every schema the declarations name and the default one. */
54
69
  export function scopeFor(target: PostgresTarget, entities: ReadonlyArray<{ type: string; props: Record<string, unknown> }>): string[] | undefined {
55
70
  if (target.schemas) return target.schemas;
@@ -68,7 +83,14 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
68
83
  async bind() {
69
84
  const { target, client } = await bindPostgres(options);
70
85
  try {
71
- const live = markProviderOwned(await readLiveSchema(client, { schemas: scopeFor(target, options.declared.filter((d) => d.type.startsWith("Postgres::"))) }), target.provider);
86
+ const live = markProviderOwned(
87
+ await readLiveSchema(client, {
88
+ schemas: scopeFor(target, options.declared.filter((d) => d.type.startsWith("Postgres::"))),
89
+ access: target.access === true,
90
+ roles: options.declared.filter((d) => d.type === POSTGRES_ENTITY_TYPES.role).map((d) => String(d.props.name)),
91
+ }),
92
+ target.provider,
93
+ );
72
94
  return { target, byKey: new Map(live.map((o) => [liveKey(o.type, o.schema, o.name, o.signature), o])) };
73
95
  } finally {
74
96
  await client.end();
@@ -77,6 +99,8 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
77
99
  classifyBindFailure: (err) => classifyPostgresFailure(err),
78
100
  async read({ target, byKey }, entity): Promise<EntityObservation> {
79
101
  if (!entity.type.startsWith("Postgres::")) return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
102
+ const why = accessUnobserved(entity.type, target.access === true);
103
+ if (why) return { unobserved: why };
80
104
  const { schema, name, signature } = declaredAddress(entity, target.defaultSchema);
81
105
  const queried = `${redactUrl(target.endpoint.url)} ${schema ? `${schema}.` : ""}${name}${signature ?? ""}`;
82
106
  const live = byKey.get(liveKey(entity.type, schema, name, signature));
@@ -72,7 +72,30 @@ describe("PostgresMigrationOp", () => {
72
72
  expect(Object.keys(g.approval.context)).toEqual(["verifiedRows", "mismatched"]);
73
73
  });
74
74
 
75
+ test('gates: "outer" leaves out both approval gates and the Contract phase, and keeps the verification (#3687)', () => {
76
+ const props = propsOf({ ...BASE, gates: "outer" });
77
+ expect(props.phases.map((p) => p.name)).toEqual(["Build", "Plan", "Expand", "Dual write", "Backfill", "Carry over", "Verify", "Switch", "Retain"]);
78
+ expect(props.phases.flatMap((p) => p.steps).filter((s) => s.kind === "gate")).toEqual([]);
79
+ const fns = props.phases.flatMap((p) => p.steps).map((s) => (s as { fn?: string }).fn);
80
+ expect(fns.indexOf("postgresMigrationVerify")).toBeLessThan(fns.indexOf("postgresMigrationSwitch"));
81
+ expect(fns).not.toContain("postgresMigrationContract");
82
+ expect(props.onFailure?.map((p) => p.steps.map((s) => (s as { fn?: string }).fn))).toEqual([["postgresMigrationCompensate"]]);
83
+ expect(props.overview).toContain("the caller's approval");
84
+ });
85
+
86
+ test('onFailure: "keep" has no onFailure and tells every step so (#3687)', () => {
87
+ const props = propsOf({ ...BASE, onFailure: "keep" });
88
+ expect(props.onFailure).toBeUndefined();
89
+ const steps = props.phases.flatMap((p) => p.steps).filter((s) => s.kind === "activity" && (s as { fn: string }).fn.startsWith("postgresMigration")) as Array<{ args: Record<string, unknown> }>;
90
+ expect(steps).toHaveLength(9);
91
+ for (const s of steps) expect(s.args.keepOnFailure).toBe(true);
92
+ expect(props.phases.map((p) => p.name)).toContain("Approve");
93
+ expect((propsOf(BASE).phases[1]!.steps[0] as { args: Record<string, unknown> }).args).not.toHaveProperty("keepOnFailure");
94
+ });
95
+
75
96
  test("bad configuration fails when the Op is built", () => {
97
+ expect(() => PostgresMigrationOp({ ...BASE, gates: "none" as never })).toThrow(/gates must be/);
98
+ expect(() => PostgresMigrationOp({ ...BASE, onFailure: "retry" as never })).toThrow(/onFailure must be/);
76
99
  expect(() => PostgresMigrationOp({ ...BASE, table: "app.users; DROP" })).toThrow(/table must be/);
77
100
  expect(() => PostgresMigrationOp({ ...BASE, column: "" })).toThrow(/column must be/);
78
101
  expect(() => PostgresMigrationOp({ ...BASE, batchSize: 0 })).toThrow(/batchSize/);
@@ -95,7 +118,7 @@ describe("PostgresMigrationOp", () => {
95
118
  ownershipEnv: "prod",
96
119
  build: false,
97
120
  };
98
- for (const config of [BASE, full, { ...BASE, replicationLag: false as const }]) {
121
+ for (const config of [BASE, full, { ...BASE, replicationLag: false as const }, { ...BASE, gates: "outer" as const, onFailure: "keep" as const }]) {
99
122
  const props = propsOf(config);
100
123
  expect(validateActivitySteps(props, merged)).toEqual([]);
101
124
  expect(validateStepOutputRefs(props, merged)).toEqual([]);