@intentius/chant-lexicon-sql 0.116.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/codegen/registry.d.ts.map +1 -1
  2. package/dist/config.d.ts +20 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/integrity.json +5 -5
  7. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  8. package/dist/lsp/postgres-template.d.ts.map +1 -1
  9. package/dist/manifest.json +1 -1
  10. package/dist/meta.json +20 -0
  11. package/dist/okf/index.md +4 -0
  12. package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
  13. package/dist/okf/types/PostgresGrant.md +9 -0
  14. package/dist/okf/types/PostgresPolicy.md +9 -0
  15. package/dist/okf/types/PostgresRole.md +9 -0
  16. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  17. package/dist/op/activity-contracts.d.ts +10 -0
  18. package/dist/op/activity-contracts.d.ts.map +1 -1
  19. package/dist/plugin.d.ts.map +1 -1
  20. package/dist/postgres/access/acl.d.ts +119 -0
  21. package/dist/postgres/access/acl.d.ts.map +1 -0
  22. package/dist/postgres/access/import.d.ts +13 -0
  23. package/dist/postgres/access/import.d.ts.map +1 -0
  24. package/dist/postgres/access/live.d.ts +24 -0
  25. package/dist/postgres/access/live.d.ts.map +1 -0
  26. package/dist/postgres/apply/apply.d.ts.map +1 -1
  27. package/dist/postgres/apply/statements.d.ts +3 -0
  28. package/dist/postgres/apply/statements.d.ts.map +1 -1
  29. package/dist/postgres/entities.d.ts +106 -4
  30. package/dist/postgres/entities.d.ts.map +1 -1
  31. package/dist/postgres/entity-types.d.ts +4 -0
  32. package/dist/postgres/entity-types.d.ts.map +1 -1
  33. package/dist/postgres/import/generator.d.ts +4 -1
  34. package/dist/postgres/import/generator.d.ts.map +1 -1
  35. package/dist/postgres/import/live-export.d.ts +6 -0
  36. package/dist/postgres/import/live-export.d.ts.map +1 -1
  37. package/dist/postgres/live/bind.d.ts +2 -0
  38. package/dist/postgres/live/bind.d.ts.map +1 -1
  39. package/dist/postgres/live/catalog.d.ts +11 -2
  40. package/dist/postgres/live/catalog.d.ts.map +1 -1
  41. package/dist/postgres/live/describe-resources.d.ts +9 -0
  42. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  43. package/dist/postgres/migrate/op.d.ts +29 -0
  44. package/dist/postgres/migrate/op.d.ts.map +1 -1
  45. package/dist/postgres/parser.d.ts +76 -3
  46. package/dist/postgres/parser.d.ts.map +1 -1
  47. package/dist/postgres/plan/commands.d.ts +12 -0
  48. package/dist/postgres/plan/commands.d.ts.map +1 -1
  49. package/dist/postgres/plan/deep.d.ts.map +1 -1
  50. package/dist/postgres/plan/diff.d.ts +10 -1
  51. package/dist/postgres/plan/diff.d.ts.map +1 -1
  52. package/dist/postgres/plan/normalize.d.ts +8 -1
  53. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  54. package/dist/postgres/plan/rules.d.ts +8 -0
  55. package/dist/postgres/plan/rules.d.ts.map +1 -1
  56. package/dist/postgres/plan/schema.d.ts +2 -1
  57. package/dist/postgres/plan/schema.d.ts.map +1 -1
  58. package/dist/postgres/plan/server-normalize.d.ts +4 -1
  59. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  60. package/dist/postgres.d.ts +1 -1
  61. package/dist/postgres.d.ts.map +1 -1
  62. package/dist/rules/postgres-templates.ts +2 -2
  63. package/dist/rules/sqlpg001.ts +7 -4
  64. package/dist/validate.d.ts.map +1 -1
  65. package/package.json +2 -2
  66. package/src/codegen/registry.ts +4 -0
  67. package/src/config.ts +10 -0
  68. package/src/generated/lexicon-sql.json +20 -0
  69. package/src/index.ts +1 -1
  70. package/src/lint/rules/postgres-templates.ts +2 -2
  71. package/src/lint/rules/sqlpg001.ts +7 -4
  72. package/src/lsp/postgres-template.ts +2 -0
  73. package/src/mcp/postgres.ts +1 -1
  74. package/src/migration-statements.ts +7 -0
  75. package/src/op/activities/postgres-migration.ts +27 -2
  76. package/src/op/activity-contracts.ts +1 -0
  77. package/src/plugin.ts +3 -0
  78. package/src/postgres/access/access.e2e.test.ts +191 -0
  79. package/src/postgres/access/access.test.ts +147 -0
  80. package/src/postgres/access/acl.ts +400 -0
  81. package/src/postgres/access/import.ts +64 -0
  82. package/src/postgres/access/live.ts +123 -0
  83. package/src/postgres/apply/apply.ts +117 -4
  84. package/src/postgres/apply/statements.ts +55 -9
  85. package/src/postgres/entities.ts +287 -11
  86. package/src/postgres/entity-types.ts +4 -0
  87. package/src/postgres/import/generator.ts +14 -4
  88. package/src/postgres/import/live-export.ts +12 -2
  89. package/src/postgres/live/bind.ts +3 -0
  90. package/src/postgres/live/catalog.ts +79 -3
  91. package/src/postgres/live/describe-resources.ts +27 -3
  92. package/src/postgres/migrate/migrate.test.ts +24 -1
  93. package/src/postgres/migrate/op.ts +80 -35
  94. package/src/postgres/migrate/outer.e2e.test.ts +163 -0
  95. package/src/postgres/parser.ts +247 -8
  96. package/src/postgres/plan/commands.ts +60 -7
  97. package/src/postgres/plan/deep.ts +24 -3
  98. package/src/postgres/plan/diff.ts +71 -6
  99. package/src/postgres/plan/normalize.ts +115 -2
  100. package/src/postgres/plan/rules.ts +44 -0
  101. package/src/postgres/plan/schema.ts +4 -2
  102. package/src/postgres/plan/server-normalize.ts +29 -1
  103. package/src/postgres.ts +11 -0
  104. package/src/skill-defs.ts +1 -1
  105. package/src/skills/chant-sql-postgres-migration.md +3 -1
  106. package/src/skills/chant-sql-postgres.md +9 -2
  107. package/src/validate.ts +4 -0
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The Postgres entities: `schema`, `table`, `index`, `view`, `sequence`,
3
- * `type`, `domain`, `extension`, `func`, `procedure` and `trigger` tagged
4
- * templates that parse their DDL at fold time into one Declarable each
5
- * (chant #3278, #3279, #3680).
3
+ * `type`, `domain`, `extension`, `func`, `procedure`, `trigger`, `policy`,
4
+ * `role` and `grant` tagged templates that parse their DDL at fold time into
5
+ * one Declarable each (chant #3278, #3279, #3680, #3681).
6
6
  *
7
7
  * ```ts
8
8
  * import { schema, table, index } from "@intentius/chant-lexicon-sql/postgres";
@@ -56,7 +56,7 @@
56
56
  import type { AttrRef } from "@intentius/chant/attrref";
57
57
  import { setInterpolationFields } from "@intentius/chant/provenance";
58
58
  import { isTrivia, POSTGRES_LEXICAL, SqlSyntaxError, untokenize, type Token } from "./tokens";
59
- import { identValue, parseStatements, type ColumnNode, type CommentNode, type ConstraintNode, type NameNode, type Span, type StatementNode } from "./parser";
59
+ import { identValue, parseStatements, type ColumnNode, type CommentNode, type ConstraintNode, type GrantNode, type NameNode, type RowSecurityNode, type Span, type StatementNode } from "./parser";
60
60
  import { keywordCategory, quoteIdent } from "./keywords";
61
61
  import { feed, lineage, spanText as text, splice as spliceWith, templateSyntaxError, type TemplateCtx, type TemplateDialect } from "../core/interpolation";
62
62
  import { SqlObject, isColumnRefOf, isSqlObjectOf, makeSqlEntity } from "../core/entity";
@@ -183,6 +183,10 @@ export interface TableProps extends CommonProps {
183
183
  with?: string;
184
184
  onCommit?: string;
185
185
  tablespace?: string;
186
+ /** `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` after the CREATE (true), or `DISABLE` (false). */
187
+ rowSecurity?: boolean;
188
+ /** `ALTER TABLE ... FORCE ROW LEVEL SECURITY` after the CREATE (true), or `NO FORCE` (false): the owner is subject to the policies too. */
189
+ forceRowSecurity?: boolean;
186
190
  }
187
191
 
188
192
  export interface IndexProps extends CommonProps {
@@ -338,6 +342,70 @@ export interface TriggerProps extends CommonProps {
338
342
  args: string[];
339
343
  }
340
344
 
345
+ export interface PolicyProps extends CommonProps {
346
+ /** The table: the entity when interpolated, else its name. */
347
+ table: PostgresObject | string;
348
+ tableName: string;
349
+ /** `AS PERMISSIVE` (the default) or `AS RESTRICTIVE`. */
350
+ permissive: boolean;
351
+ command: "all" | "select" | "insert" | "update" | "delete";
352
+ /** The roles it applies to; `public` when it names none. */
353
+ roles: string[];
354
+ /** `USING (...)`, parentheses excluded. */
355
+ using?: string;
356
+ /** `WITH CHECK (...)`, parentheses excluded. */
357
+ check?: string;
358
+ }
359
+
360
+ export interface RoleProps extends Omit<CommonProps, "schema"> {
361
+ /** The options written, each by its key word, lower case (`login`, `noinherit`). */
362
+ options: string[];
363
+ /** `CONNECTION LIMIT n`. */
364
+ connectionLimit?: string;
365
+ }
366
+
367
+ /** A privilege as written: `select`, `update` on some columns, `all`. */
368
+ export interface PrivilegeDef {
369
+ privilege: string;
370
+ columns?: string[];
371
+ }
372
+
373
+ export interface GrantProps {
374
+ /** A grant's or revoke's name, which no catalog holds: what it grants on what to whom. */
375
+ name: string;
376
+ action: "grant" | "revoke";
377
+ /** `REVOKE GRANT OPTION FOR`: only the right to grant on is revoked. */
378
+ grantOptionFor?: boolean;
379
+ privileges: PrivilegeDef[];
380
+ /** The kind of object. */
381
+ on: "table" | "sequence" | "schema" | "function" | "procedure" | "routine";
382
+ /** The objects: each the entity when interpolated, else its name. */
383
+ objects: Array<PostgresObject | string>;
384
+ /** The objects' SQL names; a routine's with its parameter types when they are written or interpolated. */
385
+ objectNames: string[];
386
+ /** Role names; `public` for PUBLIC. */
387
+ grantees: string[];
388
+ withGrantOption?: boolean;
389
+ ddl: string;
390
+ source: { strings: string[] };
391
+ }
392
+
393
+ export interface DefaultPrivilegesProps {
394
+ name: string;
395
+ action: "grant" | "revoke";
396
+ grantOptionFor?: boolean;
397
+ /** `FOR ROLE`: the roles whose new objects it applies to; the role that applies it when not written. */
398
+ forRoles?: string[];
399
+ /** `IN SCHEMA`: the schemas; every schema when not written. */
400
+ inSchemas?: string[];
401
+ privileges: string[];
402
+ on: "tables" | "sequences" | "functions" | "types" | "schemas";
403
+ grantees: string[];
404
+ withGrantOption?: boolean;
405
+ ddl: string;
406
+ source: { strings: string[] };
407
+ }
408
+
341
409
  /**
342
410
  * A string as a quoted Postgres string literal: `'` doubled, a backslash left
343
411
  * as written (standard_conforming_strings is on). An interpolated plain string
@@ -419,6 +487,21 @@ export interface PostgresTrigger extends PostgresObject {
419
487
  readonly props: TriggerProps;
420
488
  }
421
489
 
490
+ export interface PostgresPolicy extends PostgresObject {
491
+ readonly entityType: "Postgres::Policy";
492
+ readonly props: PolicyProps;
493
+ }
494
+
495
+ export interface PostgresRole extends PostgresObject {
496
+ readonly entityType: "Postgres::Role";
497
+ readonly props: RoleProps;
498
+ }
499
+
500
+ export interface PostgresGrant extends PostgresObject {
501
+ readonly entityType: "Postgres::Grant" | "Postgres::DefaultPrivileges";
502
+ readonly props: GrantProps | DefaultPrivilegesProps;
503
+ }
504
+
422
505
  export function isPostgresObject(value: unknown): value is PostgresObject {
423
506
  return isSqlObjectOf(value, "Postgres::");
424
507
  }
@@ -521,7 +604,7 @@ export const POSTGRES_TEMPLATES: TemplateDialect = {
521
604
 
522
605
  type Ctx = TemplateCtx;
523
606
 
524
- export type PostgresTag = "schema" | "table" | "index" | "view" | "sequence" | "type" | "domain" | "extension" | "func" | "procedure" | "trigger";
607
+ export type PostgresTag = "schema" | "table" | "index" | "view" | "sequence" | "type" | "domain" | "extension" | "func" | "procedure" | "trigger" | "policy" | "role" | "grant";
525
608
 
526
609
  const STATEMENT_OF: Record<PostgresTag, StatementNode["statement"]> = {
527
610
  schema: "schema",
@@ -535,6 +618,9 @@ const STATEMENT_OF: Record<PostgresTag, StatementNode["statement"]> = {
535
618
  func: "function",
536
619
  procedure: "procedure",
537
620
  trigger: "trigger",
621
+ policy: "policy",
622
+ role: "role",
623
+ grant: "grant",
538
624
  };
539
625
 
540
626
  const TAG_OF: Record<StatementNode["statement"], PostgresTag | undefined> = {
@@ -549,6 +635,10 @@ const TAG_OF: Record<StatementNode["statement"], PostgresTag | undefined> = {
549
635
  function: "func",
550
636
  procedure: "procedure",
551
637
  trigger: "trigger",
638
+ policy: "policy",
639
+ role: "role",
640
+ grant: "grant",
641
+ rowSecurity: undefined,
552
642
  comment: undefined,
553
643
  };
554
644
 
@@ -565,6 +655,10 @@ export const STATEMENT_NAMES: Record<StatementNode["statement"], string> = {
565
655
  function: "CREATE FUNCTION",
566
656
  procedure: "CREATE PROCEDURE",
567
657
  trigger: "CREATE TRIGGER",
658
+ policy: "CREATE POLICY",
659
+ role: "CREATE ROLE",
660
+ grant: "GRANT",
661
+ rowSecurity: "ALTER TABLE",
568
662
  comment: "COMMENT ON",
569
663
  };
570
664
 
@@ -782,9 +876,9 @@ function applyComments(
782
876
  if (value !== undefined) constraint!.comment = value;
783
877
  } else {
784
878
  if (!objectTypes.includes(c.objectType)) refuse(c, `does not comment on the ${tag} this template declares`);
785
- if (c.objectType === "TRIGGER") {
879
+ if (c.objectType === "TRIGGER" || c.objectType === "POLICY") {
786
880
  // `COMMENT ON TRIGGER name ON table`: the name is the trigger's, the table its own.
787
- if (qualified(ctx, c.target).name !== self.name || !c.on || !isSelf({ ...qualified(ctx, c.on), name: self.name })) refuse(c, `names another trigger than ${self.name}`);
881
+ if (qualified(ctx, c.target).name !== self.name || !c.on || !isSelf({ ...qualified(ctx, c.on), name: self.name })) refuse(c, `names another ${c.objectType.toLowerCase()} than ${self.name}`);
788
882
  } else if (!isSelf(qualified(ctx, c.target))) refuse(c, `names another object than ${sqlNameOf(self)}`);
789
883
  comment = value;
790
884
  }
@@ -801,6 +895,17 @@ function lastDot(ctx: Ctx, span: Span): number {
801
895
  return span.from;
802
896
  }
803
897
 
898
+ /**
899
+ * The first statement after the CREATE that its template may not hold: a
900
+ * template is one object, then COMMENT ON statements for it; a table's may
901
+ * also turn row-level security on or off (`ALTER TABLE ... ENABLE ROW LEVEL
902
+ * SECURITY`), as `pg_dump` prints it. A grant is one statement.
903
+ */
904
+ export function followOnStray(tag: PostgresTag, rest: readonly StatementNode[]): StatementNode | undefined {
905
+ if (tag === "grant") return rest[0];
906
+ return rest.find((n) => n.statement !== "comment" && !(tag === "table" && n.statement === "rowSecurity"));
907
+ }
908
+
804
909
  /**
805
910
  * The interpolations that sit inside a string constant (a routine's
806
911
  * `AS $$ ... $$` body), rendered into the text around them: a string
@@ -897,6 +1002,75 @@ function inlineQuoted(parts: readonly string[], values: readonly unknown[]): { p
897
1002
  return { parts: out, values: outValues, original, inlined, refs };
898
1003
  }
899
1004
 
1005
+ /** A role named where one is granted to: an interpolated role entity gives its name. */
1006
+ function roleName(ctx: Ctx, r: { name: string; span: Span }): string {
1007
+ if (r.name) return r.name;
1008
+ const t = ctx.tokens[r.span.from];
1009
+ const v = t?.kind === "ref" ? ctx.values[t.part] : undefined;
1010
+ if (isPostgresObject(v)) return (v.props as { name: string }).name;
1011
+ return identValue(req(text(ctx, r.span)));
1012
+ }
1013
+
1014
+ /** A GRANT's, REVOKE's or ALTER DEFAULT PRIVILEGES's props. */
1015
+ function grantProps(ctx: Ctx, node: GrantNode, ddl: string, source: { strings: string[] }): GrantProps | DefaultPrivilegesProps {
1016
+ const action = node.revoke ? "revoke" : "grant";
1017
+ const grantees = node.grantees.map((g) => roleName(ctx, g));
1018
+ if (node.defaults) {
1019
+ if (node.privileges.some((p) => p.columns.length > 0)) throw new SqlTemplateError("grant", "default privileges are on whole objects; a column list is for a grant on one table", 0, 0);
1020
+ const on = (node.on === "ROUTINES" ? "functions" : node.on.toLowerCase()) as DefaultPrivilegesProps["on"];
1021
+ const forRoles = node.forRoles?.map((r) => roleName(ctx, r));
1022
+ const inSchemas = node.inSchemas?.map((s) => columnName(ctx, s));
1023
+ if (on === "schemas" && inSchemas) throw new SqlTemplateError("grant", "default privileges on schemas cannot be limited to a schema (IN SCHEMA)", 0, 0);
1024
+ const privileges = node.privileges.map((p) => p.privilege.toLowerCase());
1025
+ return stripUnset({
1026
+ name: `default privileges ${action === "grant" ? "grant" : "revoke"} ${privileges.join(", ")} on ${on}${inSchemas ? ` in ${inSchemas.join(", ")}` : ""}${forRoles ? ` for ${forRoles.join(", ")}` : ""} ${action === "grant" ? "to" : "from"} ${grantees.join(", ")}`,
1027
+ action,
1028
+ grantOptionFor: node.grantOptionFor || undefined,
1029
+ forRoles,
1030
+ inSchemas,
1031
+ privileges,
1032
+ on,
1033
+ grantees,
1034
+ withGrantOption: node.withGrantOption || undefined,
1035
+ ddl,
1036
+ source,
1037
+ }) as DefaultPrivilegesProps;
1038
+ }
1039
+ const on = (node.on === "TABLE" ? "table" : node.on.toLowerCase()) as GrantProps["on"];
1040
+ const routine = on === "function" || on === "procedure" || on === "routine";
1041
+ const objects: Array<PostgresObject | string> = [];
1042
+ const objectNames: string[] = [];
1043
+ for (const o of node.objects) {
1044
+ const t = target(ctx, o.name);
1045
+ objects.push(t);
1046
+ let sqlName = typeof t === "string" ? t : on === "schema" ? (t.props as { name: string }).name : t.sqlName;
1047
+ if (routine) {
1048
+ if (o.args) sqlName += `(${req(text(ctx, o.args, "objects")) ?? ""})`;
1049
+ else if (typeof t !== "string" && (t.entityType === POSTGRES_ENTITY_TYPES.function || t.entityType === POSTGRES_ENTITY_TYPES.procedure)) {
1050
+ // An interpolated routine is named by its own parameter types.
1051
+ sqlName += `(${(t.props as RoutineProps).args.filter((a) => a.mode !== "out").map((a) => a.type).join(", ")})`;
1052
+ } else throw new SqlTemplateError("grant", `${sqlName}: name the parameter types (${sqlName}(...)), or interpolate the routine, since overloads share a name`, 0, 0);
1053
+ }
1054
+ objectNames.push(sqlName);
1055
+ }
1056
+ const privileges = node.privileges.map((p) => stripUnset({ privilege: p.privilege.toLowerCase(), columns: p.columns.length > 0 ? p.columns.map((c) => columnName(ctx, c)) : undefined }) as PrivilegeDef);
1057
+ if (on !== "table" && privileges.some((p) => p.columns)) throw new SqlTemplateError("grant", "a column list is for privileges on a table", 0, 0);
1058
+ const verb = action === "grant" ? "to" : "from";
1059
+ return stripUnset({
1060
+ name: `${action} ${privileges.map((p) => `${p.privilege}${p.columns ? `(${p.columns.join(", ")})` : ""}`).join(", ")} on ${on} ${objectNames.join(", ")} ${verb} ${grantees.join(", ")}`,
1061
+ action,
1062
+ grantOptionFor: node.grantOptionFor || undefined,
1063
+ privileges,
1064
+ on,
1065
+ objects,
1066
+ objectNames,
1067
+ grantees,
1068
+ withGrantOption: node.withGrantOption || undefined,
1069
+ ddl,
1070
+ source,
1071
+ }) as GrantProps;
1072
+ }
1073
+
900
1074
  function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string[], rawValues: readonly unknown[]): PostgresObject {
901
1075
  const rawParts = templateParts(strings);
902
1076
  const routine = tag === "func" || tag === "procedure";
@@ -915,11 +1089,28 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
915
1089
  if (node.statement !== STATEMENT_OF[tag]) {
916
1090
  const holds = STATEMENT_NAMES[node.statement];
917
1091
  const use = TAG_OF[node.statement];
918
- throw new SqlTemplateError(tag, use ? `holds a ${holds}; use the ${use} tag` : `holds a ${holds} before any CREATE; a COMMENT ON goes after the CREATE it comments on`, 0, 0);
1092
+ throw new SqlTemplateError(
1093
+ tag,
1094
+ use
1095
+ ? `holds a ${holds}; use the ${use} tag`
1096
+ : node.statement === "rowSecurity"
1097
+ ? "holds an ALTER TABLE ... ROW LEVEL SECURITY before any CREATE; it goes after the table's CREATE TABLE, in the table's template"
1098
+ : `holds a ${holds} before any CREATE; a COMMENT ON goes after the CREATE it comments on`,
1099
+ 0,
1100
+ 0,
1101
+ );
919
1102
  }
920
- const stray = rest.find((n) => n.statement !== "comment");
921
- if (stray) throw new SqlTemplateError(tag, `holds a second statement (${STATEMENT_NAMES[stray.statement]}); a template declares one object, followed only by COMMENT ON statements for it`, 0, 0);
922
- const comments = rest as CommentNode[];
1103
+ const stray = followOnStray(tag, rest);
1104
+ if (stray) {
1105
+ throw new SqlTemplateError(
1106
+ tag,
1107
+ `holds a second statement (${STATEMENT_NAMES[stray.statement]}); a template declares one object, followed only by COMMENT ON statements for it${tag === "table" ? " and ALTER TABLE ... ROW LEVEL SECURITY" : ""}`,
1108
+ 0,
1109
+ 0,
1110
+ );
1111
+ }
1112
+ const comments = rest.filter((n): n is CommentNode => n.statement === "comment");
1113
+ const rowSecurity = rest.filter((n): n is RowSecurityNode => n.statement === "rowSecurity");
923
1114
 
924
1115
  const positions = regclassPositions(tokens, values);
925
1116
  const render = (v: unknown, i?: number): string => {
@@ -1078,6 +1269,16 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
1078
1269
  const named = new Map<string, Commentable>();
1079
1270
  for (const c of [primaryKey, ...uniques, ...checks, ...foreignKeys, ...exclusions]) if (c?.name) named.set(c.name, c);
1080
1271
  const comment = applyComments(tag, ctx, comments, q, ["TABLE"], { columns: new Map(columns.map((c) => [c.name, c])), constraints: named });
1272
+ let rls: boolean | undefined;
1273
+ let force: boolean | undefined;
1274
+ for (const r of rowSecurity) {
1275
+ const t = qualified(ctx, r.table);
1276
+ if (t.name !== q.name || (t.schema !== undefined && q.schema !== undefined && t.schema !== q.schema)) {
1277
+ throw new SqlTemplateError(tag, `ALTER TABLE ${sqlNameOf(t)} ... ROW LEVEL SECURITY names another table than ${sqlNameOf(q)}`, 0, 0);
1278
+ }
1279
+ if (r.action === "ENABLE" || r.action === "DISABLE") rls = r.action === "ENABLE";
1280
+ else force = r.action === "FORCE";
1281
+ }
1081
1282
  const props: TableProps = strip(
1082
1283
  {
1083
1284
  ...q,
@@ -1099,12 +1300,16 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
1099
1300
  with: text(ctx, node.with, "with"),
1100
1301
  onCommit: text(ctx, node.onCommit),
1101
1302
  tablespace: text(ctx, node.tablespace, "tablespace"),
1303
+ rowSecurity: rls,
1304
+ forceRowSecurity: force,
1102
1305
  comment,
1103
1306
  ddl,
1104
1307
  source,
1105
1308
  },
1106
1309
  ["columns", "uniques", "checks", "foreignKeys", "exclusions", "like", "inherits"],
1107
1310
  );
1311
+ if (rls === false) props.rowSecurity = false;
1312
+ if (force === false) props.forceRowSecurity = false;
1108
1313
  return make(POSTGRES_ENTITY_TYPES.table, q, props, columns.map((c) => c.name));
1109
1314
  }
1110
1315
  case "index": {
@@ -1274,6 +1479,52 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
1274
1479
  );
1275
1480
  return make(POSTGRES_ENTITY_TYPES.trigger, q, props);
1276
1481
  }
1482
+ case "policy": {
1483
+ const tq = qualified(ctx, node.table);
1484
+ const name = node.name.pieces[0] || identValue(req(text(ctx, node.name.span)));
1485
+ feed(ctx, node.name.span, "name");
1486
+ // A policy is its table's, named in the table's schema.
1487
+ const q = { schema: tq.schema, name };
1488
+ const comment = applyComments(tag, ctx, comments, q, ["POLICY"]);
1489
+ const props: PolicyProps = stripUnset({
1490
+ ...q,
1491
+ table: target(ctx, node.table),
1492
+ tableName: sqlNameOf(tq),
1493
+ permissive: node.permissive !== false,
1494
+ command: (node.command ?? "ALL").toLowerCase() as PolicyProps["command"],
1495
+ roles: node.roles.length > 0 ? node.roles.map((r) => roleName(ctx, r)) : ["public"],
1496
+ using: text(ctx, node.using, "using"),
1497
+ check: text(ctx, node.check, "check"),
1498
+ comment,
1499
+ ddl,
1500
+ source,
1501
+ });
1502
+ // AS RESTRICTIVE is a false that means something.
1503
+ props.permissive = node.permissive !== false;
1504
+ return make(POSTGRES_ENTITY_TYPES.policy, q, props);
1505
+ }
1506
+ case "role": {
1507
+ const name = node.name.pieces[0] || identValue(req(text(ctx, node.name.span)));
1508
+ feed(ctx, node.name.span, "name");
1509
+ const comment = applyComments(tag, ctx, comments, { name }, ["ROLE"]);
1510
+ const value = (o: string) => {
1511
+ const x = node.options.find((y) => y.option === o);
1512
+ return x?.value ? text(ctx, x.value, "options") : undefined;
1513
+ };
1514
+ const props: RoleProps = stripUnset({
1515
+ name,
1516
+ options: node.options.filter((o) => o.value === undefined).map((o) => o.option.toLowerCase()),
1517
+ connectionLimit: value("CONNECTION LIMIT"),
1518
+ comment,
1519
+ ddl,
1520
+ source,
1521
+ });
1522
+ return make(POSTGRES_ENTITY_TYPES.role, { name }, props);
1523
+ }
1524
+ case "grant":
1525
+ return make(node.defaults ? POSTGRES_ENTITY_TYPES.defaultPrivileges : POSTGRES_ENTITY_TYPES.grant, { name: "" }, grantProps(ctx, node, ddl, source));
1526
+ case "rowSecurity":
1527
+ throw new SqlTemplateError(tag, "an ALTER TABLE ... ROW LEVEL SECURITY goes after the table's CREATE TABLE, in the table's template", 0, 0);
1277
1528
  case "comment":
1278
1529
  throw new SqlTemplateError(tag, "a COMMENT ON goes after the CREATE it comments on", 0, 0);
1279
1530
  }
@@ -1337,3 +1588,28 @@ export function procedure(strings: TemplateStringsArray, ...values: unknown[]):
1337
1588
  export function trigger(strings: TemplateStringsArray, ...values: unknown[]): PostgresTrigger {
1338
1589
  return build("trigger", strings, values) as PostgresTrigger;
1339
1590
  }
1591
+
1592
+ /** `` policy`CREATE POLICY name ON ${table} ... USING (...)` ``: one row-level security policy on its table. */
1593
+ export function policy(strings: TemplateStringsArray, ...values: unknown[]): PostgresPolicy {
1594
+ return build("policy", strings, values) as PostgresPolicy;
1595
+ }
1596
+
1597
+ /**
1598
+ * `` role`CREATE ROLE name [WITH] options` ``: a role the schema needs, created
1599
+ * when it is missing. A password and memberships are the environment's and are
1600
+ * refused; a role the environment provisions is named as text where it is
1601
+ * granted to, never declared.
1602
+ */
1603
+ export function role(strings: TemplateStringsArray, ...values: unknown[]): PostgresRole {
1604
+ return build("role", strings, values) as PostgresRole;
1605
+ }
1606
+
1607
+ /**
1608
+ * `` grant`GRANT ... ON ... TO ...` ``, `REVOKE ... FROM ...`, or
1609
+ * `ALTER DEFAULT PRIVILEGES ... GRANT|REVOKE ...`: privileges on schemas,
1610
+ * tables, columns, sequences and routines, applied where the profile manages
1611
+ * access (`sql.profiles.<env>.access`).
1612
+ */
1613
+ export function grant(strings: TemplateStringsArray, ...values: unknown[]): PostgresGrant {
1614
+ return build("grant", strings, values) as PostgresGrant;
1615
+ }
@@ -16,6 +16,10 @@ export const POSTGRES_ENTITY_TYPES = {
16
16
  function: "Postgres::Function",
17
17
  procedure: "Postgres::Procedure",
18
18
  trigger: "Postgres::Trigger",
19
+ policy: "Postgres::Policy",
20
+ role: "Postgres::Role",
21
+ grant: "Postgres::Grant",
22
+ defaultPrivileges: "Postgres::DefaultPrivileges",
19
23
  } as const;
20
24
 
21
25
  export type PostgresEntityType = (typeof POSTGRES_ENTITY_TYPES)[keyof typeof POSTGRES_ENTITY_TYPES];
@@ -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
@@ -45,6 +48,10 @@ const TAG: Record<string, string> = {
45
48
  [POSTGRES_ENTITY_TYPES.function]: "func",
46
49
  [POSTGRES_ENTITY_TYPES.procedure]: "procedure",
47
50
  [POSTGRES_ENTITY_TYPES.trigger]: "trigger",
51
+ [POSTGRES_ENTITY_TYPES.policy]: "policy",
52
+ [POSTGRES_ENTITY_TYPES.role]: "role",
53
+ [POSTGRES_ENTITY_TYPES.grant]: "grant",
54
+ [POSTGRES_ENTITY_TYPES.defaultPrivileges]: "grant",
48
55
  };
49
56
 
50
57
  /** Text that sits inside a template literal: a backquote and `${` escaped. */
@@ -85,8 +92,9 @@ function templateBody(item: Item, byQualified: Map<string, Item>, schemaExport:
85
92
  const dot = tokens[sig[k + 1] ?? -1];
86
93
  const b = tokens[sig[k + 2] ?? -1];
87
94
  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") {
95
+ // An extension's `WITH SCHEMA app`, a grant's `ON SCHEMA app`, default privileges' `IN SCHEMA app`.
96
+ const bySchemaWord = item.type === POSTGRES_ENTITY_TYPES.extension || item.type === POSTGRES_ENTITY_TYPES.grant || item.type === POSTGRES_ENTITY_TYPES.defaultPrivileges;
97
+ if (bySchemaWord && isName(a) && prev?.kind === "ident" && prev.text.toUpperCase() === "SCHEMA") {
90
98
  const s = schemaExport.get(identValue(a));
91
99
  if (s) replace.set(sig[k]!, { to: sig[k]! + 1, text: `\${${s}}`, ref: s });
92
100
  }
@@ -148,7 +156,9 @@ export class PostgresGenerator implements TypeScriptGenerator {
148
156
  for (const it of items) {
149
157
  if (it.type === POSTGRES_ENTITY_TYPES.schema) schemaExport.set(it.name, it.exportName);
150
158
  // 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);
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
+ }
152
162
  }
153
163
 
154
164
  const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, schemaExport)]));
@@ -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
  }
@@ -21,6 +21,8 @@ export interface PostgresTarget {
21
21
  provider?: PostgresProvider;
22
22
  /** The apply's timeouts the profile sets (`lockTimeoutMs`, `statementTimeoutMs`, `scanTimeoutMs`); the applier's defaults otherwise. */
23
23
  timeouts?: { lockTimeoutMs?: number; statementTimeoutMs?: number; scanTimeoutMs?: number };
24
+ /** Whether chant manages access here (`sql.profiles.<env>.access`): policies, row-level security, the declared roles, grants and default privileges. */
25
+ access?: boolean;
24
26
  }
25
27
 
26
28
  export interface UnresolvedTarget {
@@ -72,6 +74,7 @@ export function resolvePostgresTarget(input: {
72
74
  ...(profile.schemas ? { schemas: profile.schemas } : {}),
73
75
  defaultSchema: profile.defaultSchema ?? "public",
74
76
  ...(Object.keys(timeouts).length > 0 ? { timeouts } : {}),
77
+ ...(profile.access === true ? { access: true } : {}),
75
78
  });
76
79
  }
77
80
  const url = env.POSTGRES_URL;
@@ -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