@pgpmjs/transform 0.15.3 → 0.16.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,7 @@ 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 { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
5
6
  export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
6
7
  export { resolveFixtureClosure } from './fixture-closure';
7
8
  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
+ }
@@ -1,52 +1,49 @@
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
1
  import type { Granularity, StatementFacts } from '@pgsql/transform';
21
2
  export type { Granularity } from '@pgsql/transform';
22
3
  /** A change's deploy surface going into or out of the restructure. */
23
4
  export interface GranularityChange {
24
- /** Change name (plan token, e.g. `schemas/app/tables/users`). */
5
+ /** Change name (plan token, e.g. `schemas/app/tables/users/table`). */
25
6
  name: string;
26
7
  /** Change names this change requires (within the same module). */
27
8
  dependencies: string[];
28
9
  /** Deploy SQL (headerless — the caller owns pgpm headers). */
29
10
  deploy: string;
30
11
  }
12
+ /** A restructured change: deploy plus generated revert/verify scripts. */
13
+ export interface RestructuredChange extends GranularityChange {
14
+ /**
15
+ * Generated revert SQL (headerless): mechanical inverses of the change's
16
+ * statements in reverse topological order within the group, via
17
+ * `revertFor`. Non-invertible statements leave a `-- revert not
18
+ * derivable` comment and a warning.
19
+ */
20
+ revert: string;
21
+ /**
22
+ * Generated verify SQL (headerless): one raise-on-failure existence check
23
+ * per created object, via `verifyFor`.
24
+ */
25
+ verify: string;
26
+ }
31
27
  export interface RestructureModuleOptions {
32
28
  granularity: Granularity;
33
29
  /**
34
30
  * Derive a change name for a statement group from the facts of its primary
35
- * (creating) statement. Defaults to {@link defaultChangeName}: pgpm-style
36
- * `schemas/<schema>` / `schemas/<schema>/tables/<name>` paths.
31
+ * (creating) statement. Defaults to {@link defaultChangeName}: naming spec
32
+ * v1 paths (`identityOf` + `pathFor`).
37
33
  */
38
34
  changeName?: (facts: StatementFacts) => string;
39
35
  }
40
36
  export interface RestructureModuleResult {
41
37
  /** Restructured changes in deploy order, dependencies recomputed. */
42
- changes: GranularityChange[];
38
+ changes: RestructuredChange[];
43
39
  /** Non-fatal notes (folds rejected to preserve ordering, etc.). */
44
40
  warnings: string[];
45
41
  }
46
42
  /**
47
- * Default pgpm-style change name for a statement group:
48
- * `schemas/<schema>` for schemas, `schemas/<schema>/<kind>/<name>` for
49
- * objects, `misc/<n>` when nothing better is known.
43
+ * Default change name for a statement group: the object's canonical naming
44
+ * spec v1 path — `identityOf(facts)` (Postgres-native identity, from
45
+ * `@pgsql/transform`) rendered through `pathFor` (`@pgpmjs/naming-spec`).
46
+ * Paths are pure projections of identity, never authored.
50
47
  */
51
48
  export declare function defaultChangeName(facts: StatementFacts): string;
52
49
  /**
@@ -2,6 +2,27 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.defaultChangeName = defaultChangeName;
4
4
  exports.restructureChanges = restructureChanges;
5
+ /**
6
+ * Granularity driver: restructure a pgpm module's deploy surface between the
7
+ * atomic, object, and consolidated shapes.
8
+ *
9
+ * The upstream pass (`restructureSql` in `@pgsql/transform`) rewrites one SQL
10
+ * script between equivalent shapes, guarded by the statement dependency
11
+ * graph. This driver lifts that to the pgpm change model: it flattens a
12
+ * module's deploy scripts in plan order into one program, restructures it to
13
+ * the target granularity, then re-slices the result into changes — one change
14
+ * per created object — with change dependencies recomputed from the statement
15
+ * graph. Like the other drivers in this package it is structurally typed on
16
+ * the bundle seams: no dependency on `@pgpmjs/bundle` or `@pgpmjs/core`.
17
+ *
18
+ * - `atomic` — the machine-emitted shape: bare CREATE TABLE plus one
19
+ * ALTER per column/constraint.
20
+ * - `object` — each table fully baked; cross-object statements
21
+ * (FKs, indexes, triggers, policies) stay separate.
22
+ * - `consolidated` — additionally inlines FKs proven safe by the graph.
23
+ */
24
+ const naming_spec_1 = require("@pgpmjs/naming-spec");
25
+ const scripts_1 = require("@pgsql/scripts");
5
26
  const transform_1 = require("@pgsql/transform");
6
27
  const KIND_DIRS = {
7
28
  table: 'tables',
@@ -14,21 +35,15 @@ const KIND_DIRS = {
14
35
  seed_dml: 'fixtures'
15
36
  };
16
37
  /**
17
- * Default pgpm-style change name for a statement group:
18
- * `schemas/<schema>` for schemas, `schemas/<schema>/<kind>/<name>` for
19
- * objects, `misc/<n>` when nothing better is known.
38
+ * Default change name for a statement group: the object's canonical naming
39
+ * spec v1 path — `identityOf(facts)` (Postgres-native identity, from
40
+ * `@pgsql/transform`) rendered through `pathFor` (`@pgpmjs/naming-spec`).
41
+ * Paths are pure projections of identity, never authored.
20
42
  */
21
43
  function defaultChangeName(facts) {
22
- const created = facts.creates[0];
23
- if (facts.kind === 'schema' && created)
24
- return `schemas/${created.name}`;
25
- if (created) {
26
- const dir = KIND_DIRS[facts.kind] ?? 'objects';
27
- const schema = created.schema ?? 'public';
28
- // Trigger/policy names are table-qualified (`table.trigger`).
29
- const name = created.name.replace(/\./g, '/');
30
- return `schemas/${schema}/${dir}/${name}`;
31
- }
44
+ const identity = (0, transform_1.identityOf)(facts);
45
+ if (identity)
46
+ return (0, naming_spec_1.pathFor)(identity);
32
47
  return 'misc/statements';
33
48
  }
34
49
  /**
@@ -89,13 +104,16 @@ function restructureChanges(changes, options) {
89
104
  schemaGroup.set(f.creates[0].name, groupOf[i]);
90
105
  }
91
106
  });
92
- // Slice statement text per group, in the emitted (topological) order.
107
+ // Slice statement text per group, in the emitted (topological) order,
108
+ // and collect each group's facts for revert/verify generation.
93
109
  const groupSql = groupNames.map(() => []);
110
+ const groupStatements = groupNames.map(() => []);
94
111
  facts.forEach((f, i) => {
95
112
  const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
96
113
  const g = groupOf[i];
97
114
  if (g !== -1 && text) {
98
115
  groupSql[g].push(text.endsWith(';') ? text : `${text};`);
116
+ groupStatements[g].push(f);
99
117
  }
100
118
  });
101
119
  // Change dependencies = statement edges projected onto groups, plus
@@ -132,10 +150,22 @@ function restructureChanges(changes, options) {
132
150
  });
133
151
  const result = order
134
152
  .filter(g => groupSql[g].length > 0)
135
- .map(g => ({
136
- name: groupNames[g],
137
- dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
138
- deploy: groupSql[g].join('\n\n')
139
- }));
153
+ .map(g => {
154
+ const revert = (0, scripts_1.revertFor)(groupStatements[g]);
155
+ const verify = (0, scripts_1.verifyFor)(groupStatements[g]);
156
+ for (const warning of revert.warnings) {
157
+ warnings.push(`${groupNames[g]}: ${warning}`);
158
+ }
159
+ for (const warning of verify.warnings) {
160
+ warnings.push(`${groupNames[g]}: ${warning}`);
161
+ }
162
+ return {
163
+ name: groupNames[g],
164
+ dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
165
+ deploy: groupSql[g].join('\n\n'),
166
+ revert: revert.sql,
167
+ verify: verify.sql
168
+ };
169
+ });
140
170
  return { changes: result, warnings };
141
171
  }
package/index.d.ts CHANGED
@@ -2,8 +2,10 @@ export * from '@pgsql/transform';
2
2
  export { loadModule } from 'plpgsql-parser';
3
3
  export type { BundleScriptContext, ExtensionRoutingInput, NamespaceValidatorOptions, SchemaObjectRoute, SchemaTranspiler, SchemaTranspilerOptions, } from './bundle-driver';
4
4
  export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
5
- export type { GranularityChange, RestructureModuleOptions, RestructureModuleResult, } from './granularity-driver';
5
+ export type { GranularityChange, RestructuredChange, RestructureModuleOptions, RestructureModuleResult, } from './granularity-driver';
6
6
  export { defaultChangeName, restructureChanges, } from './granularity-driver';
7
+ export type { PartitionConfig, PartitionedChange, PartitionedPackage, PartitionInputChange, PartitionRule, PartitionUnit, PartitionUnitsResult, UnitSelector, } from './partition-driver';
8
+ export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
7
9
  export type { CategoryProfile, ChangeCategory, } from './categorize';
8
10
  export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
9
11
  export type { ClosureChange, ClosureInputChange, ClosureReason, FixtureClosure, ResolveFixtureClosureOptions, } from './fixture-closure';
package/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.parseSqlProgram = exports.emitSqlProgram = exports.resolveFixtureClosure = exports.TIER_PROFILE = exports.categorizeChange = exports.buildCategoryOf = exports.restructureChanges = exports.defaultChangeName = exports.makeSchemaTranspiler = exports.makeNamespaceValidator = exports.buildSchemaRouter = exports.buildExtensionRouter = exports.loadModule = void 0;
17
+ exports.parseSqlProgram = exports.emitSqlProgram = exports.resolveFixtureClosure = exports.TIER_PROFILE = exports.categorizeChange = exports.buildCategoryOf = exports.RESIDUAL_UNIT_PATH = exports.partitionUnits = exports.PartitionCycleError = exports.restructureChanges = exports.defaultChangeName = exports.makeSchemaTranspiler = exports.makeNamespaceValidator = exports.buildSchemaRouter = exports.buildExtensionRouter = exports.loadModule = void 0;
18
18
  __exportStar(require("@pgsql/transform"), exports);
19
19
  var plpgsql_parser_1 = require("plpgsql-parser");
20
20
  Object.defineProperty(exports, "loadModule", { enumerable: true, get: function () { return plpgsql_parser_1.loadModule; } });
@@ -26,6 +26,10 @@ Object.defineProperty(exports, "makeSchemaTranspiler", { enumerable: true, get:
26
26
  var granularity_driver_1 = require("./granularity-driver");
27
27
  Object.defineProperty(exports, "defaultChangeName", { enumerable: true, get: function () { return granularity_driver_1.defaultChangeName; } });
28
28
  Object.defineProperty(exports, "restructureChanges", { enumerable: true, get: function () { return granularity_driver_1.restructureChanges; } });
29
+ var partition_driver_1 = require("./partition-driver");
30
+ Object.defineProperty(exports, "PartitionCycleError", { enumerable: true, get: function () { return partition_driver_1.PartitionCycleError; } });
31
+ Object.defineProperty(exports, "partitionUnits", { enumerable: true, get: function () { return partition_driver_1.partitionUnits; } });
32
+ Object.defineProperty(exports, "RESIDUAL_UNIT_PATH", { enumerable: true, get: function () { return partition_driver_1.RESIDUAL_UNIT_PATH; } });
29
33
  var categorize_1 = require("./categorize");
30
34
  Object.defineProperty(exports, "buildCategoryOf", { enumerable: true, get: function () { return categorize_1.buildCategoryOf; } });
31
35
  Object.defineProperty(exports, "categorizeChange", { enumerable: true, get: function () { return categorize_1.categorizeChange; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgpmjs/transform",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Change-aware SQL transformation for PGPM — superset of @pgsql/transform plus change categorization, fixture closure, and bundle transpile/apply drivers",
6
6
  "main": "index.js",
@@ -43,8 +43,10 @@
43
43
  "makage": "^0.3.0"
44
44
  },
45
45
  "dependencies": {
46
- "@pgsql/transform": "^18.10.0",
46
+ "@pgpmjs/naming-spec": "^0.1.2",
47
+ "@pgsql/scripts": "^18.0.0",
48
+ "@pgsql/transform": "^18.11.0",
47
49
  "plpgsql-parser": "^18.2.2"
48
50
  },
49
- "gitHead": "90f447482b056a4949134565db2665f21d11aca3"
51
+ "gitHead": "7a37da1f5655c36813343f09c6efa62ca0d7a855"
50
52
  }
@@ -0,0 +1,154 @@
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 { PathStyle } from '@pgpmjs/naming-spec';
24
+ import { ObjectIdentity, ObjectIdentityKind, StatementKind } from '@pgsql/transform';
25
+ /** One deployable unit: an object (by identity) and the statements that build it. */
26
+ export interface PartitionUnit {
27
+ /** The object's identity, or `null` for split riders and the residual unit. */
28
+ identity: ObjectIdentity | null;
29
+ /** Statement kind of the unit's primary statement (`grant` for split grants, ...). */
30
+ primaryKind: StatementKind;
31
+ /** Canonical change path derived from the identity via the naming spec. */
32
+ path: string;
33
+ /** Indexes into the classified statement list, in source order. */
34
+ statements: number[];
35
+ /** The unit's deploy SQL (statement text joined in source order). */
36
+ sql: string;
37
+ /**
38
+ * A statement in this unit executes dynamic SQL: its edges are incomplete,
39
+ * so assignments involving it cannot be proven safe by the graph alone.
40
+ */
41
+ dynamicSql: boolean;
42
+ }
43
+ /**
44
+ * Matches units for assignment. Every given field must match (AND); array
45
+ * values match any element (OR). Omitted fields match everything, so
46
+ * `{ schema: 'billing' }` selects a whole schema, `{ kind: 'policy' }`
47
+ * selects every policy, and `{ path: 'schemas/app/tables/users/table' }`
48
+ * cherry-picks one object.
49
+ */
50
+ export interface UnitSelector {
51
+ kind?: ObjectIdentityKind | ObjectIdentityKind[];
52
+ /**
53
+ * Statement-level kind of the unit's primary statement — selects units
54
+ * that have no object identity, like grants split out via
55
+ * {@link PartitionConfig.splitRiders} (`statementKind: 'grant'`).
56
+ */
57
+ statementKind?: StatementKind | StatementKind[];
58
+ schema?: string | string[];
59
+ name?: string | string[];
60
+ /** Owning table, for table-scoped kinds (trigger/policy/index/constraint/seed). */
61
+ table?: string | string[];
62
+ /** The derived naming-spec path — exact cherry-pick. */
63
+ path?: string | string[];
64
+ }
65
+ /** Assigns matching units to a package. Rules are tried in order; first match wins. */
66
+ export interface PartitionRule {
67
+ package: string;
68
+ /** A unit matching ANY of these selectors is assigned to `package`. */
69
+ select: UnitSelector[];
70
+ /**
71
+ * Also pull in the transitive dependency closure (hard + fk edges) of the
72
+ * selected units. Only units that would otherwise land in the default
73
+ * package are pulled — a unit another rule claimed stays where it is and
74
+ * becomes an ordinary cross-package dependency.
75
+ */
76
+ closure?: boolean;
77
+ }
78
+ export interface PartitionConfig {
79
+ /** Assignment rules, in priority order. */
80
+ rules: PartitionRule[];
81
+ /** Package for units no rule matches. */
82
+ defaultPackage: string;
83
+ /** Naming-spec rendering style for derived paths (default `directory`). */
84
+ style?: PathStyle;
85
+ /**
86
+ * Statement kinds to split out of their host unit into standalone units
87
+ * (e.g. `['grant']` makes every GRANT its own selectable unit named
88
+ * `<hostPath>/grants/<role>`), so security surface can be partitioned
89
+ * independently of the objects it attaches to. Split units depend on
90
+ * their host, so ordering stays correct wherever they are assigned.
91
+ */
92
+ splitRiders?: StatementKind[];
93
+ }
94
+ /** One emitted change of a partitioned package. */
95
+ export interface PartitionedChange {
96
+ /** Change path (naming spec). */
97
+ name: string;
98
+ /**
99
+ * Same-package dependencies as plain paths, cross-package dependencies as
100
+ * `<pkg>:<path>` — the pgpm cross-package requires convention.
101
+ */
102
+ dependencies: string[];
103
+ deploy: string;
104
+ }
105
+ export interface PartitionedPackage {
106
+ name: string;
107
+ /** Changes in a valid deploy order (statement-graph topological order). */
108
+ changes: PartitionedChange[];
109
+ /** Package names this package requires (from cross-package edges). */
110
+ requires: string[];
111
+ }
112
+ export interface PartitionUnitsResult {
113
+ packages: PartitionedPackage[];
114
+ /** unit path → package name, for every unit. */
115
+ assignments: Map<string, string>;
116
+ /** Units pulled in by closure rules: path → the rule's package. */
117
+ closureIncluded: Map<string, string>;
118
+ warnings: string[];
119
+ }
120
+ /** A partition whose packages require each other in a cycle — unshippable. */
121
+ export declare class PartitionCycleError extends Error {
122
+ /** The packages forming the cycle. */
123
+ readonly packages: string[];
124
+ /** One offending edge per hop: `fromPkg:fromPath -> toPkg:toPath`. */
125
+ readonly edges: string[];
126
+ constructor(
127
+ /** The packages forming the cycle. */
128
+ packages: string[],
129
+ /** One offending edge per hop: `fromPkg:fromPath -> toPkg:toPath`. */
130
+ edges: string[]);
131
+ }
132
+ /** Path of the residual unit for statements attributable to no object. */
133
+ export declare const RESIDUAL_UNIT_PATH = "misc/statements";
134
+ /** A change's deploy surface going into the partition (same seam as the granularity driver). */
135
+ export interface PartitionInputChange {
136
+ name: string;
137
+ dependencies: string[];
138
+ deploy: string;
139
+ }
140
+ /**
141
+ * Partition a deploy surface into packages.
142
+ *
143
+ * Accepts either raw SQL or pgpm changes (whose deploy scripts are flattened
144
+ * in the given order — run the granularity dial first if a different unit
145
+ * shape is wanted). Requires `loadModule()` from `plpgsql-parser` first.
146
+ *
147
+ * Assignment: for each unit the first matching rule wins; unmatched units go
148
+ * to `defaultPackage`. Closure rules then pull the transitive dependency
149
+ * closure (hard + fk) of their units out of the default package. Finally the
150
+ * requires post-pass renders every unit dependency through the naming spec —
151
+ * same package as `<path>`, cross-package as `<pkg>:<path>` — and the
152
+ * package-level graph is checked for cycles ({@link PartitionCycleError}).
153
+ */
154
+ export declare function partitionUnits(input: string | PartitionInputChange[], config: PartitionConfig): PartitionUnitsResult;
@@ -0,0 +1,368 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RESIDUAL_UNIT_PATH = exports.PartitionCycleError = void 0;
4
+ exports.partitionUnits = partitionUnits;
5
+ /**
6
+ * Partition dial: project one deploy surface into a set of pgpm packages.
7
+ *
8
+ * The object graph (statements classified by `@pgsql/transform`, grouped
9
+ * into identity-keyed units) is the source of truth; which package a unit
10
+ * lives in is a *derived projection* of a declarative partition config.
11
+ * Units can be assigned by schema, by object kind, by explicit cherry-pick
12
+ * (identity or derived path), or dragged in as the dependency closure of a
13
+ * package's seeds. `requires` headers are a mechanical post-pass over the
14
+ * statement graph: intra-package edges become `requires: <path>`,
15
+ * cross-package edges become `requires: <pkg>:<path>` — both rendered
16
+ * through the naming spec, never authored.
17
+ *
18
+ * A partition is rejected (hard error) when packages require each other in
19
+ * a cycle: that is the one unshippable configuration, because no package
20
+ * install order can satisfy it.
21
+ *
22
+ * Like the other drivers in this package the emit shape is structurally
23
+ * typed on the change seam — no dependency on `@pgpmjs/bundle` or
24
+ * `@pgpmjs/core`. Callers materialize packages however they like (write
25
+ * pgpm.plan + deploy trees, feed a bundle, ...).
26
+ */
27
+ const naming_spec_1 = require("@pgpmjs/naming-spec");
28
+ const transform_1 = require("@pgsql/transform");
29
+ /** A partition whose packages require each other in a cycle — unshippable. */
30
+ class PartitionCycleError extends Error {
31
+ packages;
32
+ edges;
33
+ constructor(
34
+ /** The packages forming the cycle. */
35
+ packages,
36
+ /** One offending edge per hop: `fromPkg:fromPath -> toPkg:toPath`. */
37
+ edges) {
38
+ super(`cross-package dependency cycle: ${packages.join(' -> ')} -> ${packages[0]}\n` +
39
+ edges.map(e => ` ${e}`).join('\n'));
40
+ this.packages = packages;
41
+ this.edges = edges;
42
+ this.name = 'PartitionCycleError';
43
+ }
44
+ }
45
+ exports.PartitionCycleError = PartitionCycleError;
46
+ const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
47
+ const matchField = (value, wanted) => {
48
+ if (wanted === undefined)
49
+ return true;
50
+ return Array.isArray(wanted) ? wanted.includes(value) : wanted === value;
51
+ };
52
+ function matches(unit, selector) {
53
+ if (!matchField(unit.path, selector.path))
54
+ return false;
55
+ if (!matchField(unit.primaryKind, selector.statementKind))
56
+ return false;
57
+ const id = unit.identity;
58
+ if (selector.kind === undefined && selector.schema === undefined &&
59
+ selector.name === undefined && selector.table === undefined) {
60
+ return true;
61
+ }
62
+ if (!id)
63
+ return false;
64
+ // A schema object's own name counts as its schema, so `{ schema: 'x' }`
65
+ // selects the schema together with everything in it.
66
+ const schema = id.kind === 'schema' ? id.name : id.schema;
67
+ return (matchField(id.kind, selector.kind) &&
68
+ matchField(schema, selector.schema) &&
69
+ matchField(id.name, selector.name) &&
70
+ matchField(id.table, selector.table));
71
+ }
72
+ /** Path of the residual unit for statements attributable to no object. */
73
+ exports.RESIDUAL_UNIT_PATH = 'misc/statements';
74
+ /**
75
+ * Group a classified script into identity-keyed units and compute unit-level
76
+ * dependencies from the statement graph.
77
+ *
78
+ * Statements with no identity of their own (grants, comments) ride with the
79
+ * unit of the object they target — resolved through the statement graph's
80
+ * edges — falling back to the closest preceding statement's unit; lead-in
81
+ * statements with no unit at all land in a residual unit so nothing is
82
+ * silently dropped. Kinds listed in `splitRiders` become standalone units
83
+ * (named `<hostPath>/grants/<role>` for grants, `<hostPath>/<kind>/<n>`
84
+ * otherwise) that depend on their host unit.
85
+ */
86
+ function buildUnitGraph(sql, style, splitRiders) {
87
+ const facts = (0, transform_1.classifyStatements)(sql);
88
+ const graph = (0, transform_1.buildStatementGraph)(facts);
89
+ const unitOf = new Array(facts.length).fill(-1);
90
+ const units = [];
91
+ const byIdentity = new Map();
92
+ const takenPaths = new Set();
93
+ const newUnit = (identity, primaryKind, path) => {
94
+ units.push({ identity, primaryKind, path, statements: [], sql: '', dynamicSql: false });
95
+ takenPaths.add(path);
96
+ return units.length - 1;
97
+ };
98
+ // Naming-spec path for a unit; ENABLE ROW LEVEL SECURITY uses the spec's
99
+ // dedicated rls template rather than its constraint-shaped identity.
100
+ const unitPath = (identity, kind) => {
101
+ if (kind === 'rls_enable' && identity.table) {
102
+ return `schemas/${identity.schema ?? 'public'}/tables/${identity.table}/policies/enable_row_level_security`;
103
+ }
104
+ return (0, naming_spec_1.pathFor)(identity, { style });
105
+ };
106
+ const unitFor = (identity, kind) => {
107
+ const key = identity ? identityKey(identity) : exports.RESIDUAL_UNIT_PATH;
108
+ let u = byIdentity.get(key);
109
+ if (u === undefined) {
110
+ u = newUnit(identity, kind, identity ? unitPath(identity, kind) : exports.RESIDUAL_UNIT_PATH);
111
+ byIdentity.set(key, u);
112
+ }
113
+ return u;
114
+ };
115
+ // Object-owning units by qualified name, for rider host resolution. Only
116
+ // top-level objects qualify (table-scoped identities share the table's
117
+ // qualified name and must not shadow it).
118
+ const objectUnits = new Map();
119
+ const registerObject = (identity, u) => {
120
+ if (identity.table)
121
+ return;
122
+ objectUnits.set(`${identity.schema ?? ''}.${identity.name}`, u);
123
+ };
124
+ /** Host unit of a rider statement: the unit owning the first object it
125
+ * references, falling back to the preceding statement's unit. */
126
+ const hostOf = (i) => {
127
+ for (const r of facts[i].references) {
128
+ const u = objectUnits.get(`${r.schema ?? ''}.${r.name}`);
129
+ if (u !== undefined)
130
+ return u;
131
+ }
132
+ if (i > 0 && unitOf[i - 1] !== -1)
133
+ return unitOf[i - 1];
134
+ return -1;
135
+ };
136
+ const riderPath = (f, host) => {
137
+ const base = host !== -1 ? units[host].path : exports.RESIDUAL_UNIT_PATH;
138
+ const dir = f.kind === 'grant' ? 'grants' : f.kind;
139
+ const label = f.roles.length > 0 ? f.roles.join('_') : f.kind;
140
+ let path = `${base}/${dir}/${label}`;
141
+ for (let n = 2; takenPaths.has(path); n++)
142
+ path = `${base}/${dir}/${label}-${n}`;
143
+ return path;
144
+ };
145
+ const hostUnit = new Map();
146
+ facts.forEach((f, i) => {
147
+ const identity = (0, transform_1.identityOf)(f);
148
+ if (identity && !splitRiders.has(f.kind)) {
149
+ unitOf[i] = unitFor(identity, f.kind);
150
+ registerObject(identity, unitOf[i]);
151
+ return;
152
+ }
153
+ const host = hostOf(i);
154
+ if (splitRiders.has(f.kind)) {
155
+ unitOf[i] = newUnit(identity, f.kind, identity ? unitPath(identity, f.kind) : riderPath(f, host));
156
+ if (identity)
157
+ registerObject(identity, unitOf[i]);
158
+ if (host !== -1)
159
+ hostUnit.set(unitOf[i], host);
160
+ return;
161
+ }
162
+ // No identity: ride with the host unit.
163
+ if (host !== -1) {
164
+ unitOf[i] = host;
165
+ return;
166
+ }
167
+ unitOf[i] = unitFor(null, f.kind);
168
+ });
169
+ facts.forEach((f, i) => {
170
+ const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
171
+ if (!text)
172
+ return;
173
+ const unit = units[unitOf[i]];
174
+ unit.statements.push(i);
175
+ unit.sql += (unit.sql ? '\n\n' : '') + (text.endsWith(';') ? text : `${text};`);
176
+ if (f.dynamicSql)
177
+ unit.dynamicSql = true;
178
+ });
179
+ // Unit dependencies: statement edges (late edges do not constrain deploy
180
+ // order), plus schema membership (an object requires its schema's unit).
181
+ const deps = units.map(() => new Set());
182
+ for (const edge of graph.edges) {
183
+ if (edge.kind === 'late')
184
+ continue;
185
+ const from = unitOf[edge.from];
186
+ const to = unitOf[edge.to];
187
+ if (from !== -1 && to !== -1 && from !== to)
188
+ deps[from].add(to);
189
+ }
190
+ // A split rider requires its host object; a table-scoped object (trigger,
191
+ // policy, index, constraint, RLS) requires its table.
192
+ for (const [u, host] of hostUnit) {
193
+ if (u !== host)
194
+ deps[u].add(host);
195
+ }
196
+ units.forEach((u, i) => {
197
+ const id = u.identity;
198
+ if (!id?.table)
199
+ return;
200
+ const table = objectUnits.get(`${id.schema ?? ''}.${id.table}`);
201
+ if (table !== undefined && table !== i)
202
+ deps[i].add(table);
203
+ });
204
+ const schemaUnit = new Map();
205
+ units.forEach((u, i) => {
206
+ if (u.identity?.kind === 'schema')
207
+ schemaUnit.set(u.identity.name, i);
208
+ });
209
+ facts.forEach((f, i) => {
210
+ const from = unitOf[i];
211
+ if (from === -1)
212
+ return;
213
+ const schemas = new Set(f.referencedSchemas);
214
+ for (const created of f.creates) {
215
+ if (created.schema)
216
+ schemas.add(created.schema);
217
+ }
218
+ for (const schema of schemas) {
219
+ const to = schemaUnit.get(schema);
220
+ if (to !== undefined && to !== from)
221
+ deps[from].add(to);
222
+ }
223
+ });
224
+ return { facts, units, unitOf, deps, source: sql };
225
+ }
226
+ /**
227
+ * Partition a deploy surface into packages.
228
+ *
229
+ * Accepts either raw SQL or pgpm changes (whose deploy scripts are flattened
230
+ * in the given order — run the granularity dial first if a different unit
231
+ * shape is wanted). Requires `loadModule()` from `plpgsql-parser` first.
232
+ *
233
+ * Assignment: for each unit the first matching rule wins; unmatched units go
234
+ * to `defaultPackage`. Closure rules then pull the transitive dependency
235
+ * closure (hard + fk) of their units out of the default package. Finally the
236
+ * requires post-pass renders every unit dependency through the naming spec —
237
+ * same package as `<path>`, cross-package as `<pkg>:<path>` — and the
238
+ * package-level graph is checked for cycles ({@link PartitionCycleError}).
239
+ */
240
+ function partitionUnits(input, config) {
241
+ const style = config.style ?? 'directory';
242
+ const sql = typeof input === 'string'
243
+ ? input
244
+ : input.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
245
+ const { units, deps } = buildUnitGraph(sql, style, new Set(config.splitRiders ?? []));
246
+ const warnings = [];
247
+ // 1. Assignment: first matching rule wins.
248
+ const packageOf = units.map(unit => {
249
+ for (const rule of config.rules) {
250
+ if (rule.select.some(s => matches(unit, s)))
251
+ return rule.package;
252
+ }
253
+ return config.defaultPackage;
254
+ });
255
+ // 2. Closure: pull each closure rule's dependency closure out of the
256
+ // default package (never out of another rule's package).
257
+ const closureIncluded = new Map();
258
+ for (const rule of config.rules) {
259
+ if (!rule.closure)
260
+ continue;
261
+ const queue = units.map((_, i) => i).filter(i => packageOf[i] === rule.package);
262
+ const seen = new Set(queue);
263
+ while (queue.length > 0) {
264
+ const current = queue.shift();
265
+ for (const dep of deps[current]) {
266
+ if (seen.has(dep))
267
+ continue;
268
+ seen.add(dep);
269
+ if (packageOf[dep] === rule.package) {
270
+ queue.push(dep);
271
+ continue;
272
+ }
273
+ if (packageOf[dep] !== config.defaultPackage)
274
+ continue;
275
+ packageOf[dep] = rule.package;
276
+ closureIncluded.set(units[dep].path, rule.package);
277
+ queue.push(dep);
278
+ }
279
+ }
280
+ }
281
+ // Dynamic SQL makes edges incomplete: surface it, do not guess.
282
+ units.forEach((unit, i) => {
283
+ if (unit.dynamicSql && unit.statements.length > 0) {
284
+ warnings.push(`${units[i].path} (${packageOf[i]}) executes dynamic SQL; hidden references cannot be checked against the partition`);
285
+ }
286
+ });
287
+ // 3. Requires post-pass + package-level graph.
288
+ const pkgNames = [...new Set([...packageOf, config.defaultPackage])];
289
+ const pkgIndex = new Map(pkgNames.map((p, i) => [p, i]));
290
+ const pkgEdges = new Map();
291
+ const changesByPkg = new Map();
292
+ for (const p of pkgNames)
293
+ changesByPkg.set(p, []);
294
+ units.forEach((unit, i) => {
295
+ if (unit.statements.length === 0)
296
+ return;
297
+ const pkg = packageOf[i];
298
+ const dependencies = [...deps[i]]
299
+ .filter(d => units[d].statements.length > 0)
300
+ .map(d => {
301
+ const depPkg = packageOf[d];
302
+ if (depPkg === pkg)
303
+ return units[d].path;
304
+ const key = `${pkg}\u0000${depPkg}`;
305
+ if (!pkgEdges.has(key)) {
306
+ pkgEdges.set(key, {
307
+ from: pkgIndex.get(pkg),
308
+ to: pkgIndex.get(depPkg),
309
+ via: `${pkg}:${unit.path} -> ${depPkg}:${units[d].path}`
310
+ });
311
+ }
312
+ return `${depPkg}:${units[d].path}`;
313
+ })
314
+ .sort();
315
+ changesByPkg.get(pkg).push({ name: unit.path, dependencies, deploy: unit.sql });
316
+ });
317
+ // 4. Cross-package cycle detection over the package graph.
318
+ detectCycles(pkgNames, [...pkgEdges.values()]);
319
+ const assignments = new Map();
320
+ units.forEach((unit, i) => {
321
+ if (unit.statements.length > 0)
322
+ assignments.set(unit.path, packageOf[i]);
323
+ });
324
+ const packages = pkgNames
325
+ .map(name => ({
326
+ name,
327
+ changes: changesByPkg.get(name),
328
+ requires: [...new Set([...pkgEdges.values()]
329
+ .filter(e => pkgNames[e.from] === name)
330
+ .map(e => pkgNames[e.to]))].sort()
331
+ }))
332
+ .filter(p => p.changes.length > 0);
333
+ return { packages, assignments, closureIncluded, warnings };
334
+ }
335
+ function detectCycles(pkgNames, edges) {
336
+ const out = new Map();
337
+ for (const e of edges) {
338
+ const list = out.get(e.from) ?? [];
339
+ list.push({ to: e.to, via: e.via });
340
+ out.set(e.from, list);
341
+ }
342
+ const state = new Array(pkgNames.length).fill(0); // 0 unseen, 1 on stack, 2 done
343
+ const stack = [];
344
+ const viaStack = [];
345
+ const visit = (v) => {
346
+ state[v] = 1;
347
+ stack.push(v);
348
+ for (const { to, via } of out.get(v) ?? []) {
349
+ if (state[to] === 2)
350
+ continue;
351
+ viaStack.push(via);
352
+ if (state[to] === 1) {
353
+ const start = stack.indexOf(to);
354
+ const cyclePkgs = stack.slice(start).map(i => pkgNames[i]);
355
+ const cycleEdges = viaStack.slice(viaStack.length - cyclePkgs.length);
356
+ throw new PartitionCycleError(cyclePkgs, cycleEdges);
357
+ }
358
+ visit(to);
359
+ viaStack.pop();
360
+ }
361
+ stack.pop();
362
+ state[v] = 2;
363
+ };
364
+ for (let v = 0; v < pkgNames.length; v++) {
365
+ if (state[v] === 0)
366
+ visit(v);
367
+ }
368
+ }