@intentius/chant-lexicon-sql 0.114.1 → 0.116.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 (142) hide show
  1. package/dist/clickhouse/apply/mutations.d.ts +7 -1
  2. package/dist/clickhouse/apply/mutations.d.ts.map +1 -1
  3. package/dist/clickhouse/container.d.ts +22 -7
  4. package/dist/clickhouse/container.d.ts.map +1 -1
  5. package/dist/clickhouse/plan/deep.d.ts +5 -0
  6. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  8. package/dist/clickhouse/rebuild/backfill.d.ts +11 -5
  9. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  10. package/dist/clickhouse/rebuild/op.d.ts +29 -0
  11. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  12. package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
  13. package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
  15. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  16. package/dist/clickhouse/rebuild/shards.d.ts +59 -0
  17. package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
  18. package/dist/clickhouse/rebuild/steps.d.ts +3 -0
  19. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  20. package/dist/clickhouse/rebuild/verify.d.ts +4 -0
  21. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  22. package/dist/codegen/registry.d.ts.map +1 -1
  23. package/dist/config.d.ts +14 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/core/receipts.d.ts +5 -3
  26. package/dist/core/receipts.d.ts.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/integrity.json +5 -5
  30. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  31. package/dist/lsp/postgres-template.d.ts.map +1 -1
  32. package/dist/manifest.json +1 -1
  33. package/dist/meta.json +15 -0
  34. package/dist/okf/index.md +3 -0
  35. package/dist/okf/types/PostgresFunction.md +9 -0
  36. package/dist/okf/types/PostgresProcedure.md +9 -0
  37. package/dist/okf/types/PostgresTrigger.md +9 -0
  38. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  39. package/dist/op/activities/index.d.ts +13 -1
  40. package/dist/op/activities/index.d.ts.map +1 -1
  41. package/dist/op/activities/sql-exec.d.ts +41 -0
  42. package/dist/op/activities/sql-exec.d.ts.map +1 -0
  43. package/dist/op/activity-contracts.d.ts +20 -0
  44. package/dist/op/activity-contracts.d.ts.map +1 -1
  45. package/dist/plugin.d.ts.map +1 -1
  46. package/dist/postgres/apply/apply.d.ts +6 -1
  47. package/dist/postgres/apply/apply.d.ts.map +1 -1
  48. package/dist/postgres/apply/statements.d.ts +9 -2
  49. package/dist/postgres/apply/statements.d.ts.map +1 -1
  50. package/dist/postgres/entities.d.ts +110 -3
  51. package/dist/postgres/entities.d.ts.map +1 -1
  52. package/dist/postgres/entity-types.d.ts +3 -0
  53. package/dist/postgres/entity-types.d.ts.map +1 -1
  54. package/dist/postgres/import/generator.d.ts.map +1 -1
  55. package/dist/postgres/import/live-export.d.ts.map +1 -1
  56. package/dist/postgres/live/catalog.d.ts +17 -5
  57. package/dist/postgres/live/catalog.d.ts.map +1 -1
  58. package/dist/postgres/live/describe-resources.d.ts +6 -1
  59. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  60. package/dist/postgres/migrate/receipts.d.ts +8 -5
  61. package/dist/postgres/migrate/receipts.d.ts.map +1 -1
  62. package/dist/postgres/parser.d.ts +82 -5
  63. package/dist/postgres/parser.d.ts.map +1 -1
  64. package/dist/postgres/plan/deep.d.ts.map +1 -1
  65. package/dist/postgres/plan/diff.d.ts +1 -1
  66. package/dist/postgres/plan/diff.d.ts.map +1 -1
  67. package/dist/postgres/plan/normalize.d.ts +21 -2
  68. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  69. package/dist/postgres/plan/rules.d.ts +6 -0
  70. package/dist/postgres/plan/rules.d.ts.map +1 -1
  71. package/dist/postgres/plan/schema.d.ts +11 -1
  72. package/dist/postgres/plan/schema.d.ts.map +1 -1
  73. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  74. package/dist/postgres.d.ts +1 -1
  75. package/dist/postgres.d.ts.map +1 -1
  76. package/dist/receipts.d.ts +102 -0
  77. package/dist/receipts.d.ts.map +1 -0
  78. package/dist/rules/postgres-templates.ts +2 -2
  79. package/dist/rules/sqlpg001.ts +3 -0
  80. package/dist/validate.d.ts.map +1 -1
  81. package/package.json +2 -2
  82. package/src/clickhouse/apply/mutations.ts +9 -3
  83. package/src/clickhouse/container.ts +39 -11
  84. package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
  85. package/src/clickhouse/plan/deep.ts +102 -3
  86. package/src/clickhouse/plan/normalize.ts +10 -0
  87. package/src/clickhouse/plan/plan.test.ts +10 -0
  88. package/src/clickhouse/rebuild/backfill.ts +85 -42
  89. package/src/clickhouse/rebuild/op.ts +80 -35
  90. package/src/clickhouse/rebuild/partitions.ts +11 -0
  91. package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
  92. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +110 -4
  93. package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
  94. package/src/clickhouse/rebuild/receipts.ts +4 -6
  95. package/src/clickhouse/rebuild/shards.ts +104 -0
  96. package/src/clickhouse/rebuild/steps.ts +4 -0
  97. package/src/clickhouse/rebuild/verify.ts +14 -5
  98. package/src/codegen/postgres-classifier-page.ts +1 -1
  99. package/src/codegen/registry.ts +3 -0
  100. package/src/config.ts +7 -0
  101. package/src/core/receipts.ts +5 -3
  102. package/src/generated/lexicon-sql.json +15 -0
  103. package/src/index.ts +1 -1
  104. package/src/lint/rules/postgres-templates.ts +2 -2
  105. package/src/lint/rules/sqlpg001.ts +3 -0
  106. package/src/lsp/postgres-template.ts +6 -0
  107. package/src/mcp/postgres.ts +1 -1
  108. package/src/op/activities/clickhouse-rebuild.ts +32 -7
  109. package/src/op/activities/index.ts +21 -4
  110. package/src/op/activities/sql-exec.ts +84 -0
  111. package/src/op/activity-contracts.ts +15 -0
  112. package/src/op/effect-receipts.e2e.test.ts +277 -0
  113. package/src/plugin.ts +3 -0
  114. package/src/postgres/apply/apply.ts +21 -5
  115. package/src/postgres/apply/statements.ts +53 -11
  116. package/src/postgres/entities.ts +332 -9
  117. package/src/postgres/entity-types.ts +3 -0
  118. package/src/postgres/import/generator.ts +5 -2
  119. package/src/postgres/import/live-export.ts +4 -0
  120. package/src/postgres/live/catalog.ts +81 -5
  121. package/src/postgres/live/describe-resources.ts +17 -7
  122. package/src/postgres/live/routines.e2e.test.ts +173 -0
  123. package/src/postgres/migrate/batches.e2e.test.ts +2 -1
  124. package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
  125. package/src/postgres/migrate/migrate.test.ts +3 -2
  126. package/src/postgres/migrate/receipts.ts +18 -10
  127. package/src/postgres/parser.ts +314 -8
  128. package/src/postgres/plan/commands.ts +2 -2
  129. package/src/postgres/plan/deep.ts +57 -17
  130. package/src/postgres/plan/diff.ts +46 -6
  131. package/src/postgres/plan/normalize.ts +158 -4
  132. package/src/postgres/plan/rules.ts +36 -0
  133. package/src/postgres/plan/schema.ts +25 -6
  134. package/src/postgres/plan/server-normalize.ts +24 -0
  135. package/src/postgres/routines.test.ts +150 -0
  136. package/src/postgres.ts +11 -0
  137. package/src/receipts.test.ts +91 -0
  138. package/src/receipts.ts +259 -0
  139. package/src/skill-defs.ts +1 -1
  140. package/src/skills/chant-sql-postgres.md +18 -2
  141. package/src/skills/chant-sql-rebuild.md +5 -1
  142. package/src/validate.ts +3 -0
@@ -27,7 +27,15 @@
27
27
  * column's columns are `NOT NULL` whether or not that is written;
28
28
  * - an identity column's or a sequence's options at their defaults are left
29
29
  * out; storage parameters compare as a map;
30
- * - an index's access method defaults to `btree`.
30
+ * - an index's access method defaults to `btree`;
31
+ * - a function's or procedure's parameter types drop their modifiers (the
32
+ * catalog keeps none: `varchar(20)` is `character varying`), a parameter's
33
+ * mode is `in` unless written, its attributes at their defaults (VOLATILE,
34
+ * CALLED ON NULL INPUT, SECURITY INVOKER, PARALLEL UNSAFE, COST 100 or 1,
35
+ * ROWS 1000 or 0) are left out, and its body is compared as written, since
36
+ * the server keeps it verbatim;
37
+ * - a trigger's events compare as a set, `UPDATE OF` columns sorted, and its
38
+ * arguments as the strings the catalog stores.
31
39
  *
32
40
  * What the rules cannot see (a view's `SELECT *`, which the server expands, or
33
41
  * an expression it parenthesizes) is asked of the live server itself when a
@@ -52,10 +60,13 @@ import type {
52
60
  IndexProps,
53
61
  KeyDef,
54
62
  SchemaProps,
63
+ RoutineProps,
55
64
  SequenceProps,
56
65
  TableProps,
66
+ TriggerProps,
57
67
  ViewProps,
58
68
  } from "../entities";
69
+ import { stringValue } from "../entities";
59
70
 
60
71
  // ── Types ──────────────────────────────────────────────────────────────
61
72
 
@@ -178,7 +189,7 @@ function castEnd(sig: readonly Token[], i: number): { end: number; type: string
178
189
  return { end: j, type: words.join("").toLowerCase() };
179
190
  }
180
191
 
181
- const isLiteral = (t: Token | undefined): boolean => t !== undefined && (t.kind === "string" || t.kind === "number");
192
+ const isLiteral = (t: Token | undefined): boolean => t !== undefined && (t.kind === "string" || t.kind === "number" || (t.kind === "ident" && t.text.toUpperCase() === "NULL"));
182
193
 
183
194
  /** One token's canonical text. */
184
195
  function tokenText(t: Token): string {
@@ -308,12 +319,18 @@ export interface CanonicalConstraint {
308
319
  comment?: string;
309
320
  }
310
321
 
311
- export type CanonicalKind = "schema" | "table" | "index" | "view" | "materializedView" | "sequence" | "enum" | "domain" | "extension";
322
+ export type CanonicalKind = "schema" | "table" | "index" | "view" | "materializedView" | "sequence" | "enum" | "domain" | "extension" | "function" | "procedure" | "trigger";
312
323
 
313
324
  export interface CanonicalPgObject {
314
325
  kind: CanonicalKind;
315
326
  schema?: string;
316
327
  name: string;
328
+ /**
329
+ * What tells the object from another of the same name: a routine's input
330
+ * parameter types, `(integer,text)`; a trigger's table, ` ON app.users`.
331
+ * Written as SQL names the object after its name.
332
+ */
333
+ signature?: string;
317
334
  /** Everything else that is compared, by field, each field canonical. */
318
335
  fields: Record<string, unknown>;
319
336
  columns: CanonicalColumn[];
@@ -330,6 +347,9 @@ const kindOf: Record<string, CanonicalKind> = {
330
347
  "Postgres::Enum": "enum",
331
348
  "Postgres::Domain": "domain",
332
349
  "Postgres::Extension": "extension",
350
+ "Postgres::Function": "function",
351
+ "Postgres::Procedure": "procedure",
352
+ "Postgres::Trigger": "trigger",
333
353
  };
334
354
 
335
355
  const comment = (c: string | undefined): string | undefined => {
@@ -390,6 +410,127 @@ const keyBody = (kind: string, k: KeyDef) =>
390
410
  .filter(Boolean)
391
411
  .join(" ");
392
412
 
413
+ /** The pseudo-types (`pg_type.typtype = 'p'`), in `pg_catalog`: a routine's parameter or result, never a column's. */
414
+ const PSEUDO_TYPES = new Set([
415
+ "any", "anyarray", "anycompatible", "anycompatiblearray", "anycompatiblemultirange", "anycompatiblenonarray", "anycompatiblerange",
416
+ "anyelement", "anyenum", "anymultirange", "anynonarray", "anyrange", "cstring", "event_trigger", "fdw_handler", "index_am_handler",
417
+ "internal", "language_handler", "pg_ddl_command", "record", "table_am_handler", "trigger", "tsm_handler", "unknown", "void",
418
+ ]);
419
+
420
+ /** A parameter's or result's type as the catalog keeps it: canonical, its modifiers dropped (`varchar(20)` is `character varying`). */
421
+ export function canonicalArgType(text: string, defaultSchema: string): string {
422
+ const m = /^\s*(?:pg_catalog\s*\.\s*)?([A-Za-z_]+)\s*((?:\[\s*\]\s*)*)$/.exec(text);
423
+ if (m && PSEUDO_TYPES.has(m[1]!.toLowerCase())) return `${m[1]!.toLowerCase()}${"[]".repeat((m[2]!.match(/\[/g) ?? []).length)}`;
424
+ return canonicalType(text, defaultSchema)!.replace(/\([^()]*\)/g, "");
425
+ }
426
+
427
+ /** A routine's input parameters: those whose mode is `in`, `inout` or `variadic`. */
428
+ const isInput = (a: { mode: string }) => a.mode !== "out";
429
+
430
+ /** A routine's identity among its overloads: its input parameter types, `(integer,text)`. */
431
+ export function routineSignature(p: Pick<RoutineProps, "args">, defaultSchema: string): string {
432
+ return `(${p.args.filter(isInput).map((a) => canonicalArgType(a.type, defaultSchema)).join(",")})`;
433
+ }
434
+
435
+ /** The result a function declares, or the one its OUT parameters give it: one OUT is its type, several are `record`. */
436
+ function routineResult(p: RoutineProps, defaultSchema: string): string | undefined {
437
+ if (p.returnsTable) return `table(${p.returnsTable.map((c) => `${quoteIdent(c.name)} ${canonicalArgType(c.type, defaultSchema)}`).join(",")})`;
438
+ if (p.returns !== undefined) {
439
+ const m = /^setof\s+(.*)$/is.exec(p.returns.trim());
440
+ return m ? `setof ${canonicalArgType(m[1]!, defaultSchema)}` : canonicalArgType(p.returns, defaultSchema);
441
+ }
442
+ const outs = p.args.filter((a) => a.mode === "out" || a.mode === "inout");
443
+ if (outs.length === 1) return canonicalArgType(outs[0]!.type, defaultSchema);
444
+ if (outs.length > 1) return "record";
445
+ return undefined;
446
+ }
447
+
448
+ /** A `SET` value as compared: each item unquoted, a bare word folded, `TO` and `=` alike. */
449
+ function canonicalSetting(value: string): string {
450
+ const items: string[] = [];
451
+ let tokens: Token[];
452
+ try {
453
+ tokens = tokenizeText(value, 0).filter((t) => !isTrivia(t));
454
+ } catch {
455
+ return value.trim();
456
+ }
457
+ let sign = "";
458
+ for (const t of tokens) {
459
+ if (t.kind === "punct" && t.text === ",") continue;
460
+ if (t.kind === "op" && (t.text === "-" || t.text === "+")) {
461
+ sign = t.text === "-" ? "-" : "";
462
+ continue;
463
+ }
464
+ if (t.kind === "string") items.push(stringValue(t.text) ?? t.text);
465
+ else if (t.kind === "qident") items.push(identValue(t));
466
+ else items.push(`${sign}${t.kind === "ident" ? t.text.toLowerCase() : t.text}`);
467
+ sign = "";
468
+ }
469
+ return items.join(", ");
470
+ }
471
+
472
+ /** The canonical fields of a function or procedure. */
473
+ function routineFields(kind: "function" | "procedure", p: RoutineProps, defaultSchema: string, fields: Record<string, unknown>): void {
474
+ const lang = (p.language ?? "sql").toLowerCase();
475
+ fields.args = p.args.map((a) =>
476
+ [a.mode, a.name ? quoteIdent(a.name) : "", canonicalArgType(a.type, defaultSchema), a.default !== undefined ? `default ${canonicalExpr(a.default)}` : ""].filter(Boolean).join(" "),
477
+ );
478
+ fields.language = lang;
479
+ // Line endings aside, the body is what the server keeps and prints back.
480
+ fields.body = p.body.replace(/\r\n/g, "\n");
481
+ fields.link = p.link;
482
+ fields.securityDefiner = p.securityDefiner === true;
483
+ fields.set = p.set && Object.keys(p.set).length > 0
484
+ ? Object.fromEntries(Object.entries(p.set).map(([k, v]) => [k.toLowerCase(), canonicalSetting(v)]).sort(([a], [b]) => (a! < b! ? -1 : 1)))
485
+ : undefined;
486
+ fields.transform = p.transform ? canonicalExpr(p.transform) : undefined;
487
+ if (kind === "procedure") return;
488
+ const result = routineResult(p, defaultSchema);
489
+ const setReturning = result !== undefined && /^(setof |table\()/.test(result);
490
+ fields.returns = result;
491
+ fields.volatility = p.volatility && p.volatility !== "volatile" ? p.volatility : undefined;
492
+ fields.strict = p.strict === true;
493
+ fields.leakproof = p.leakproof === true;
494
+ fields.parallel = p.parallel && p.parallel !== "unsafe" ? p.parallel : undefined;
495
+ const defaultCost = lang === "c" || lang === "internal" ? 1 : 100;
496
+ fields.cost = p.cost !== undefined && Number(p.cost) !== defaultCost ? String(Number(p.cost)) : undefined;
497
+ const defaultRows = setReturning ? 1000 : 0;
498
+ fields.rows = p.rows !== undefined && Number(p.rows) !== defaultRows ? String(Number(p.rows)) : undefined;
499
+ fields.support = p.support ? canonicalName(p.support, "pg_catalog").replace(/^pg_catalog\./, "") : undefined;
500
+ fields.window = p.window === true;
501
+ }
502
+
503
+ /** The order the catalog prints a trigger's events in. */
504
+ const EVENT_ORDER = ["insert", "delete", "update", "truncate"];
505
+
506
+ /** A trigger argument as the catalog stores it: a string. */
507
+ function triggerArg(a: string): string {
508
+ const t = a.trim();
509
+ if (/^'/.test(t) || /^[Ee]'/.test(t) || /^\$/.test(t)) return stringValue(t) ?? t;
510
+ if (/^"/.test(t)) return identValue(t);
511
+ if (/^-?[0-9.]/.test(t)) return t.replace(/\s+/g, "");
512
+ return t.toLowerCase();
513
+ }
514
+
515
+ function triggerFields(p: TriggerProps, defaultSchema: string, fields: Record<string, unknown>): void {
516
+ fields.table = canonicalName(p.tableName, defaultSchema);
517
+ fields.constraint = p.constraint === true;
518
+ fields.timing = p.timing;
519
+ fields.events = [...p.events]
520
+ .sort((a, b) => EVENT_ORDER.indexOf(a.event) - EVENT_ORDER.indexOf(b.event))
521
+ .map((e) => (e.columns && e.columns.length > 0 ? `${e.event} of ${[...e.columns].sort().map(quoteIdent).join(",")}` : e.event));
522
+ fields.forEach = p.forEach;
523
+ fields.when = canonicalExpr(p.when);
524
+ fields.function = canonicalName(p.functionName, defaultSchema);
525
+ fields.args = p.args.length > 0 ? p.args.map(triggerArg) : undefined;
526
+ fields.from = p.from ? canonicalName(p.from, defaultSchema) : undefined;
527
+ fields.deferrable = p.deferrable === true;
528
+ fields.initiallyDeferred = p.initiallyDeferred === true;
529
+ fields.referencing = p.referencing && (p.referencing.old || p.referencing.new)
530
+ ? [p.referencing.old ? `old table as ${quoteIdent(p.referencing.old)}` : "", p.referencing.new ? `new table as ${quoteIdent(p.referencing.new)}` : ""].filter(Boolean).join(" ")
531
+ : undefined;
532
+ }
533
+
393
534
  /**
394
535
  * A definition in canonical form. `props` is an entity's props, declared or
395
536
  * parsed from what the server printed; `defaultSchema` qualifies a bare name.
@@ -398,7 +539,7 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
398
539
  const kind = kindOf[entityType];
399
540
  if (!kind) throw new Error(`not a Postgres entity type: ${entityType}`);
400
541
  const schemaOf = (p: { schema?: string }) => (kind === "schema" || kind === "extension" ? undefined : (p.schema ?? defaultSchema));
401
- const base = { kind, schema: schemaOf(props as { schema?: string }), name: String(props.name) };
542
+ const base: Pick<CanonicalPgObject, "kind" | "schema" | "name" | "signature"> = { kind, schema: schemaOf(props as { schema?: string }), name: String(props.name) };
402
543
  const fields: Record<string, unknown> = { comment: comment(props.comment as string | undefined) };
403
544
  const columns: CanonicalColumn[] = [];
404
545
  const constraints: CanonicalConstraint[] = [];
@@ -418,6 +559,19 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
418
559
  case "enum":
419
560
  fields.labels = (props as unknown as EnumProps).labels;
420
561
  break;
562
+ case "function":
563
+ case "procedure": {
564
+ const p = props as unknown as RoutineProps;
565
+ base.signature = routineSignature(p, defaultSchema);
566
+ routineFields(kind, p, defaultSchema, fields);
567
+ break;
568
+ }
569
+ case "trigger": {
570
+ const p = props as unknown as TriggerProps;
571
+ triggerFields(p, defaultSchema, fields);
572
+ base.signature = ` ON ${String(fields.table)}`;
573
+ break;
574
+ }
421
575
  case "domain": {
422
576
  const p = props as unknown as DomainProps;
423
577
  fields.dataType = canonicalType(p.dataType, defaultSchema);
@@ -270,6 +270,42 @@ export const PG_CLASSIFIER_RULES = {
270
270
  ),
271
271
  SQLPG267: rule("SQLPG267", "metadata", "Change a schema's owner", "ALTER SCHEMA ... OWNER TO changes only the catalog.", `${DOCS}/sql-alterschema.html`),
272
272
  SQLPG268: rule("SQLPG268", "expand", "Change an object's kind", "A view and a materialized view, or a table and either, are different kinds of object: the old one is dropped and the new one created.", `${DOCS}/sql-createview.html`),
273
+ SQLPG280: rule(
274
+ "SQLPG280",
275
+ "metadata",
276
+ "Replace a function's or procedure's definition",
277
+ "CREATE OR REPLACE FUNCTION (or PROCEDURE) replaces the body and attributes in the catalog; it locks no table, and a call already running finishes with the old definition. The parameter types, the result and the input parameters' names have to stay the same.",
278
+ `${DOCS}/sql-createfunction.html`,
279
+ ),
280
+ SQLPG281: rule(
281
+ "SQLPG281",
282
+ "metadata",
283
+ "Drop and create a function or procedure",
284
+ "CREATE OR REPLACE refuses a change of the result type, the output parameters, an input parameter's name, a removed parameter default or the routine's kind: the routine is dropped and created in one transaction. Nothing on the server depends on it (a view, a trigger, a column default would make the DROP fail).",
285
+ `${DOCS}/sql-createfunction.html`,
286
+ ),
287
+ SQLPG282: rule(
288
+ "SQLPG282",
289
+ "expand",
290
+ "Change a function's result or parameters while other objects depend on it",
291
+ "DROP FUNCTION refuses while a view, a trigger, a column default or another routine depends on the function, and CREATE OR REPLACE cannot make this change. Create the new definition under a new name (or signature), move what depends on it, then drop the old.",
292
+ `${DOCS}/sql-dropfunction.html`,
293
+ ),
294
+ SQLPG283: rule(
295
+ "SQLPG283",
296
+ "metadata",
297
+ "Create a trigger on an existing table",
298
+ "CREATE TRIGGER takes SHARE ROW EXCLUSIVE on its table: writes and other schema changes to the table wait until the transaction commits, but no row is read. The applier's lock_timeout bounds the wait behind long-running transactions.",
299
+ `${DOCS}/sql-createtrigger.html`,
300
+ ),
301
+ SQLPG284: rule(
302
+ "SQLPG284",
303
+ "metadata",
304
+ "Change a trigger",
305
+ "CREATE OR REPLACE TRIGGER (14 and later) replaces the trigger under SHARE ROW EXCLUSIVE on its table. A constraint trigger has no OR REPLACE, and a trigger moved to another table is another trigger: each is dropped (ACCESS EXCLUSIVE, briefly) and created in one transaction.",
306
+ `${DOCS}/sql-createtrigger.html`,
307
+ ),
308
+ SQLPG285: rule("SQLPG285", "drop", "Drop a trigger", "DROP TRIGGER takes ACCESS EXCLUSIVE on its table, briefly; no row is read or lost, and the table's writes no longer fire it.", `${DOCS}/sql-droptrigger.html`),
273
309
  SQLPG270: rule("SQLPG270", "drop", "Drop an object", "The object and, for a table, a materialized view or a sequence, its data are gone.", `${DOCS}/sql-droptable.html`),
274
310
  } as const satisfies Record<string, PgClassifierRule>;
275
311
 
@@ -28,6 +28,8 @@ export interface PgDiffObject extends CanonicalPgObject {
28
28
  foreign?: string;
29
29
  /** The export name, against a server. */
30
30
  exportName?: string;
31
+ /** What depends on a routine on the server (a view, a trigger, a default), which a DROP FUNCTION would refuse. */
32
+ dependents?: string[];
31
33
  }
32
34
 
33
35
  export type PgSchemaObject = Keyed<PgDiffObject>;
@@ -94,10 +96,22 @@ export function pgSchemaFromBuildFile(path: string, defaultSchema = "public"): P
94
96
  return pgSchemaFromBuildOutput(readFileSync(path, "utf-8"), defaultSchema);
95
97
  }
96
98
 
97
- /** The identity an object has on a server: its kind's namespace and its qualified name. */
98
- export function qualifiedKey(o: { kind: string; schema?: string; name: string }): string {
99
- const space = o.kind === "schema" ? "schema" : o.kind === "extension" ? "extension" : o.kind === "enum" || o.kind === "domain" ? "type" : "relation";
100
- return `${space} ${o.schema ? `${o.schema}.` : ""}${o.name}`;
99
+ /**
100
+ * The namespace an object's name is unique in: relations (tables, views,
101
+ * sequences, indexes) share one per schema, types and domains another,
102
+ * functions and procedures a third (told apart by their parameter types),
103
+ * and a trigger's name is unique on its table.
104
+ */
105
+ export function pgNamespace(kind: string): string {
106
+ if (kind === "schema" || kind === "extension" || kind === "trigger") return kind;
107
+ if (kind === "enum" || kind === "domain") return "type";
108
+ if (kind === "function" || kind === "procedure") return "routine";
109
+ return "relation";
110
+ }
111
+
112
+ /** The identity an object has on a server: its kind's namespace, its qualified name, and its signature where it has one. */
113
+ export function qualifiedKey(o: { kind: string; schema?: string; name: string; signature?: string }): string {
114
+ return `${pgNamespace(o.kind)} ${o.schema ? `${o.schema}.` : ""}${o.name}${o.signature ?? ""}`;
101
115
  }
102
116
 
103
117
  /** The declared objects re-keyed by qualified name, the only identity a server has. */
@@ -107,8 +121,13 @@ export function keyedByQualifiedName(objects: readonly PgSchemaObject[]): PgSche
107
121
 
108
122
  /** What a server holds, in the diff's form, keyed by qualified name. */
109
123
  export function pgSchemaFromLive(live: readonly LivePgObject[], defaultSchema: string): PgSchemaObject[] {
110
- return live.map((o) => {
111
- const canonical = { ...diffObject(o.type, o.statement, defaultSchema), ...(o.foreign ? { foreign: o.foreign } : {}) };
124
+ // A routine with a SQL-standard body is not one a declaration can hold (`../parser.ts`): never chant's, never compared.
125
+ return live.filter((o) => !o.unsupported).map((o) => {
126
+ const canonical = {
127
+ ...diffObject(o.type, o.statement, defaultSchema),
128
+ ...(o.foreign ? { foreign: o.foreign } : {}),
129
+ ...(o.dependents && o.dependents.length > 0 ? { dependents: o.dependents } : {}),
130
+ };
112
131
  return { key: qualifiedKey(canonical), canonical };
113
132
  });
114
133
  }
@@ -88,6 +88,26 @@ async function tableExpressions(client: PostgresClient, o: CanonicalPgObject, de
88
88
  });
89
89
  }
90
90
 
91
+ /** A trigger's WHEN condition as the server prints it, canonical. */
92
+ async function triggerWhen(client: PostgresClient, o: CanonicalPgObject, defaultSchema: string): Promise<string | undefined> {
93
+ const when = o.fields.when;
94
+ // An INSTEAD OF trigger, on a view, takes no WHEN.
95
+ if (typeof when !== "string" || o.fields.timing === "instead of") return undefined;
96
+ const events = ((o.fields.events as string[] | undefined) ?? []).map((e) => e.replace(/^(\w+)( of )?/, (_m, ev: string, of?: string) => `${ev.toUpperCase()}${of ? " OF " : ""}`));
97
+ return rolledBack(client, defaultSchema, async () => {
98
+ await client.query(`CREATE TEMP TABLE chant_normalize (LIKE ${String(o.fields.table)})`);
99
+ await client.query(`CREATE FUNCTION pg_temp.chant_normalize() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NULL; END'`);
100
+ await client.query(
101
+ `CREATE TRIGGER chant_normalize ${String(o.fields.timing).toUpperCase()} ${events.join(" OR ")} ON pg_temp.chant_normalize ` +
102
+ `FOR EACH ${o.fields.forEach === "row" ? "ROW" : "STATEMENT"} WHEN (${when}) EXECUTE FUNCTION pg_temp.chant_normalize()`,
103
+ );
104
+ await emptyPath(client);
105
+ const [row] = await client.query<Row>(`SELECT pg_catalog.pg_get_triggerdef(t.oid, true) AS def FROM pg_catalog.pg_trigger t WHERE t.tgrelid = 'pg_temp.chant_normalize'::pg_catalog.regclass`);
106
+ const m = / WHEN \((.*)\) EXECUTE (?:FUNCTION|PROCEDURE) /s.exec(String(row?.def ?? ""));
107
+ return m ? canonicalExpr(m[1]!) : undefined;
108
+ });
109
+ }
110
+
91
111
  /**
92
112
  * The declared object with its expressions in the server's own printing,
93
113
  * when the server answers; the object unchanged otherwise. Only the
@@ -99,6 +119,10 @@ export async function serverNormalized<T extends CanonicalPgObject & { outputs?:
99
119
  // A declared column list names the outputs; otherwise the server's names do (`SELECT *` expanded).
100
120
  return q === undefined ? o : { ...o, fields: { ...o.fields, query: q.query }, ...(o.fields.columns === undefined ? { outputs: q.outputs } : {}) };
101
121
  }
122
+ if (o.kind === "trigger") {
123
+ const when = await triggerWhen(client, o, defaultSchema);
124
+ return when === undefined ? o : { ...o, fields: { ...o.fields, when } };
125
+ }
102
126
  if (o.kind === "table") {
103
127
  const e = await tableExpressions(client, o, defaultSchema);
104
128
  if (!e) return o;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Functions, procedures and triggers (#3680): what the tags parse and refuse,
3
+ * how a change to each is classified and made, and how an import writes them.
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { func, procedure, schema, SqlTemplateError, table, trigger } from "./entities";
8
+ import { diffObject, keyedByQualifiedName, pgSchemaFromLive, type PgSchemaObject } from "./plan/schema";
9
+ import { diffPgSchemas, matchPgObjects } from "./plan/diff";
10
+ import { alterSteps, dropStatement } from "./apply/statements";
11
+ import { declaredObjects } from "./apply/apply";
12
+ import type { LivePgObject } from "./live/catalog";
13
+ import { objectsToIR } from "./import/ir";
14
+ import { PostgresGenerator } from "./import/generator";
15
+
16
+ const app = schema`CREATE SCHEMA app`;
17
+ const users = table`CREATE TABLE ${app}.users (id bigint PRIMARY KEY, email text, updated_at timestamptz)`;
18
+
19
+ describe("the tags", () => {
20
+ test("a function: its signature, attributes and body as written; an interpolation in the body is a reference", () => {
21
+ const count = func`
22
+ CREATE FUNCTION ${app}.user_count(min_id int = 0, OUT n bigint)
23
+ LANGUAGE sql STABLE STRICT PARALLEL SAFE SET search_path = ${app}
24
+ AS $$ SELECT count(*) FROM ${users} WHERE ${users.columns.id} >= min_id $$;
25
+ COMMENT ON FUNCTION ${app}.user_count(int) IS 'Users'`;
26
+ expect(count.entityType).toBe("Postgres::Function");
27
+ expect(count.sqlName).toBe("app.user_count");
28
+ expect(count.props).toMatchObject({
29
+ schema: "app",
30
+ name: "user_count",
31
+ args: [
32
+ { mode: "in", name: "min_id", type: "int", default: "0" },
33
+ { mode: "out", name: "n", type: "bigint" },
34
+ ],
35
+ language: "sql",
36
+ volatility: "stable",
37
+ strict: true,
38
+ parallel: "safe",
39
+ set: { search_path: "app" },
40
+ body: " SELECT count(*) FROM app.users WHERE id >= min_id ",
41
+ comment: "Users",
42
+ });
43
+ expect(count.dependsOn).toContain(users);
44
+ expect(count.props.reads).toEqual([users]);
45
+ });
46
+
47
+ test("a procedure and a trigger", () => {
48
+ const archive = procedure`CREATE PROCEDURE ${app}.archive(IN before bigint, INOUT n int DEFAULT NULL) LANGUAGE plpgsql AS 'BEGIN n := 0; END'`;
49
+ expect(archive.props).toMatchObject({ args: [{ mode: "in", name: "before" }, { mode: "inout", name: "n", default: "NULL" }], body: "BEGIN n := 0; END" });
50
+ const touch = func`CREATE FUNCTION ${app}.touch() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END $$`;
51
+ const t = trigger`CREATE TRIGGER users_touch BEFORE INSERT OR UPDATE OF email ON ${users} FOR EACH ROW WHEN (NEW.email IS NOT NULL) EXECUTE FUNCTION ${touch}('x', 1)`;
52
+ expect(t.entityType).toBe("Postgres::Trigger");
53
+ expect(t.props).toMatchObject({
54
+ schema: "app",
55
+ name: "users_touch",
56
+ timing: "before",
57
+ events: [{ event: "insert" }, { event: "update", columns: ["email"] }],
58
+ tableName: "app.users",
59
+ forEach: "row",
60
+ when: "NEW.email IS NOT NULL",
61
+ functionName: "app.touch",
62
+ args: ["'x'", "1"],
63
+ });
64
+ expect(t.dependsOn).toEqual([users, touch]);
65
+ });
66
+
67
+ test("a SQL-standard body, SET FROM CURRENT and a qualified trigger name are refused", () => {
68
+ expect(() => func`CREATE FUNCTION app.f(a int) RETURNS int LANGUAGE sql RETURN a + 1`).toThrow(/SQL-standard body/);
69
+ expect(() => func`CREATE FUNCTION app.f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; END`).toThrow(/SQL-standard body/);
70
+ expect(() => func`CREATE FUNCTION app.f() RETURNS int LANGUAGE sql SET search_path FROM CURRENT AS 'select 1'`).toThrow(/FROM CURRENT/);
71
+ expect(() => procedure`CREATE PROCEDURE app.p() LANGUAGE sql STABLE AS 'select 1'`).toThrow(/not an attribute of a procedure/);
72
+ expect(() => trigger`CREATE TRIGGER app.t BEFORE INSERT ON app.users EXECUTE FUNCTION app.f()`).toThrow(/not schema-qualified/);
73
+ expect(() => func`CREATE TABLE app.t (a int)`).toThrow(SqlTemplateError);
74
+ });
75
+ });
76
+
77
+ // ── Classifying and making changes ─────────────────────────────────────
78
+
79
+ const F = "Postgres::Function";
80
+ const TR = "Postgres::Trigger";
81
+ const live = (type: string, statement: string, extra: Partial<LivePgObject> = {}) => pgSchemaFromLive([{ type, statement, name: "", oid: "1", ...extra } as LivePgObject], "public");
82
+ const declared = (type: string, ddl: string): PgSchemaObject[] => keyedByQualifiedName([{ key: "x", canonical: { ...diffObject(type, ddl, "public"), exportName: "x" } }]);
83
+ const rules = (type: string, before: string, after: string, extra: Partial<LivePgObject> = {}) =>
84
+ diffPgSchemas(live(type, before, extra), declared(type, after)).changes.map((c) => [c.field, c.rule]);
85
+
86
+ const FN = (body: string, returns = "integer", args = "a integer") => `CREATE FUNCTION app.f(${args}) RETURNS ${returns} LANGUAGE sql AS $$ ${body} $$`;
87
+ const SERVER_FN = "CREATE OR REPLACE FUNCTION app.f(a integer)\n RETURNS integer\n LANGUAGE sql\nAS $function$ select a $function$\n";
88
+
89
+ describe("the classifier", () => {
90
+ test("the server's printing of a function is the declaration's", () => {
91
+ expect(rules(F, SERVER_FN, FN("select a", "int", "a int"))).toEqual([]);
92
+ });
93
+
94
+ test("a new body or attribute is CREATE OR REPLACE; a new result is drop and create, or expand and contract when something depends on it", () => {
95
+ expect(rules(F, SERVER_FN, FN("select a + 1"))).toEqual([["body", "SQLPG280"]]);
96
+ expect(rules(F, SERVER_FN, FN("select a", "bigint"))).toEqual([["returns", "SQLPG281"]]);
97
+ expect(rules(F, SERVER_FN, FN("select a", "integer", "b integer"))).toEqual([["args", "SQLPG281"]]);
98
+ expect(rules(F, SERVER_FN, FN("select a", "bigint"), { dependents: ["view app.v"] })).toEqual([["returns", "SQLPG282"]]);
99
+ });
100
+
101
+ test("a trigger: created on an existing table under SHARE ROW EXCLUSIVE, changed in place, dropped", () => {
102
+ const T1 = "CREATE TRIGGER t BEFORE UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()";
103
+ const T2 = "CREATE TRIGGER t BEFORE INSERT OR UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()";
104
+ expect(diffPgSchemas([], declared(TR, T1)).changes.map((c) => c.rule)).toEqual(["SQLPG283"]);
105
+ expect(rules(TR, T1, T2)).toEqual([["events", "SQLPG284"]]);
106
+ expect(diffPgSchemas(live(TR, T1), []).changes.map((c) => [c.rule, c.class])).toEqual([["SQLPG285", "drop"]]);
107
+ });
108
+ });
109
+
110
+ function steps(type: string, liveDdl: string, declaredDdl: string): string[] {
111
+ const [obj] = declaredObjects(JSON.stringify({ dialect: "postgres", objects: [{ export: "x", type, ddl: declaredDdl, dependsOn: [] }] }));
112
+ const after = keyedByQualifiedName([{ key: "x", canonical: obj!.canonical }]);
113
+ const before = live(type, liveDdl);
114
+ const m = matchPgObjects(before, after).find((x) => x.kind === "matched")!;
115
+ return alterSteps(obj!, m.before!.canonical, diffPgSchemas(before, after).changes, { major: 18 }).steps.map((s) => s.sql);
116
+ }
117
+
118
+ describe("the statements", () => {
119
+ test("OR REPLACE, or a drop and a create named by the parameter types", () => {
120
+ expect(steps(F, SERVER_FN, FN("select a + 1"))).toEqual(["CREATE OR REPLACE FUNCTION app.f(a integer) RETURNS integer LANGUAGE sql AS $$ select a + 1 $$"]);
121
+ const recreate = steps(F, SERVER_FN, FN("select a", "bigint"));
122
+ expect(recreate[0]).toBe("DROP FUNCTION app.f(integer)");
123
+ expect(recreate[1]).toBe("CREATE FUNCTION app.f(a integer) RETURNS bigint LANGUAGE sql AS $$ select a $$");
124
+ expect(recreate[2]).toMatch(/^COMMENT ON FUNCTION app\.f\(integer\) IS /);
125
+ expect(dropStatement("procedure", "app", "p", "(integer,text)").sql).toBe("DROP PROCEDURE app.p(integer,text)");
126
+ });
127
+
128
+ test("a trigger is replaced in place; a constraint trigger is dropped and created", () => {
129
+ const T1 = "CREATE TRIGGER t BEFORE UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()";
130
+ expect(steps(TR, T1, T1.replace("UPDATE", "INSERT"))).toEqual(["CREATE OR REPLACE TRIGGER t BEFORE INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()"]);
131
+ const C1 = "CREATE CONSTRAINT TRIGGER t AFTER UPDATE ON app.users DEFERRABLE FOR EACH ROW EXECUTE FUNCTION app.touch()";
132
+ expect(steps(TR, C1, C1.replace("UPDATE", "INSERT")).slice(0, 2)).toEqual(["DROP TRIGGER t ON app.users", C1.replace("UPDATE", "INSERT")]);
133
+ expect(dropStatement("trigger", "app", "t", " ON app.users").sql).toBe("DROP TRIGGER t ON app.users");
134
+ });
135
+ });
136
+
137
+ describe("importing", () => {
138
+ test("functions and triggers are written as func and trigger declarations, their references interpolated", () => {
139
+ const content = new PostgresGenerator().generate(
140
+ objectsToIR([
141
+ { type: "Postgres::Table", schema: "app", name: "users", ddl: "CREATE TABLE app.users (\n id bigint\n)" },
142
+ { type: "Postgres::Function", schema: "app", name: "touch", ddl: "CREATE OR REPLACE FUNCTION app.touch()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$ BEGIN RETURN NEW; END $function$" },
143
+ { type: "Postgres::Trigger", schema: "app", name: "users_touch", ddl: "CREATE TRIGGER users_touch BEFORE UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()" },
144
+ ]),
145
+ )[0]!.content;
146
+ expect(content).toContain('import { func, table, trigger } from "@intentius/chant-lexicon-sql/postgres";');
147
+ expect(content).toContain("export const touch = func`");
148
+ expect(content).toContain("ON ${users} FOR EACH ROW EXECUTE FUNCTION ${touch}()");
149
+ });
150
+ });
package/src/postgres.ts CHANGED
@@ -15,6 +15,9 @@ export {
15
15
  type,
16
16
  domain,
17
17
  extension,
18
+ func,
19
+ procedure,
20
+ trigger,
18
21
  literal,
19
22
  SqlLiteral,
20
23
  SqlTemplateError,
@@ -33,6 +36,9 @@ export {
33
36
  type PostgresEnum,
34
37
  type PostgresDomain,
35
38
  type PostgresExtension,
39
+ type PostgresFunction,
40
+ type PostgresProcedure,
41
+ type PostgresTrigger,
36
42
  type ColumnDef,
37
43
  type KeyDef,
38
44
  type CheckDef,
@@ -46,6 +52,11 @@ export {
46
52
  type EnumProps,
47
53
  type DomainProps,
48
54
  type ExtensionProps,
55
+ type RoutineProps,
56
+ type RoutineArgDef,
57
+ type FunctionProps,
58
+ type ProcedureProps,
59
+ type TriggerProps,
49
60
  type LineageEdge,
50
61
  } from "./postgres/entities";
51
62
 
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { environmentOf, receiptDialect, sqlReceiptStore } from "./receipts";
3
+ import type { PostgresClient } from "./postgres/live/client";
4
+
5
+ const ref = (effect: string) => ({ name: effect, effect, flavor: "existence" as const, inputs: {} });
6
+
7
+ /** A Postgres client that records each statement and answers the store's catalog reads. */
8
+ function fakePostgres(state: { schema?: boolean; table?: string | null } = {}) {
9
+ const statements: string[] = [];
10
+ let schema = state.schema ?? false;
11
+ let table: string | null | undefined = state.table;
12
+ const rows = new Map<string, string>();
13
+ const client: PostgresClient = {
14
+ async query<T>(sql: string, params: readonly unknown[] = []): Promise<T[]> {
15
+ statements.push(sql);
16
+ if (sql.includes("pg_namespace WHERE nspname")) return [{ n: schema ? 1 : 0 }] as T[];
17
+ if (sql.startsWith("CREATE SCHEMA")) schema = true;
18
+ if (sql.includes("to_regclass($1) IS NOT NULL AS present")) return [{ present: table !== undefined, comment: table ?? null }] as T[];
19
+ if (sql.startsWith("CREATE TABLE")) table = null;
20
+ if (sql.startsWith("COMMENT ON TABLE")) table = sql.slice(sql.indexOf("IS '") + 4, -1);
21
+ if (sql.startsWith("INSERT INTO")) rows.set(String(params[0]), String(params[2]));
22
+ if (sql.startsWith("SELECT expectation")) {
23
+ const v = rows.get(String(params[0]));
24
+ return (v === undefined ? [] : [{ expectation: v }]) as T[];
25
+ }
26
+ return [] as T[];
27
+ },
28
+ end: async () => undefined,
29
+ };
30
+ return { client, statements, rows };
31
+ }
32
+
33
+ describe("receiptDialect (#3657)", () => {
34
+ test("the environment's profile decides, by its URL's scheme", () => {
35
+ const config = { sql: { profiles: { pg: { url: "postgres://db:5432/app" }, ch: { url: "http://ch:8123" } } } };
36
+ expect(receiptDialect(config, "pg", {})).toBe("postgres");
37
+ expect(receiptDialect(config, "ch", {})).toBe("clickhouse");
38
+ });
39
+
40
+ test("with no profile, sql.dialect, then whichever server variable is set", () => {
41
+ expect(receiptDialect({ sql: { dialect: "postgres" } }, "dev", {})).toBe("postgres");
42
+ expect(receiptDialect({ sql: { dialect: ["clickhouse"] } }, undefined, { POSTGRES_URL: "postgres://x/y" })).toBe("clickhouse");
43
+ expect(receiptDialect(undefined, undefined, { POSTGRES_URL: "postgres://x/y" })).toBe("postgres");
44
+ expect(receiptDialect(undefined, undefined, {})).toBe("clickhouse");
45
+ });
46
+ });
47
+
48
+ describe("environmentOf (#3657)", () => {
49
+ test("the option, then CHANT_ENV, then a literal ownership.env", () => {
50
+ expect(environmentOf({ environment: "a" }, { ownership: { stack: "s", env: "c" } }, { CHANT_ENV: "b" })).toBe("a");
51
+ expect(environmentOf({}, { ownership: { stack: "s", env: "c" } }, { CHANT_ENV: "b" })).toBe("b");
52
+ expect(environmentOf({}, { ownership: { stack: "s", env: "c" } }, {})).toBe("c");
53
+ expect(environmentOf({}, undefined, {})).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe("sqlReceiptStore over Postgres (#3657)", () => {
58
+ test("keeps receipts in chant_receipts.receipts, a schema and table it marks as chant's", async () => {
59
+ const pg = fakePostgres();
60
+ const store = sqlReceiptStore({ environment: "prod", stack: "shop", postgres: pg.client, runId: "r1" });
61
+ expect(await store.read(ref("fill/b1"))).toBeUndefined();
62
+ await store.write(ref("fill/b1"), "present");
63
+ expect(await store.read(ref("fill/b1"))).toBe("present");
64
+
65
+ expect(pg.statements).toContain("CREATE SCHEMA IF NOT EXISTS chant_receipts");
66
+ expect(pg.statements.find((s) => s.startsWith("COMMENT ON SCHEMA chant_receipts"))).toContain("receipts=effects");
67
+ expect(pg.statements.find((s) => s.startsWith("CREATE TABLE"))).toContain("TABLE IF NOT EXISTS chant_receipts.receipts (");
68
+ expect([...pg.rows.keys()]).toEqual(["shop/prod/fill/b1"]);
69
+ expect(await store.location()).toEqual({ dialect: "postgres", table: "chant_receipts.receipts", source: "the caller's connection", address: "shop/prod/<effect>" });
70
+ });
71
+
72
+ test("a receiptsSchema keeps them in <schema>.__chant_receipts, and creates no schema", async () => {
73
+ const pg = fakePostgres();
74
+ const store = sqlReceiptStore({
75
+ environment: "prod",
76
+ postgres: pg.client,
77
+ config: { ownership: { stack: "shop" }, sql: { profiles: { prod: { url: "postgres://db/app", receiptsSchema: "app" } } } },
78
+ });
79
+ await store.write(ref("fill/b1"), "present");
80
+ expect(pg.statements.some((s) => s.includes("CREATE SCHEMA"))).toBe(false);
81
+ expect(pg.statements.find((s) => s.startsWith("CREATE TABLE"))).toContain("TABLE IF NOT EXISTS app.__chant_receipts (");
82
+ expect((await store.location()).table).toBe("app.__chant_receipts");
83
+ });
84
+
85
+ test("a table of the user's under the receipts name is refused, not written into", async () => {
86
+ const pg = fakePostgres({ schema: true, table: "the user's own receipts" });
87
+ const store = sqlReceiptStore({ environment: "prod", stack: "shop", postgres: pg.client });
88
+ await expect(store.write(ref("fill/b1"), "present")).rejects.toThrow(/chant_receipts\.receipts exists and is not chant's receipts table/);
89
+ expect(pg.rows.size).toBe(0);
90
+ });
91
+ });