@intentius/chant-lexicon-sql 0.124.14 → 0.124.16

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 (61) hide show
  1. package/dist/change-set.d.ts +174 -0
  2. package/dist/change-set.d.ts.map +1 -0
  3. package/dist/composites/audit-log-table.d.ts +12 -4
  4. package/dist/composites/audit-log-table.d.ts.map +1 -1
  5. package/dist/composites/cdc-mirror.d.ts +11 -4
  6. package/dist/composites/cdc-mirror.d.ts.map +1 -1
  7. package/dist/composites/events-table.d.ts +10 -3
  8. package/dist/composites/events-table.d.ts.map +1 -1
  9. package/dist/composites/join-table.d.ts +11 -3
  10. package/dist/composites/join-table.d.ts.map +1 -1
  11. package/dist/composites/refreshed-view.d.ts +11 -3
  12. package/dist/composites/refreshed-view.d.ts.map +1 -1
  13. package/dist/composites/replacing-table.d.ts +10 -3
  14. package/dist/composites/replacing-table.d.ts.map +1 -1
  15. package/dist/composites/rollup-view.d.ts +11 -4
  16. package/dist/composites/rollup-view.d.ts.map +1 -1
  17. package/dist/composites/sharded-table.d.ts +11 -4
  18. package/dist/composites/sharded-table.d.ts.map +1 -1
  19. package/dist/composites/soft-delete-table.d.ts +12 -4
  20. package/dist/composites/soft-delete-table.d.ts.map +1 -1
  21. package/dist/composites/tenant-table.d.ts +11 -3
  22. package/dist/composites/tenant-table.d.ts.map +1 -1
  23. package/dist/composites/typed.d.ts +13 -0
  24. package/dist/composites/typed.d.ts.map +1 -0
  25. package/dist/core/typed.d.ts +22 -0
  26. package/dist/core/typed.d.ts.map +1 -1
  27. package/dist/integrity.json +3 -3
  28. package/dist/lint/rules/sqlpg005.d.ts +5 -0
  29. package/dist/lint/rules/sqlpg005.d.ts.map +1 -1
  30. package/dist/manifest.json +1 -1
  31. package/dist/op/activities/clickhouse-rebuild.d.ts +6 -0
  32. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  33. package/dist/op/activities/postgres-migration.d.ts +6 -0
  34. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  35. package/dist/op/activity-contracts.d.ts +30 -0
  36. package/dist/op/activity-contracts.d.ts.map +1 -1
  37. package/dist/postgres/entities.d.ts +12 -3
  38. package/dist/postgres/entities.d.ts.map +1 -1
  39. package/dist/rules/sqlpg005.ts +11 -3
  40. package/package.json +2 -2
  41. package/src/change-set.test.ts +246 -0
  42. package/src/change-set.ts +354 -0
  43. package/src/composites/audit-log-table.ts +11 -4
  44. package/src/composites/cdc-mirror.ts +11 -4
  45. package/src/composites/events-table.ts +8 -3
  46. package/src/composites/join-table.ts +10 -3
  47. package/src/composites/refreshed-view.ts +10 -3
  48. package/src/composites/replacing-table.ts +8 -3
  49. package/src/composites/rollup-view.ts +11 -4
  50. package/src/composites/sharded-table.ts +9 -4
  51. package/src/composites/soft-delete-table.ts +11 -4
  52. package/src/composites/tenant-table.ts +10 -3
  53. package/src/composites/typed.ts +15 -0
  54. package/src/core/typed.ts +40 -0
  55. package/src/lint/rules/sqlpg005.ts +11 -3
  56. package/src/lint/rules/typed-names.test.ts +12 -0
  57. package/src/op/activities/clickhouse-rebuild.ts +11 -1
  58. package/src/op/activities/postgres-migration.ts +11 -1
  59. package/src/op/activity-contracts.ts +6 -2
  60. package/src/postgres/entities.ts +13 -4
  61. package/src/typed-columns.e2e.test.ts +17 -0
@@ -11,6 +11,8 @@
11
11
 
12
12
  import { Composite } from "@intentius/chant/composite";
13
13
  import { index, table, type PostgresIndex, type PostgresSchema, type PostgresTable } from "../postgres/entities";
14
+ import type { PostgresMemberName } from "../core/typed";
15
+ import type { Instance, TypedComposite } from "./typed";
14
16
 
15
17
  export interface TenantTableProps {
16
18
  /** The table's name, unqualified. The index is `<name>_tenant_idx`. */
@@ -29,13 +31,18 @@ export interface TenantTableProps {
29
31
  tenantType?: string;
30
32
  }
31
33
 
32
- export type TenantTableMembers = {
33
- table: PostgresTable;
34
+ /** `Table` is the table's qualified name, when the call's props give it (chant#3787). */
35
+ export type TenantTableMembers<Table extends string = string> = {
36
+ table: PostgresTable<Table>;
34
37
  index: PostgresIndex;
35
38
  };
36
39
 
40
+ type TenantTableCall = <const N extends string, const S extends PostgresSchema | string = "public">(
41
+ props: TenantTableProps & { name: N; schema?: S },
42
+ ) => Instance<TenantTableMembers<PostgresMemberName<S, N>>>;
43
+
37
44
  /** A multi-tenant table with the tenant key first in its primary key and its index. */
38
- export const TenantTable = Composite<TenantTableProps, TenantTableMembers>((props) => {
45
+ export const TenantTable = Composite<TenantTableProps, TenantTableMembers, TypedComposite<TenantTableProps, TenantTableMembers, TenantTableCall>>((props) => {
39
46
  const schema = props.schema ?? "public";
40
47
  const tenant = props.tenant ?? "tenant_id";
41
48
  const rows = table`
@@ -0,0 +1,15 @@
1
+ /**
2
+ * chant#3787 — the call signature a composite is typed with, so a member's
3
+ * `.columns` are the generated `SqlSchema` entry for the name its props give
4
+ * it. `users.table.columns.emial` then fails tsc as `table<"app.users">`'s
5
+ * would. The definition is unchanged at run time: `Composite`'s third type
6
+ * argument carries this signature in front of the plain one.
7
+ */
8
+
9
+ import type { CompositeDefinition, CompositeFactoryMembers, CompositeInstance } from "@intentius/chant/composite";
10
+
11
+ /** What a typed composite call returns: the instance, with its members' types. */
12
+ export type Instance<M extends CompositeFactoryMembers> = CompositeInstance<M> & M;
13
+
14
+ /** A composite definition whose precise, generic `Call` signature is tried before the plain `(props: P) => M`. */
15
+ export type TypedComposite<P, M extends CompositeFactoryMembers, Call> = Call & CompositeDefinition<P, M>;
package/src/core/typed.ts CHANGED
@@ -109,6 +109,46 @@ export type RowIn<Registry, T> = T extends string
109
109
  ? RowFromColumns<ColumnsIn<Registry, T>>
110
110
  : T extends Named<infer N> ? RowFromColumns<ColumnsIn<Registry, N>> : never;
111
111
 
112
+ // ── Composite members ───────────────────────────────────────────────────
113
+
114
+ /** Whether `S` is a name the type system knows: a literal, not `string` and not {@link Unnamed}. */
115
+ type Known<S> = [S] extends [Unnamed] ? false : string extends S ? false : true;
116
+
117
+ /** A piece of a name a composite's props give: the literal, the name a {@link Named} entity carries, or {@link Unnamed}. */
118
+ type NamePiece<V> = [V] extends [string]
119
+ ? Known<V> extends true ? V : Unnamed
120
+ : [V] extends [Named<infer N>] ? (Known<N> extends true ? N : Unnamed) : Unnamed;
121
+
122
+ /**
123
+ * chant#3787 — the qualified name of a relation a Postgres composite declares
124
+ * as `${schema}.${name}${suffix}`: `schema` is the composite's `schema` prop (a
125
+ * string, or a schema whose tag was given its name, `schema<"app">`), `name`
126
+ * its `name` prop. Postgres folds both, as the build does. {@link Unnamed},
127
+ * so the columns stay unchecked, when either piece is not a known literal.
128
+ */
129
+ export type PostgresMemberName<Schema, Name, Suffix extends string = ""> = [NamePiece<Schema>, NamePiece<Name>] extends [
130
+ infer S extends string,
131
+ infer N extends string,
132
+ ]
133
+ ? Known<S> extends true
134
+ ? Known<N> extends true
135
+ ? `${Lowercase<S>}.${Lowercase<N>}${Suffix}`
136
+ : Unnamed
137
+ : Unnamed
138
+ : Unnamed;
139
+
140
+ /**
141
+ * chant#3787 — the name of a relation a ClickHouse composite declares,
142
+ * `${name}${suffix}`, where `name` is its `name` prop as written (`events` or
143
+ * `analytics.events`). ClickHouse does not fold names. {@link Unnamed} when
144
+ * the name is not a known literal.
145
+ */
146
+ export type ClickHouseMemberName<Name, Suffix extends string = ""> = NamePiece<Name> extends infer N extends string
147
+ ? Known<N> extends true
148
+ ? `${N}${Suffix}`
149
+ : Unnamed
150
+ : Unnamed;
151
+
112
152
  // ── The slot record ─────────────────────────────────────────────────────
113
153
 
114
154
  /**
@@ -1,7 +1,7 @@
1
1
  import type { LintContext, LintDiagnostic, LintRule } from "@intentius/chant/lint/rule";
2
2
  import { parseStatements, SqlSyntaxError } from "../../postgres/parser";
3
3
  import { findPostgresTemplates, postgresTokensOf } from "./postgres-templates";
4
- import { declaredText, generatedNamesFor, nameAgrees, normalizedName, SQL_TYPES_FILE, typeArgumentName } from "./typed-names";
4
+ import { declaredText, generatedNamesFor, nameAgrees, normalizedName, splitQualified, SQL_TYPES_FILE, typeArgumentName } from "./typed-names";
5
5
 
6
6
  const MODULE = "@intentius/chant-lexicon-sql/postgres";
7
7
 
@@ -19,6 +19,11 @@ const MODULE = "@intentius/chant-lexicon-sql/postgres";
19
19
  * generated file, only the first check runs. A name missing from the file is
20
20
  * a warning, since a table added since the last `chant sql types` is stale
21
21
  * and `chant sql types --check` is what fails on it.
22
+ *
23
+ * chant#3787: `schema<"app">\`CREATE SCHEMA app\`` is read too, since a
24
+ * composite placed in the schema builds its members' qualified names from
25
+ * that argument. A schema is not in the generated file, so only the name
26
+ * comparison runs, and the argument is the schema's own name, one piece.
22
27
  */
23
28
  export const sqlpg005: LintRule = {
24
29
  id: "SQLPG005",
@@ -31,7 +36,7 @@ export const sqlpg005: LintRule = {
31
36
  const out: LintDiagnostic[] = [];
32
37
  let listed: Set<string> | undefined | null = null;
33
38
  for (const found of findPostgresTemplates(source)) {
34
- if (found.tag !== "table" && found.tag !== "view") continue;
39
+ if (found.tag !== "table" && found.tag !== "view" && found.tag !== "schema") continue;
35
40
  const arg = typeArgumentName(found.node);
36
41
  if (!arg) continue;
37
42
  const at = (() => {
@@ -42,12 +47,14 @@ export const sqlpg005: LintRule = {
42
47
  try {
43
48
  const node = parseStatements(postgresTokensOf(found))[0];
44
49
  if (node && (node.statement === "table" || node.statement === "view")) declared = node.name.pieces;
50
+ else if (node && node.statement === "schema" && found.tag === "schema") declared = node.name?.pieces;
45
51
  } catch (err) {
46
52
  if (err instanceof SqlSyntaxError) continue; // SQLPG001 reports it
47
53
  throw err;
48
54
  }
49
55
  if (!declared) continue; // SQLPG001: the tag holds another statement
50
- if (!nameAgrees(declared, arg.name)) {
56
+ const agrees = found.tag === "schema" ? splitQualified(arg.name).length === 1 && nameAgrees(declared, arg.name) : nameAgrees(declared, arg.name);
57
+ if (!agrees) {
51
58
  out.push({
52
59
  ruleId: "SQLPG005",
53
60
  severity: "error",
@@ -57,6 +64,7 @@ export const sqlpg005: LintRule = {
57
64
  });
58
65
  continue;
59
66
  }
67
+ if (found.tag === "schema") continue;
60
68
  if (listed === null) listed = generatedNamesFor(context.filePath, MODULE);
61
69
  if (listed && !listed.has(normalizedName(arg.name))) {
62
70
  out.push({
@@ -113,6 +113,18 @@ describe("SQLPG005: a typed Postgres tag's type argument", () => {
113
113
  const code = `${PGX}export const o = table<"shop.orders">\`CREATE TABLE shop.orders (id bigint)\`;`;
114
114
  expect(sqlpg005.check(ctx(code, file))).toEqual([]);
115
115
  });
116
+
117
+ // chant#3787 — a composite builds its members' names from the schema's type argument.
118
+ test("a schema's type argument must be its name, and is not looked for in the generated file", () => {
119
+ const SCX = `import { schema } from "${PG}";\n`;
120
+ const file = project(PG, ["shop.orders"]);
121
+ expect(sqlpg005.check(ctx(`${SCX}export const s = schema<"shop">\`CREATE SCHEMA Shop\`;`, file))).toEqual([]);
122
+ const [d, ...rest] = sqlpg005.check(ctx(`${SCX}export const s = schema<"shp">\`CREATE SCHEMA shop\`;`, file));
123
+ expect(rest).toEqual([]);
124
+ expect(d).toMatchObject({ ruleId: "SQLPG005", severity: "error", line: 2 });
125
+ expect(sqlpg005.check(ctx(`${SCX}export const s = schema<"x.shop">\`CREATE SCHEMA shop\`;`))).toHaveLength(1);
126
+ expect(sqlpg005.check(ctx(`${SCX}export const s = schema\`CREATE SCHEMA shop\`;`))).toEqual([]);
127
+ });
116
128
  });
117
129
 
118
130
  describe("SQLCH004: a typed ClickHouse tag's type argument", () => {
@@ -34,6 +34,9 @@ import type { ClickHouseRebuildArgs } from "../../clickhouse/rebuild/op";
34
34
  import { CLASSIFIER_RULES } from "../../clickhouse/plan/rules";
35
35
  import { clusterSharding } from "../../clickhouse/rebuild/shards";
36
36
  import { resolveMarker } from "./clickhouse-apply";
37
+ import { statementChange, type StatementChange } from "../../core/statements";
38
+ import { clickhouseRebuildPart } from "../../change-set";
39
+ import type { ChangeSetPart } from "@intentius/chant/change-set";
37
40
 
38
41
  export type { ClickHouseRebuildArgs } from "../../clickhouse/rebuild/op";
39
42
 
@@ -104,6 +107,10 @@ export interface RebuildPlanResult {
104
107
  planDigest?: string;
105
108
  /** Each classified change, as `<rule> <field>: <before> -> <after>`. */
106
109
  changes: string[];
110
+ /** The same changes, structured. */
111
+ classified: StatementChange[];
112
+ /** The plan as a change-set part (#3767), member the table: what `composeChangeSet` takes, and a gate's Cedar `context.plan`. */
113
+ part: ChangeSetPart;
107
114
  summary: string;
108
115
  }
109
116
 
@@ -125,7 +132,10 @@ async function plan(args: ClickHouseRebuildArgs, signal: AbortSignal | undefined
125
132
  : `${o.names.key} holds its declaration; nothing to rebuild`;
126
133
  run.log(`-- ${summary}`);
127
134
  for (const c of changes) run.log(`-- ${c}`);
128
- return { state: o.state, table: o.names.key, ...(o.state === "rebuild" ? { planDigest: rebuildPlanDigest(o, run.dualWrite) } : {}), changes, summary };
135
+ const classified = o.changes.map(statementChange);
136
+ const result = { state: o.state, table: o.names.key, ...(o.state === "rebuild" ? { planDigest: rebuildPlanDigest(o, run.dualWrite) } : {}), changes, classified };
137
+ const part = clickhouseRebuildPart({ member: o.names.key, plan: result, ...(args.environment !== undefined ? { environment: args.environment } : {}) });
138
+ return { ...result, part, summary };
129
139
  }
130
140
 
131
141
  export async function clickhouseRebuildCreate(args: ClickHouseRebuildArgs, signal?: AbortSignal, deps: ClickHouseRebuildDeps = {}): Promise<CreateResult> {
@@ -39,6 +39,9 @@ import {
39
39
  import { DEFAULT_REPLICATION_LAG } from "../../postgres/migrate/replication";
40
40
  import type { PostgresMigrationArgs } from "../../postgres/migrate/op";
41
41
  import { buildMajor } from "./postgres-apply";
42
+ import { statementChange, type StatementChange } from "../../core/statements";
43
+ import { postgresMigrationPart } from "../../change-set";
44
+ import type { ChangeSetPart } from "@intentius/chant/change-set";
42
45
 
43
46
  export type { PostgresMigrationArgs } from "../../postgres/migrate/op";
44
47
 
@@ -137,6 +140,10 @@ export interface MigrationPlanResult {
137
140
  planDigest?: string;
138
141
  /** Each classified change, as `<rule> <field>: <before> -> <after>`. */
139
142
  changes: string[];
143
+ /** The same changes, structured. */
144
+ classified: StatementChange[];
145
+ /** The plan as a change-set part (#3767), member the migration (`schema.table.column`): what `composeChangeSet` takes, and a gate's Cedar `context.plan`. */
146
+ part: ChangeSetPart;
140
147
  summary: string;
141
148
  }
142
149
 
@@ -164,7 +171,10 @@ export async function postgresMigrationPlan(args: PostgresMigrationArgs, signal?
164
171
  : `${n.key} holds its declaration; nothing to migrate`;
165
172
  run.log(`-- ${summary}`);
166
173
  for (const c of changes) run.log(`-- ${c}`);
167
- return { state: o.state, migration: n.key, change: n.change, ...(o.state === "migrate" ? { planDigest: migrationPlanDigest(o) } : {}), changes, summary };
174
+ const classified = o.changes.map(statementChange);
175
+ const result = { state: o.state, migration: n.key, change: n.change, ...(o.state === "migrate" ? { planDigest: migrationPlanDigest(o) } : {}), changes, classified };
176
+ const part = postgresMigrationPart({ member: n.key, plan: result, ...(args.environment !== undefined ? { environment: args.environment } : {}) });
177
+ return { ...result, part, summary };
168
178
  });
169
179
  }
170
180
 
@@ -39,12 +39,16 @@ const rebuildArgs = z.strictObject({
39
39
  });
40
40
 
41
41
  const state = z.enum(["rebuild", "swapped", "done"]);
42
+
43
+ /** A plan step's structured changes and its change-set part (#3767), as `../change-set.ts` makes it. */
44
+ const classified = z.array(z.object({ rule: z.string(), class: z.string(), field: z.string() }).passthrough());
45
+ const part = z.object({ member: z.object({ member: z.string(), planner: z.literal("sql"), planDigest: z.string().nullable() }).passthrough(), entries: z.array(z.unknown()) });
42
46
  const tableEntity = { entities: ["table"] };
43
47
 
44
48
  export const clickhouseRebuildPlanContract = activityContract(
45
49
  "clickhouseRebuildPlan",
46
50
  rebuildArgs,
47
- z.object({ state, table: z.string(), planDigest: z.string().optional(), changes: z.array(z.string()), summary: z.string() }),
51
+ z.object({ state, table: z.string(), planDigest: z.string().optional(), changes: z.array(z.string()), classified, part, summary: z.string() }),
48
52
  tableEntity,
49
53
  );
50
54
 
@@ -136,7 +140,7 @@ const migrationState = z.enum(["migrate", "switched", "done"]);
136
140
  export const postgresMigrationPlanContract = activityContract(
137
141
  "postgresMigrationPlan",
138
142
  migrationArgs,
139
- z.object({ state: migrationState, migration: z.string(), change: z.enum(["rename", "type"]), planDigest: z.string().optional(), changes: z.array(z.string()), summary: z.string() }),
143
+ z.object({ state: migrationState, migration: z.string(), change: z.enum(["rename", "type"]), planDigest: z.string().optional(), changes: z.array(z.string()), classified, part, summary: z.string() }),
140
144
  tableEntity,
141
145
  );
142
146
 
@@ -434,7 +434,12 @@ export abstract class PostgresObject extends SqlObject {
434
434
  declare readonly sqlName: string;
435
435
  }
436
436
 
437
- export interface PostgresSchema extends PostgresObject {
437
+ /**
438
+ * A schema. `N` is its name when its tag was given one
439
+ * (`` schema<"shop">`CREATE SCHEMA shop` ``), which is how a composite placed
440
+ * in it knows its members' qualified names (chant#3787).
441
+ */
442
+ export interface PostgresSchema<N extends string = string> extends PostgresObject, Named<N> {
438
443
  readonly entityType: "Postgres::Schema";
439
444
  readonly props: SchemaProps;
440
445
  }
@@ -1601,9 +1606,13 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
1601
1606
  }
1602
1607
  }
1603
1608
 
1604
- /** `` schema`CREATE SCHEMA ...` ``: one Postgres schema, parsed at build time. */
1605
- export function schema(strings: TemplateStringsArray, ...values: unknown[]): PostgresSchema {
1606
- return build("schema", strings, values) as PostgresSchema;
1609
+ /**
1610
+ * `` schema`CREATE SCHEMA ...` ``: one Postgres schema, parsed at build time.
1611
+ * Given its name as the type argument (`` schema<"shop">`...` ``), a composite
1612
+ * placed in it types its members' columns (chant#3787).
1613
+ */
1614
+ export function schema<const N extends string = Unnamed>(strings: TemplateStringsArray, ...values: unknown[]): PostgresSchema<N> {
1615
+ return build("schema", strings, values) as PostgresSchema<N>;
1607
1616
  }
1608
1617
 
1609
1618
  /**
@@ -89,4 +89,21 @@ describe("chant sql types, through tsc", () => {
89
89
  expect.stringMatching(/^src\/by-country\.ts\(7\): TS2345 Argument of type 'Column<"Nullable\(String\)", .*' is not assignable to parameter of type 'NotNullColumn'/),
90
90
  ]);
91
91
  }, 120_000);
92
+
93
+ // chant#3787 — a composite's members are typed from the names its props give
94
+ // them: `schema<"app">` or a schema by name, and the `name` prop, Postgres
95
+ // folding both. A name or schema the type system cannot read stays unchecked.
96
+ test("Postgres composites: a misspelled column on a member fails; every member's row is its generated entry's", async () => {
97
+ const dir = join(fixtures, "postgres-composites");
98
+ expect(await rendered(dir)).toBe(readFileSync(join(dir, "src", SQL_TYPES_FILE), "utf8"));
99
+ expect(tscErrors(dir)).toEqual([
100
+ expect.stringMatching(/^checks\/misspelled\.ts\(4\): TS2551 Property 'emial' does not exist .* Did you mean 'email'\?$/),
101
+ ]);
102
+ }, 120_000);
103
+
104
+ test("ClickHouse composites: a misspelled column on a member fails; every member's row is its generated entry's", async () => {
105
+ const dir = join(fixtures, "clickhouse-composites");
106
+ expect(await rendered(dir)).toBe(readFileSync(join(dir, "src", SQL_TYPES_FILE), "utf8"));
107
+ expect(tscErrors(dir)).toEqual([expect.stringMatching(/^checks\/rows\.ts\(12\): TS2339 Property 'knid' does not exist on type/)]);
108
+ }, 120_000);
92
109
  });