@pgpmjs/transform 0.15.3 → 0.17.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.
@@ -1,4 +1,25 @@
1
- import { buildStatementGraph, classifyStatements, restructureSql } from '@pgsql/transform';
1
+ /**
2
+ * Granularity driver: restructure a pgpm module's deploy surface between the
3
+ * atomic, object, and consolidated shapes.
4
+ *
5
+ * The upstream pass (`restructureSql` in `@pgsql/transform`) rewrites one SQL
6
+ * script between equivalent shapes, guarded by the statement dependency
7
+ * graph. This driver lifts that to the pgpm change model: it flattens a
8
+ * module's deploy scripts in plan order into one program, restructures it to
9
+ * the target granularity, then re-slices the result into changes — one change
10
+ * per created object — with change dependencies recomputed from the statement
11
+ * graph. Like the other drivers in this package it is structurally typed on
12
+ * the bundle seams: no dependency on `@pgpmjs/bundle` or `@pgpmjs/core`.
13
+ *
14
+ * - `atomic` — the machine-emitted shape: bare CREATE TABLE plus one
15
+ * ALTER per column/constraint.
16
+ * - `object` — each table fully baked; cross-object statements
17
+ * (FKs, indexes, triggers, policies) stay separate.
18
+ * - `consolidated` — additionally inlines FKs proven safe by the graph.
19
+ */
20
+ import { pathFor } from '@pgpmjs/naming-spec';
21
+ import { revertFor, verifyFor } from '@pgsql/scripts';
22
+ import { buildStatementGraph, classifyStatements, identityOf, restructureSql } from '@pgsql/transform';
2
23
  const KIND_DIRS = {
3
24
  table: 'tables',
4
25
  view: 'views',
@@ -10,21 +31,15 @@ const KIND_DIRS = {
10
31
  seed_dml: 'fixtures'
11
32
  };
12
33
  /**
13
- * Default pgpm-style change name for a statement group:
14
- * `schemas/<schema>` for schemas, `schemas/<schema>/<kind>/<name>` for
15
- * objects, `misc/<n>` when nothing better is known.
34
+ * Default change name for a statement group: the object's canonical naming
35
+ * spec v1 path — `identityOf(facts)` (Postgres-native identity, from
36
+ * `@pgsql/transform`) rendered through `pathFor` (`@pgpmjs/naming-spec`).
37
+ * Paths are pure projections of identity, never authored.
16
38
  */
17
39
  export function defaultChangeName(facts) {
18
- const created = facts.creates[0];
19
- if (facts.kind === 'schema' && created)
20
- return `schemas/${created.name}`;
21
- if (created) {
22
- const dir = KIND_DIRS[facts.kind] ?? 'objects';
23
- const schema = created.schema ?? 'public';
24
- // Trigger/policy names are table-qualified (`table.trigger`).
25
- const name = created.name.replace(/\./g, '/');
26
- return `schemas/${schema}/${dir}/${name}`;
27
- }
40
+ const identity = identityOf(facts);
41
+ if (identity)
42
+ return pathFor(identity);
28
43
  return 'misc/statements';
29
44
  }
30
45
  /**
@@ -85,13 +100,16 @@ export function restructureChanges(changes, options) {
85
100
  schemaGroup.set(f.creates[0].name, groupOf[i]);
86
101
  }
87
102
  });
88
- // Slice statement text per group, in the emitted (topological) order.
103
+ // Slice statement text per group, in the emitted (topological) order,
104
+ // and collect each group's facts for revert/verify generation.
89
105
  const groupSql = groupNames.map(() => []);
106
+ const groupStatements = groupNames.map(() => []);
90
107
  facts.forEach((f, i) => {
91
108
  const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
92
109
  const g = groupOf[i];
93
110
  if (g !== -1 && text) {
94
111
  groupSql[g].push(text.endsWith(';') ? text : `${text};`);
112
+ groupStatements[g].push(f);
95
113
  }
96
114
  });
97
115
  // Change dependencies = statement edges projected onto groups, plus
@@ -128,10 +146,22 @@ export function restructureChanges(changes, options) {
128
146
  });
129
147
  const result = order
130
148
  .filter(g => groupSql[g].length > 0)
131
- .map(g => ({
132
- name: groupNames[g],
133
- dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
134
- deploy: groupSql[g].join('\n\n')
135
- }));
149
+ .map(g => {
150
+ const revert = revertFor(groupStatements[g]);
151
+ const verify = verifyFor(groupStatements[g]);
152
+ for (const warning of revert.warnings) {
153
+ warnings.push(`${groupNames[g]}: ${warning}`);
154
+ }
155
+ for (const warning of verify.warnings) {
156
+ warnings.push(`${groupNames[g]}: ${warning}`);
157
+ }
158
+ return {
159
+ name: groupNames[g],
160
+ dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
161
+ deploy: groupSql[g].join('\n\n'),
162
+ revert: revert.sql,
163
+ verify: verify.sql
164
+ };
165
+ });
136
166
  return { changes: result, warnings };
137
167
  }
package/esm/index.js CHANGED
@@ -2,6 +2,9 @@ export * from '@pgsql/transform';
2
2
  export { loadModule } from 'plpgsql-parser';
3
3
  export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
4
4
  export { defaultChangeName, restructureChanges, } from './granularity-driver';
5
+ export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
6
+ export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
5
7
  export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
6
8
  export { resolveFixtureClosure } from './fixture-closure';
9
+ export { classifyScript, isStubScript, regenerateScripts, } from './regen';
7
10
  export { emitSqlProgram, parseSqlProgram } from './program';
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Partition dial: project one deploy surface into a set of pgpm packages.
3
+ *
4
+ * The object graph (statements classified by `@pgsql/transform`, grouped
5
+ * into identity-keyed units) is the source of truth; which package a unit
6
+ * lives in is a *derived projection* of a declarative partition config.
7
+ * Units can be assigned by schema, by object kind, by explicit cherry-pick
8
+ * (identity or derived path), or dragged in as the dependency closure of a
9
+ * package's seeds. `requires` headers are a mechanical post-pass over the
10
+ * statement graph: intra-package edges become `requires: <path>`,
11
+ * cross-package edges become `requires: <pkg>:<path>` — both rendered
12
+ * through the naming spec, never authored.
13
+ *
14
+ * A partition is rejected (hard error) when packages require each other in
15
+ * a cycle: that is the one unshippable configuration, because no package
16
+ * install order can satisfy it.
17
+ *
18
+ * Like the other drivers in this package the emit shape is structurally
19
+ * typed on the change seam — no dependency on `@pgpmjs/bundle` or
20
+ * `@pgpmjs/core`. Callers materialize packages however they like (write
21
+ * pgpm.plan + deploy trees, feed a bundle, ...).
22
+ */
23
+ import { pathFor } from '@pgpmjs/naming-spec';
24
+ import { buildStatementGraph, classifyStatements, identityOf } from '@pgsql/transform';
25
+ /** A partition whose packages require each other in a cycle — unshippable. */
26
+ export class PartitionCycleError extends Error {
27
+ packages;
28
+ edges;
29
+ constructor(
30
+ /** The packages forming the cycle. */
31
+ packages,
32
+ /** One offending edge per hop: `fromPkg:fromPath -> toPkg:toPath`. */
33
+ edges) {
34
+ super(`cross-package dependency cycle: ${packages.join(' -> ')} -> ${packages[0]}\n` +
35
+ edges.map(e => ` ${e}`).join('\n'));
36
+ this.packages = packages;
37
+ this.edges = edges;
38
+ this.name = 'PartitionCycleError';
39
+ }
40
+ }
41
+ const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
42
+ const matchField = (value, wanted) => {
43
+ if (wanted === undefined)
44
+ return true;
45
+ return Array.isArray(wanted) ? wanted.includes(value) : wanted === value;
46
+ };
47
+ function matches(unit, selector) {
48
+ if (!matchField(unit.path, selector.path))
49
+ return false;
50
+ if (!matchField(unit.primaryKind, selector.statementKind))
51
+ return false;
52
+ const id = unit.identity;
53
+ if (selector.kind === undefined && selector.schema === undefined &&
54
+ selector.name === undefined && selector.table === undefined) {
55
+ return true;
56
+ }
57
+ if (!id)
58
+ return false;
59
+ // A schema object's own name counts as its schema, so `{ schema: 'x' }`
60
+ // selects the schema together with everything in it.
61
+ const schema = id.kind === 'schema' ? id.name : id.schema;
62
+ return (matchField(id.kind, selector.kind) &&
63
+ matchField(schema, selector.schema) &&
64
+ matchField(id.name, selector.name) &&
65
+ matchField(id.table, selector.table));
66
+ }
67
+ /** Path of the residual unit for statements attributable to no object. */
68
+ export const RESIDUAL_UNIT_PATH = 'misc/statements';
69
+ /**
70
+ * Group a classified script into identity-keyed units and compute unit-level
71
+ * dependencies from the statement graph.
72
+ *
73
+ * Statements with no identity of their own (grants, comments) ride with the
74
+ * unit of the object they target — resolved through the statement graph's
75
+ * edges — falling back to the closest preceding statement's unit; lead-in
76
+ * statements with no unit at all land in a residual unit so nothing is
77
+ * silently dropped. Kinds listed in `splitRiders` become standalone units
78
+ * (named `<hostPath>/grants/<role>` for grants, `<hostPath>/<kind>/<n>`
79
+ * otherwise) that depend on their host unit.
80
+ */
81
+ function buildUnitGraph(sql, style, splitRiders) {
82
+ const facts = classifyStatements(sql);
83
+ const graph = buildStatementGraph(facts);
84
+ const unitOf = new Array(facts.length).fill(-1);
85
+ const units = [];
86
+ const byIdentity = new Map();
87
+ const takenPaths = new Set();
88
+ const newUnit = (identity, primaryKind, path) => {
89
+ units.push({ identity, primaryKind, path, statements: [], sql: '', dynamicSql: false });
90
+ takenPaths.add(path);
91
+ return units.length - 1;
92
+ };
93
+ // Naming-spec path for a unit; ENABLE ROW LEVEL SECURITY uses the spec's
94
+ // dedicated rls template rather than its constraint-shaped identity.
95
+ const unitPath = (identity, kind) => {
96
+ if (kind === 'rls_enable' && identity.table) {
97
+ return `schemas/${identity.schema ?? 'public'}/tables/${identity.table}/policies/enable_row_level_security`;
98
+ }
99
+ return pathFor(identity, { style });
100
+ };
101
+ const unitFor = (identity, kind) => {
102
+ const key = identity ? identityKey(identity) : RESIDUAL_UNIT_PATH;
103
+ let u = byIdentity.get(key);
104
+ if (u === undefined) {
105
+ u = newUnit(identity, kind, identity ? unitPath(identity, kind) : RESIDUAL_UNIT_PATH);
106
+ byIdentity.set(key, u);
107
+ }
108
+ return u;
109
+ };
110
+ // Object-owning units by qualified name, for rider host resolution. Only
111
+ // top-level objects qualify (table-scoped identities share the table's
112
+ // qualified name and must not shadow it).
113
+ const objectUnits = new Map();
114
+ const registerObject = (identity, u) => {
115
+ if (identity.table)
116
+ return;
117
+ objectUnits.set(`${identity.schema ?? ''}.${identity.name}`, u);
118
+ };
119
+ /** Host unit of a rider statement: the unit owning the first object it
120
+ * references, falling back to the preceding statement's unit. */
121
+ const hostOf = (i) => {
122
+ for (const r of facts[i].references) {
123
+ const u = objectUnits.get(`${r.schema ?? ''}.${r.name}`);
124
+ if (u !== undefined)
125
+ return u;
126
+ }
127
+ if (i > 0 && unitOf[i - 1] !== -1)
128
+ return unitOf[i - 1];
129
+ return -1;
130
+ };
131
+ const riderPath = (f, host) => {
132
+ const base = host !== -1 ? units[host].path : RESIDUAL_UNIT_PATH;
133
+ const dir = f.kind === 'grant' ? 'grants' : f.kind;
134
+ const label = f.roles.length > 0 ? f.roles.join('_') : f.kind;
135
+ let path = `${base}/${dir}/${label}`;
136
+ for (let n = 2; takenPaths.has(path); n++)
137
+ path = `${base}/${dir}/${label}-${n}`;
138
+ return path;
139
+ };
140
+ const hostUnit = new Map();
141
+ facts.forEach((f, i) => {
142
+ const identity = identityOf(f);
143
+ if (identity && !splitRiders.has(f.kind)) {
144
+ unitOf[i] = unitFor(identity, f.kind);
145
+ registerObject(identity, unitOf[i]);
146
+ return;
147
+ }
148
+ const host = hostOf(i);
149
+ if (splitRiders.has(f.kind)) {
150
+ unitOf[i] = newUnit(identity, f.kind, identity ? unitPath(identity, f.kind) : riderPath(f, host));
151
+ if (identity)
152
+ registerObject(identity, unitOf[i]);
153
+ if (host !== -1)
154
+ hostUnit.set(unitOf[i], host);
155
+ return;
156
+ }
157
+ // No identity: ride with the host unit.
158
+ if (host !== -1) {
159
+ unitOf[i] = host;
160
+ return;
161
+ }
162
+ unitOf[i] = unitFor(null, f.kind);
163
+ });
164
+ facts.forEach((f, i) => {
165
+ const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
166
+ if (!text)
167
+ return;
168
+ const unit = units[unitOf[i]];
169
+ unit.statements.push(i);
170
+ unit.sql += (unit.sql ? '\n\n' : '') + (text.endsWith(';') ? text : `${text};`);
171
+ if (f.dynamicSql)
172
+ unit.dynamicSql = true;
173
+ });
174
+ // Unit dependencies: statement edges (late edges do not constrain deploy
175
+ // order), plus schema membership (an object requires its schema's unit).
176
+ const deps = units.map(() => new Set());
177
+ for (const edge of graph.edges) {
178
+ if (edge.kind === 'late')
179
+ continue;
180
+ const from = unitOf[edge.from];
181
+ const to = unitOf[edge.to];
182
+ if (from !== -1 && to !== -1 && from !== to)
183
+ deps[from].add(to);
184
+ }
185
+ // A split rider requires its host object; a table-scoped object (trigger,
186
+ // policy, index, constraint, RLS) requires its table.
187
+ for (const [u, host] of hostUnit) {
188
+ if (u !== host)
189
+ deps[u].add(host);
190
+ }
191
+ units.forEach((u, i) => {
192
+ const id = u.identity;
193
+ if (!id?.table)
194
+ return;
195
+ const table = objectUnits.get(`${id.schema ?? ''}.${id.table}`);
196
+ if (table !== undefined && table !== i)
197
+ deps[i].add(table);
198
+ });
199
+ const schemaUnit = new Map();
200
+ units.forEach((u, i) => {
201
+ if (u.identity?.kind === 'schema')
202
+ schemaUnit.set(u.identity.name, i);
203
+ });
204
+ facts.forEach((f, i) => {
205
+ const from = unitOf[i];
206
+ if (from === -1)
207
+ return;
208
+ const schemas = new Set(f.referencedSchemas);
209
+ for (const created of f.creates) {
210
+ if (created.schema)
211
+ schemas.add(created.schema);
212
+ }
213
+ for (const schema of schemas) {
214
+ const to = schemaUnit.get(schema);
215
+ if (to !== undefined && to !== from)
216
+ deps[from].add(to);
217
+ }
218
+ });
219
+ return { facts, units, unitOf, deps, source: sql };
220
+ }
221
+ /**
222
+ * Partition a deploy surface into packages.
223
+ *
224
+ * Accepts either raw SQL or pgpm changes (whose deploy scripts are flattened
225
+ * in the given order — run the granularity dial first if a different unit
226
+ * shape is wanted). Requires `loadModule()` from `plpgsql-parser` first.
227
+ *
228
+ * Assignment: for each unit the first matching rule wins; unmatched units go
229
+ * to `defaultPackage`. Closure rules then pull the transitive dependency
230
+ * closure (hard + fk) of their units out of the default package. Finally the
231
+ * requires post-pass renders every unit dependency through the naming spec —
232
+ * same package as `<path>`, cross-package as `<pkg>:<path>` — and the
233
+ * package-level graph is checked for cycles ({@link PartitionCycleError}).
234
+ */
235
+ export function partitionUnits(input, config) {
236
+ const style = config.style ?? 'directory';
237
+ const sql = typeof input === 'string'
238
+ ? input
239
+ : input.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
240
+ const { units, deps } = buildUnitGraph(sql, style, new Set(config.splitRiders ?? []));
241
+ const warnings = [];
242
+ // 1. Assignment: first matching rule wins.
243
+ const packageOf = units.map(unit => {
244
+ for (const rule of config.rules) {
245
+ if (rule.select.some(s => matches(unit, s)))
246
+ return rule.package;
247
+ }
248
+ return config.defaultPackage;
249
+ });
250
+ // 2. Closure: pull each closure rule's dependency closure out of the
251
+ // default package (never out of another rule's package).
252
+ const closureIncluded = new Map();
253
+ for (const rule of config.rules) {
254
+ if (!rule.closure)
255
+ continue;
256
+ const queue = units.map((_, i) => i).filter(i => packageOf[i] === rule.package);
257
+ const seen = new Set(queue);
258
+ while (queue.length > 0) {
259
+ const current = queue.shift();
260
+ for (const dep of deps[current]) {
261
+ if (seen.has(dep))
262
+ continue;
263
+ seen.add(dep);
264
+ if (packageOf[dep] === rule.package) {
265
+ queue.push(dep);
266
+ continue;
267
+ }
268
+ if (packageOf[dep] !== config.defaultPackage)
269
+ continue;
270
+ packageOf[dep] = rule.package;
271
+ closureIncluded.set(units[dep].path, rule.package);
272
+ queue.push(dep);
273
+ }
274
+ }
275
+ }
276
+ // Dynamic SQL makes edges incomplete: surface it, do not guess.
277
+ units.forEach((unit, i) => {
278
+ if (unit.dynamicSql && unit.statements.length > 0) {
279
+ warnings.push(`${units[i].path} (${packageOf[i]}) executes dynamic SQL; hidden references cannot be checked against the partition`);
280
+ }
281
+ });
282
+ // 3. Requires post-pass + package-level graph.
283
+ const pkgNames = [...new Set([...packageOf, config.defaultPackage])];
284
+ const pkgIndex = new Map(pkgNames.map((p, i) => [p, i]));
285
+ const pkgEdges = new Map();
286
+ const changesByPkg = new Map();
287
+ for (const p of pkgNames)
288
+ changesByPkg.set(p, []);
289
+ units.forEach((unit, i) => {
290
+ if (unit.statements.length === 0)
291
+ return;
292
+ const pkg = packageOf[i];
293
+ const dependencies = [...deps[i]]
294
+ .filter(d => units[d].statements.length > 0)
295
+ .map(d => {
296
+ const depPkg = packageOf[d];
297
+ if (depPkg === pkg)
298
+ return units[d].path;
299
+ const key = `${pkg}\u0000${depPkg}`;
300
+ if (!pkgEdges.has(key)) {
301
+ pkgEdges.set(key, {
302
+ from: pkgIndex.get(pkg),
303
+ to: pkgIndex.get(depPkg),
304
+ via: `${pkg}:${unit.path} -> ${depPkg}:${units[d].path}`
305
+ });
306
+ }
307
+ return `${depPkg}:${units[d].path}`;
308
+ })
309
+ .sort();
310
+ changesByPkg.get(pkg).push({ name: unit.path, dependencies, deploy: unit.sql });
311
+ });
312
+ // 4. Cross-package cycle detection over the package graph.
313
+ detectCycles(pkgNames, [...pkgEdges.values()]);
314
+ const assignments = new Map();
315
+ units.forEach((unit, i) => {
316
+ if (unit.statements.length > 0)
317
+ assignments.set(unit.path, packageOf[i]);
318
+ });
319
+ const packages = pkgNames
320
+ .map(name => ({
321
+ name,
322
+ changes: changesByPkg.get(name),
323
+ requires: [...new Set([...pkgEdges.values()]
324
+ .filter(e => pkgNames[e.from] === name)
325
+ .map(e => pkgNames[e.to]))].sort()
326
+ }))
327
+ .filter(p => p.changes.length > 0);
328
+ return { packages, assignments, closureIncluded, warnings };
329
+ }
330
+ function detectCycles(pkgNames, edges) {
331
+ const out = new Map();
332
+ for (const e of edges) {
333
+ const list = out.get(e.from) ?? [];
334
+ list.push({ to: e.to, via: e.via });
335
+ out.set(e.from, list);
336
+ }
337
+ const state = new Array(pkgNames.length).fill(0); // 0 unseen, 1 on stack, 2 done
338
+ const stack = [];
339
+ const viaStack = [];
340
+ const visit = (v) => {
341
+ state[v] = 1;
342
+ stack.push(v);
343
+ for (const { to, via } of out.get(v) ?? []) {
344
+ if (state[to] === 2)
345
+ continue;
346
+ viaStack.push(via);
347
+ if (state[to] === 1) {
348
+ const start = stack.indexOf(to);
349
+ const cyclePkgs = stack.slice(start).map(i => pkgNames[i]);
350
+ const cycleEdges = viaStack.slice(viaStack.length - cyclePkgs.length);
351
+ throw new PartitionCycleError(cyclePkgs, cycleEdges);
352
+ }
353
+ visit(to);
354
+ viaStack.pop();
355
+ }
356
+ stack.pop();
357
+ state[v] = 2;
358
+ };
359
+ for (let v = 0; v < pkgNames.length; v++) {
360
+ if (state[v] === 0)
361
+ visit(v);
362
+ }
363
+ }
package/esm/regen.js ADDED
@@ -0,0 +1,39 @@
1
+ import { revertFor, verifyFor } from '@pgsql/scripts';
2
+ import { classifyStatements } from '@pgsql/transform';
3
+ /**
4
+ * Classify a pgpm script's substantive statements: every top-level statement
5
+ * except transaction control (`BEGIN`/`COMMIT`/`ROLLBACK` wrappers).
6
+ */
7
+ export function classifyScript(sql) {
8
+ return classifyStatements(sql).filter(f => f.nodeTag !== 'TransactionStmt');
9
+ }
10
+ /**
11
+ * Whether a script is an empty stub: nothing but comments, whitespace, and
12
+ * a transaction wrapper — no substantive statements. Scaffolded
13
+ * `-- Add your revert SQL here` files and bare `BEGIN; ... COMMIT;` shells
14
+ * are stubs. A script that fails to parse is NOT a stub (never treat content
15
+ * we cannot understand as safe to overwrite).
16
+ */
17
+ export function isStubScript(sql) {
18
+ if (!sql || !sql.trim())
19
+ return true;
20
+ try {
21
+ return classifyScript(sql).length === 0;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ /**
28
+ * Generate revert and verify scripts (headerless, unwrapped) for one
29
+ * change's deploy SQL. Statements with no derivable inverse contribute a
30
+ * `-- revert not derivable: <reason>` comment and a warning; statements
31
+ * whose effect cannot be existence-checked add a verify warning.
32
+ */
33
+ export function regenerateScripts(deploySql) {
34
+ const facts = classifyScript(deploySql);
35
+ return {
36
+ revert: revertFor(facts),
37
+ verify: verifyFor(facts)
38
+ };
39
+ }