@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.
- package/esm/granularity-driver.js +50 -20
- package/esm/index.js +1 -0
- package/esm/partition-driver.js +363 -0
- package/granularity-driver.d.ts +23 -26
- package/granularity-driver.js +49 -19
- package/index.d.ts +3 -1
- package/index.js +5 -1
- package/package.json +5 -3
- package/partition-driver.d.ts +154 -0
- package/partition-driver.js +368 -0
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
|
|
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
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
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
|
|
19
|
-
if (
|
|
20
|
-
return
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|
package/granularity-driver.d.ts
CHANGED
|
@@ -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}:
|
|
36
|
-
* `
|
|
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:
|
|
38
|
+
changes: RestructuredChange[];
|
|
43
39
|
/** Non-fatal notes (folds rejected to preserve ordering, etc.). */
|
|
44
40
|
warnings: string[];
|
|
45
41
|
}
|
|
46
42
|
/**
|
|
47
|
-
* Default
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
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
|
/**
|
package/granularity-driver.js
CHANGED
|
@@ -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
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
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
|
|
23
|
-
if (
|
|
24
|
-
return
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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
|
-
"@
|
|
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": "
|
|
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
|
+
}
|