@pgpmjs/transform 0.16.0 → 0.18.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/index.js CHANGED
@@ -2,7 +2,9 @@ export * from '@pgsql/transform';
2
2
  export { loadModule } from 'plpgsql-parser';
3
3
  export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
4
4
  export { defaultChangeName, restructureChanges, } from './granularity-driver';
5
+ export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
5
6
  export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
6
7
  export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
7
8
  export { resolveFixtureClosure } from './fixture-closure';
9
+ export { classifyScript, isStubScript, regenerateScripts, } from './regen';
8
10
  export { emitSqlProgram, parseSqlProgram } from './program';
package/esm/regen.js ADDED
@@ -0,0 +1,39 @@
1
+ import { revertFor, verifyFor } from '@pgsql/scripts';
2
+ import { classifyStatements } from '@pgsql/transform';
3
+ /**
4
+ * Classify a pgpm script's substantive statements: every top-level statement
5
+ * except transaction control (`BEGIN`/`COMMIT`/`ROLLBACK` wrappers).
6
+ */
7
+ export function classifyScript(sql) {
8
+ return classifyStatements(sql).filter(f => f.nodeTag !== 'TransactionStmt');
9
+ }
10
+ /**
11
+ * Whether a script is an empty stub: nothing but comments, whitespace, and
12
+ * a transaction wrapper — no substantive statements. Scaffolded
13
+ * `-- Add your revert SQL here` files and bare `BEGIN; ... COMMIT;` shells
14
+ * are stubs. A script that fails to parse is NOT a stub (never treat content
15
+ * we cannot understand as safe to overwrite).
16
+ */
17
+ export function isStubScript(sql) {
18
+ if (!sql || !sql.trim())
19
+ return true;
20
+ try {
21
+ return classifyScript(sql).length === 0;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ /**
28
+ * Generate revert and verify scripts (headerless, unwrapped) for one
29
+ * change's deploy SQL. Statements with no derivable inverse contribute a
30
+ * `-- revert not derivable: <reason>` comment and a warning; statements
31
+ * whose effect cannot be existence-checked add a verify warning.
32
+ */
33
+ export function regenerateScripts(deploySql) {
34
+ const facts = classifyScript(deploySql);
35
+ return {
36
+ revert: revertFor(facts),
37
+ verify: verifyFor(facts)
38
+ };
39
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Semantic diff driver: identity-keyed schema delta between two deploy
3
+ * surfaces.
4
+ *
5
+ * Where `diffBundles` (in `@pgpmjs/bundle`) compares change names and content
6
+ * digests — which breaks the moment a dial regroups or renames changes — this
7
+ * driver re-keys the comparison on object identity (`identityOf`), so the
8
+ * same schema authored at different granularities or partitions diffs as
9
+ * equal. Objects are compared at the AST level (normalized parse trees, so
10
+ * formatting never matters); tables are compared column-by-column and
11
+ * constraint-by-constraint so an added column emits `ALTER TABLE ADD COLUMN`,
12
+ * not a table rebuild.
13
+ *
14
+ * The emitted delta is a set of pgpm changes: DROPs for removed objects
15
+ * (reverse topological order), then CREATE/ALTER changes for added and
16
+ * modified objects routed through the granularity pipeline
17
+ * (`restructureChanges`), so names come from the naming spec and `requires`
18
+ * from the statement graph — the same derivation every other projection uses.
19
+ * Every emitted change carries a full deploy/revert/verify triple: inverses
20
+ * and existence checks are composed at the AST level via `@pgsql/scripts`'
21
+ * node-level API (`invertStatement`/`existenceCheck`) — never hand-templated
22
+ * and never round-tripped through deparsed text — deparsing only once at the
23
+ * end. Statements the node layer cannot derive fall back to the text layer
24
+ * (`revertFor`/`verifyFor`) solely to surface its not-derivable comment and
25
+ * warning.
26
+ *
27
+ * Requires `loadModule()` from `plpgsql-parser` before use.
28
+ */
29
+ import { pathFor } from '@pgpmjs/naming-spec';
30
+ import { existenceCheck, invertStatement, revertFor, verifyFor } from '@pgsql/scripts';
31
+ import { buildStatementGraph, classifyStatements, cleanTree, identityOf } from '@pgsql/transform';
32
+ import { Deparser, parseSync } from 'plpgsql-parser';
33
+ import { restructureChanges } from './granularity-driver';
34
+ const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
35
+ /**
36
+ * Group one side's script into identity-keyed object units. Statements with
37
+ * no identity (grants, comments) attach to the unit of the first object they
38
+ * reference, falling back to the preceding statement's unit; truly homeless
39
+ * statements are reported via `warnings`.
40
+ */
41
+ function readSide(sql, warnings, label) {
42
+ const facts = classifyStatements(sql);
43
+ const graph = buildStatementGraph(facts);
44
+ const units = new Map();
45
+ const order = [];
46
+ const unitOf = new Array(facts.length).fill(null);
47
+ const byObject = new Map();
48
+ facts.forEach((f, i) => {
49
+ const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
50
+ if (!text)
51
+ return;
52
+ let identity = groupingIdentity(f);
53
+ if (identity && identity.kind === 'other') {
54
+ const asTable = identityKey({ kind: 'table', schema: identity.schema, name: identity.name });
55
+ if (units.has(asTable))
56
+ identity = units.get(asTable).identity;
57
+ }
58
+ let key = null;
59
+ if (identity) {
60
+ key = identityKey(identity);
61
+ if (!units.has(key)) {
62
+ units.set(key, { identity, statements: [], texts: [] });
63
+ order.push(key);
64
+ }
65
+ if (!identity.table) {
66
+ byObject.set(`${identity.schema ?? ''}.${identity.name}`, key);
67
+ }
68
+ }
69
+ else {
70
+ for (const r of f.references) {
71
+ const host = byObject.get(`${r.schema ?? ''}.${r.name}`);
72
+ if (host) {
73
+ key = host;
74
+ break;
75
+ }
76
+ }
77
+ if (!key && i > 0)
78
+ key = unitOf[i - 1];
79
+ }
80
+ if (!key) {
81
+ warnings.push(`${label}: statement ${i} (${f.kind}) belongs to no object; ignored in the diff`);
82
+ return;
83
+ }
84
+ unitOf[i] = key;
85
+ const unit = units.get(key);
86
+ unit.statements.push(i);
87
+ unit.texts.push(text.endsWith(';') ? text : `${text};`);
88
+ });
89
+ return { facts, units, order, graph };
90
+ }
91
+ /**
92
+ * The identity a statement groups under. Table-shaping statements — ALTER
93
+ * TABLE column/constraint additions, RLS enablement — fold into their table's
94
+ * unit so that atomic and consolidated authorships of the same table land in
95
+ * the same unit and compare shape-to-shape. Generic ALTERs (`kind: 'other'`)
96
+ * fold in `readSide` when a table unit for the same object already exists.
97
+ */
98
+ function groupingIdentity(f) {
99
+ const identity = identityOf(f);
100
+ if (!identity)
101
+ return null;
102
+ if ((f.kind === 'table' || f.kind === 'constraint' || f.kind === 'rls_enable') &&
103
+ identity.kind !== 'table') {
104
+ return { kind: 'table', schema: identity.schema, name: identity.table ?? identity.name };
105
+ }
106
+ return identity;
107
+ }
108
+ /** Normalized-AST fingerprint of one statement (formatting/position-proof). */
109
+ function fingerprint(text) {
110
+ const parsed = parseSync(text);
111
+ return JSON.stringify(cleanTree(parsed.sql.stmts.map(s => s.stmt)));
112
+ }
113
+ /** Multiset fingerprint of a whole unit. */
114
+ const unitFingerprint = (unit) => unit.texts.map(fingerprint).sort().join('\n');
115
+ /**
116
+ * Fold a table unit's statements (CREATE TABLE plus any ALTER TABLE
117
+ * ADD COLUMN / ADD CONSTRAINT) into an effective shape, so atomic and
118
+ * consolidated authorships of the same table compare equal.
119
+ */
120
+ function tableShape(unit) {
121
+ const shape = { relation: null, columns: new Map(), extras: new Map() };
122
+ const addColumn = (node) => {
123
+ const def = node;
124
+ const clean = cleanTree(node);
125
+ shape.columns.set(def.colname ?? '', { def: clean, print: JSON.stringify(clean) });
126
+ };
127
+ const addExtra = (node) => {
128
+ const clean = cleanTree(node);
129
+ shape.extras.set(JSON.stringify(clean), clean);
130
+ };
131
+ for (const text of unit.texts) {
132
+ for (const raw of parseSync(text).sql.stmts) {
133
+ const stmt = raw.stmt;
134
+ if (stmt.CreateStmt) {
135
+ shape.relation = stmt.CreateStmt.relation;
136
+ const elts = stmt.CreateStmt.tableElts ?? [];
137
+ for (const elt of elts) {
138
+ if (elt.ColumnDef)
139
+ addColumn(elt.ColumnDef);
140
+ else
141
+ addExtra(elt);
142
+ }
143
+ }
144
+ else if (stmt.AlterTableStmt) {
145
+ shape.relation ??= stmt.AlterTableStmt.relation;
146
+ const cmds = stmt.AlterTableStmt.cmds ?? [];
147
+ for (const cmd of cmds) {
148
+ const at = cmd.AlterTableCmd;
149
+ if (!at)
150
+ continue;
151
+ const def = at.def;
152
+ if (at.subtype === 'AT_AddColumn' && def?.ColumnDef) {
153
+ addColumn(def.ColumnDef);
154
+ }
155
+ else {
156
+ addExtra({ AlterTableCmd: at });
157
+ }
158
+ }
159
+ }
160
+ else {
161
+ addExtra(stmt);
162
+ }
163
+ }
164
+ }
165
+ return shape;
166
+ }
167
+ const deparseStmt = (node) => `${Deparser.deparse(node, { pretty: false })};`;
168
+ /** Column-level ALTERs between two table shapes. */
169
+ function diffTable(from, to, diff, warnings) {
170
+ const stmts = [];
171
+ const relation = to.relation ?? from.relation;
172
+ const alter = (cmd) => deparseStmt({
173
+ AlterTableStmt: {
174
+ relation,
175
+ cmds: [{ AlterTableCmd: { behavior: 'DROP_RESTRICT', ...cmd } }],
176
+ objtype: 'OBJECT_TABLE'
177
+ }
178
+ });
179
+ diff.columnsAdded = [];
180
+ diff.columnsRemoved = [];
181
+ diff.columnsModified = [];
182
+ for (const [name, col] of to.columns) {
183
+ const prior = from.columns.get(name);
184
+ if (!prior) {
185
+ diff.columnsAdded.push(name);
186
+ stmts.push(alter({ subtype: 'AT_AddColumn', def: { ColumnDef: col.def } }));
187
+ }
188
+ else if (prior.print !== col.print) {
189
+ diff.columnsModified.push(name);
190
+ const fromDef = prior.def;
191
+ const toDef = col.def;
192
+ const typeChanged = JSON.stringify(fromDef.typeName) !== JSON.stringify(toDef.typeName);
193
+ const restChanged = JSON.stringify({ ...fromDef, typeName: null }) !==
194
+ JSON.stringify({ ...toDef, typeName: null });
195
+ if (typeChanged) {
196
+ stmts.push(alter({
197
+ subtype: 'AT_AlterColumnType',
198
+ name,
199
+ def: { ColumnDef: { typeName: toDef.typeName } }
200
+ }));
201
+ }
202
+ if (restChanged) {
203
+ warnings.push(`${diff.path}: column "${name}" changed beyond its type (constraints/defaults); emit a manual ALTER`);
204
+ }
205
+ }
206
+ }
207
+ for (const name of from.columns.keys()) {
208
+ if (!to.columns.has(name)) {
209
+ diff.columnsRemoved.push(name);
210
+ stmts.push(alter({ subtype: 'AT_DropColumn', name }));
211
+ }
212
+ }
213
+ for (const [print, extra] of to.extras) {
214
+ if (!from.extras.has(print)) {
215
+ const at = extra.AlterTableCmd;
216
+ if (at)
217
+ stmts.push(alter(at));
218
+ else if (extra.Constraint) {
219
+ stmts.push(alter({ subtype: 'AT_AddConstraint', def: extra }));
220
+ }
221
+ else {
222
+ warnings.push(`${diff.path}: unrecognized added table element; emit a manual ALTER`);
223
+ }
224
+ }
225
+ }
226
+ for (const [print, extra] of from.extras) {
227
+ if (to.extras.has(print))
228
+ continue;
229
+ const conname = extra.Constraint?.conname
230
+ ?? extra
231
+ .AlterTableCmd?.def?.Constraint?.conname;
232
+ if (conname) {
233
+ stmts.push(alter({ subtype: 'AT_DropConstraint', name: conname }));
234
+ }
235
+ else {
236
+ warnings.push(`${diff.path}: removed table element has no name to drop; emit a manual ALTER`);
237
+ }
238
+ }
239
+ return stmts;
240
+ }
241
+ /**
242
+ * Diff two deploy surfaces by object identity and emit the migration delta.
243
+ *
244
+ * `from`/`to` are whole deploy scripts (flatten changes in plan order to
245
+ * diff bundles/modules). The result's `changes` deploy `to`'s shape on top
246
+ * of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
247
+ * ordered by `restructureChanges`.
248
+ */
249
+ export function diffSchemas(from, to, options = {}) {
250
+ const style = options.style ?? 'directory';
251
+ const warnings = [];
252
+ const a = readSide(from, warnings, 'from');
253
+ const b = readSide(to, warnings, 'to');
254
+ const objects = [];
255
+ const forwardSql = [];
256
+ const modifiedChanges = [];
257
+ const dropUnits = [];
258
+ /**
259
+ * `@pgsql/scripts` inverse of a deploy script (drops in reverse topo
260
+ * order), composed at the AST level via `invertStatement` and deparsed
261
+ * once. Underivable statements fall back to `revertFor` on the single
262
+ * statement to surface its not-derivable comment and warning.
263
+ */
264
+ const inverseOf = (deploy, context) => {
265
+ const facts = classifyStatements(deploy);
266
+ const graph = buildStatementGraph(facts);
267
+ const pieces = [];
268
+ for (const i of [...graph.order].reverse()) {
269
+ const nodes = invertStatement(facts[i]);
270
+ if (nodes === null) {
271
+ const { sql, warnings: w } = revertFor([facts[i]]);
272
+ warnings.push(...w.map(x => `${context}: ${x}`));
273
+ if (sql.trim())
274
+ pieces.push(sql);
275
+ continue;
276
+ }
277
+ pieces.push(...nodes.map(n => deparseStmt(n)));
278
+ }
279
+ return pieces.join('\n');
280
+ };
281
+ /**
282
+ * `@pgsql/scripts` existence checks for a deploy script, composed from
283
+ * `existenceCheck`'s SelectStmt nodes and deparsed once. Underivable
284
+ * statements fall back to `verifyFor` to surface the warning.
285
+ */
286
+ const verifyOf = (deploy, context) => {
287
+ const pieces = [];
288
+ for (const fact of classifyStatements(deploy)) {
289
+ const nodes = existenceCheck(fact);
290
+ if (nodes === null) {
291
+ const { warnings: w } = verifyFor([fact]);
292
+ warnings.push(...w.map(x => `${context}: ${x}`));
293
+ continue;
294
+ }
295
+ pieces.push(...nodes.map(n => deparseStmt(n)));
296
+ }
297
+ return pieces.join('\n\n');
298
+ };
299
+ for (const key of b.order) {
300
+ const unit = b.units.get(key);
301
+ const prior = a.units.get(key);
302
+ const path = pathFor(unit.identity, { style });
303
+ if (!prior) {
304
+ objects.push({ identity: unit.identity, path, delta: 'added' });
305
+ forwardSql.push(...unit.texts);
306
+ continue;
307
+ }
308
+ if (unit.identity.kind === 'table') {
309
+ const shapeFrom = tableShape(prior);
310
+ const shapeTo = tableShape(unit);
311
+ const diff = { identity: unit.identity, path, delta: 'modified' };
312
+ const stmts = diffTable(shapeFrom, shapeTo, diff, warnings);
313
+ const columnsTouched = diff.columnsAdded.length + diff.columnsRemoved.length + diff.columnsModified.length > 0;
314
+ if (stmts.length > 0 || columnsTouched) {
315
+ objects.push(diff);
316
+ if (stmts.length > 0) {
317
+ // The inverse alteration is the same diff with the sides swapped.
318
+ const scratch = { identity: unit.identity, path, delta: 'modified' };
319
+ const deploy = stmts.join('\n');
320
+ modifiedChanges.push({
321
+ name: `${path}/alter`,
322
+ dependencies: [],
323
+ deploy,
324
+ revert: diffTable(shapeTo, shapeFrom, scratch, []).join('\n'),
325
+ verify: verifyOf(deploy, path)
326
+ });
327
+ }
328
+ }
329
+ continue;
330
+ }
331
+ if (unitFingerprint(prior) !== unitFingerprint(unit)) {
332
+ objects.push({ identity: unit.identity, path, delta: 'modified' });
333
+ const priorDeploy = prior.texts.join('\n');
334
+ const newDeploy = unit.texts.join('\n');
335
+ modifiedChanges.push({
336
+ name: path,
337
+ dependencies: [],
338
+ deploy: [inverseOf(priorDeploy, path), newDeploy].filter(Boolean).join('\n'),
339
+ revert: [inverseOf(newDeploy, path), priorDeploy].filter(Boolean).join('\n'),
340
+ verify: verifyOf(newDeploy, path)
341
+ });
342
+ }
343
+ }
344
+ for (const key of a.order) {
345
+ if (b.units.has(key))
346
+ continue;
347
+ const unit = a.units.get(key);
348
+ objects.push({ identity: unit.identity, path: pathFor(unit.identity, { style }), delta: 'removed' });
349
+ dropUnits.push(unit);
350
+ }
351
+ // Removed objects drop in reverse topological order of the `from` graph
352
+ // (dependents before dependencies), one change per object.
353
+ const position = new Map(dropUnits.map(u => [u, Math.min(...u.statements.map(s => a.graph.order.indexOf(s)))]));
354
+ dropUnits.sort((x, y) => position.get(y) - position.get(x));
355
+ const dropChanges = [];
356
+ for (const unit of dropUnits) {
357
+ const path = pathFor(unit.identity, { style });
358
+ const recreate = unit.texts.join('\n');
359
+ const drop = inverseOf(recreate, path);
360
+ if (!drop.trim() || drop.trim().startsWith('--')) {
361
+ warnings.push(`${path}: no DROP derivable for kind "${unit.identity.kind}"; drop manually`);
362
+ continue;
363
+ }
364
+ dropChanges.push({
365
+ name: `${path}/drop`,
366
+ dependencies: dropChanges.length > 0 ? [dropChanges[dropChanges.length - 1].name] : [],
367
+ deploy: drop,
368
+ revert: recreate,
369
+ verify: ''
370
+ });
371
+ }
372
+ let forwardChanges = [];
373
+ if (forwardSql.length > 0) {
374
+ const { changes, warnings: w } = restructureChanges([{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }], { granularity: options.granularity ?? 'object' });
375
+ forwardChanges = changes.map(c => ({
376
+ ...c,
377
+ revert: inverseOf(c.deploy, c.name),
378
+ verify: verifyOf(c.deploy, c.name)
379
+ }));
380
+ warnings.push(...w);
381
+ }
382
+ return {
383
+ identical: objects.length === 0,
384
+ objects,
385
+ changes: [...dropChanges, ...forwardChanges, ...modifiedChanges],
386
+ warnings
387
+ };
388
+ }
389
+ /**
390
+ * Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
391
+ * in plan order and delegates to {@link diffSchemas}. Change names never
392
+ * enter the comparison — regrouping, renaming, or repartitioning the same
393
+ * schema yields an identical diff.
394
+ */
395
+ export function diffChangeSets(from, to, options = {}) {
396
+ const flatten = (changes) => changes.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
397
+ return diffSchemas(flatten(from), flatten(to), options);
398
+ }
package/index.d.ts CHANGED
@@ -4,11 +4,15 @@ export type { BundleScriptContext, ExtensionRoutingInput, NamespaceValidatorOpti
4
4
  export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
5
5
  export type { GranularityChange, RestructuredChange, RestructureModuleOptions, RestructureModuleResult, } from './granularity-driver';
6
6
  export { defaultChangeName, restructureChanges, } from './granularity-driver';
7
+ export type { DiffInputChange, ObjectDelta, SemanticDeltaChange, SemanticDiffOptions, SemanticDiffResult, SemanticObjectDiff, } from './semantic-diff-driver';
8
+ export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
7
9
  export type { PartitionConfig, PartitionedChange, PartitionedPackage, PartitionInputChange, PartitionRule, PartitionUnit, PartitionUnitsResult, UnitSelector, } from './partition-driver';
8
10
  export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
9
11
  export type { CategoryProfile, ChangeCategory, } from './categorize';
10
12
  export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
11
13
  export type { ClosureChange, ClosureInputChange, ClosureReason, FixtureClosure, ResolveFixtureClosureOptions, } from './fixture-closure';
12
14
  export { resolveFixtureClosure } from './fixture-closure';
15
+ export type { GeneratedScript, RegeneratedScripts, } from './regen';
16
+ export { classifyScript, isStubScript, regenerateScripts, } from './regen';
13
17
  export type { SqlProgram, SqlStatementAst, SqlStatementSpan, } from './program';
14
18
  export { emitSqlProgram, parseSqlProgram } from './program';
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.RESIDUAL_UNIT_PATH = exports.partitionUnits = exports.PartitionCycleError = exports.restructureChanges = exports.defaultChangeName = exports.makeSchemaTranspiler = exports.makeNamespaceValidator = exports.buildSchemaRouter = exports.buildExtensionRouter = exports.loadModule = void 0;
17
+ exports.parseSqlProgram = exports.emitSqlProgram = exports.regenerateScripts = exports.isStubScript = exports.classifyScript = exports.resolveFixtureClosure = exports.TIER_PROFILE = exports.categorizeChange = exports.buildCategoryOf = exports.RESIDUAL_UNIT_PATH = exports.partitionUnits = exports.PartitionCycleError = exports.diffSchemas = exports.diffChangeSets = 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,9 @@ 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 semantic_diff_driver_1 = require("./semantic-diff-driver");
30
+ Object.defineProperty(exports, "diffChangeSets", { enumerable: true, get: function () { return semantic_diff_driver_1.diffChangeSets; } });
31
+ Object.defineProperty(exports, "diffSchemas", { enumerable: true, get: function () { return semantic_diff_driver_1.diffSchemas; } });
29
32
  var partition_driver_1 = require("./partition-driver");
30
33
  Object.defineProperty(exports, "PartitionCycleError", { enumerable: true, get: function () { return partition_driver_1.PartitionCycleError; } });
31
34
  Object.defineProperty(exports, "partitionUnits", { enumerable: true, get: function () { return partition_driver_1.partitionUnits; } });
@@ -36,6 +39,10 @@ Object.defineProperty(exports, "categorizeChange", { enumerable: true, get: func
36
39
  Object.defineProperty(exports, "TIER_PROFILE", { enumerable: true, get: function () { return categorize_1.TIER_PROFILE; } });
37
40
  var fixture_closure_1 = require("./fixture-closure");
38
41
  Object.defineProperty(exports, "resolveFixtureClosure", { enumerable: true, get: function () { return fixture_closure_1.resolveFixtureClosure; } });
42
+ var regen_1 = require("./regen");
43
+ Object.defineProperty(exports, "classifyScript", { enumerable: true, get: function () { return regen_1.classifyScript; } });
44
+ Object.defineProperty(exports, "isStubScript", { enumerable: true, get: function () { return regen_1.isStubScript; } });
45
+ Object.defineProperty(exports, "regenerateScripts", { enumerable: true, get: function () { return regen_1.regenerateScripts; } });
39
46
  var program_1 = require("./program");
40
47
  Object.defineProperty(exports, "emitSqlProgram", { enumerable: true, get: function () { return program_1.emitSqlProgram; } });
41
48
  Object.defineProperty(exports, "parseSqlProgram", { enumerable: true, get: function () { return program_1.parseSqlProgram; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgpmjs/transform",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -44,9 +44,9 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@pgpmjs/naming-spec": "^0.1.2",
47
- "@pgsql/scripts": "^18.0.0",
47
+ "@pgsql/scripts": "^18.1.0",
48
48
  "@pgsql/transform": "^18.11.0",
49
- "plpgsql-parser": "^18.2.2"
49
+ "plpgsql-parser": "^18.2.3"
50
50
  },
51
- "gitHead": "7a37da1f5655c36813343f09c6efa62ca0d7a855"
51
+ "gitHead": "6d3e25dc5c9f93421dba49ddedf93239f87213a8"
52
52
  }
package/regen.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Regeneration driver: derive revert/verify scripts for a single pgpm
3
+ * change from its deploy SQL.
4
+ *
5
+ * Deploy scripts are classified into statement facts (`classifyStatements`)
6
+ * and fed through `revertFor`/`verifyFor` from `@pgsql/scripts`: revert is
7
+ * the mechanical inverses in reverse topological order, verify is one
8
+ * raise-on-failure existence check per created object. Transaction control
9
+ * statements (the BEGIN/COMMIT wrapper some modules carry inside their
10
+ * scripts) are excluded before classification — the caller owns the wrapper.
11
+ *
12
+ * Requires `loadModule()` from `plpgsql-parser` to have completed.
13
+ */
14
+ import type { GeneratedScript } from '@pgsql/scripts';
15
+ import type { StatementFacts } from '@pgsql/transform';
16
+ export type { GeneratedScript } from '@pgsql/scripts';
17
+ /** Generated revert and verify scripts for one change's deploy SQL. */
18
+ export interface RegeneratedScripts {
19
+ revert: GeneratedScript;
20
+ verify: GeneratedScript;
21
+ }
22
+ /**
23
+ * Classify a pgpm script's substantive statements: every top-level statement
24
+ * except transaction control (`BEGIN`/`COMMIT`/`ROLLBACK` wrappers).
25
+ */
26
+ export declare function classifyScript(sql: string): StatementFacts[];
27
+ /**
28
+ * Whether a script is an empty stub: nothing but comments, whitespace, and
29
+ * a transaction wrapper — no substantive statements. Scaffolded
30
+ * `-- Add your revert SQL here` files and bare `BEGIN; ... COMMIT;` shells
31
+ * are stubs. A script that fails to parse is NOT a stub (never treat content
32
+ * we cannot understand as safe to overwrite).
33
+ */
34
+ export declare function isStubScript(sql: string): boolean;
35
+ /**
36
+ * Generate revert and verify scripts (headerless, unwrapped) for one
37
+ * change's deploy SQL. Statements with no derivable inverse contribute a
38
+ * `-- revert not derivable: <reason>` comment and a warning; statements
39
+ * whose effect cannot be existence-checked add a verify warning.
40
+ */
41
+ export declare function regenerateScripts(deploySql: string): RegeneratedScripts;
package/regen.js ADDED
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyScript = classifyScript;
4
+ exports.isStubScript = isStubScript;
5
+ exports.regenerateScripts = regenerateScripts;
6
+ const scripts_1 = require("@pgsql/scripts");
7
+ const transform_1 = require("@pgsql/transform");
8
+ /**
9
+ * Classify a pgpm script's substantive statements: every top-level statement
10
+ * except transaction control (`BEGIN`/`COMMIT`/`ROLLBACK` wrappers).
11
+ */
12
+ function classifyScript(sql) {
13
+ return (0, transform_1.classifyStatements)(sql).filter(f => f.nodeTag !== 'TransactionStmt');
14
+ }
15
+ /**
16
+ * Whether a script is an empty stub: nothing but comments, whitespace, and
17
+ * a transaction wrapper — no substantive statements. Scaffolded
18
+ * `-- Add your revert SQL here` files and bare `BEGIN; ... COMMIT;` shells
19
+ * are stubs. A script that fails to parse is NOT a stub (never treat content
20
+ * we cannot understand as safe to overwrite).
21
+ */
22
+ function isStubScript(sql) {
23
+ if (!sql || !sql.trim())
24
+ return true;
25
+ try {
26
+ return classifyScript(sql).length === 0;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ /**
33
+ * Generate revert and verify scripts (headerless, unwrapped) for one
34
+ * change's deploy SQL. Statements with no derivable inverse contribute a
35
+ * `-- revert not derivable: <reason>` comment and a warning; statements
36
+ * whose effect cannot be existence-checked add a verify warning.
37
+ */
38
+ function regenerateScripts(deploySql) {
39
+ const facts = classifyScript(deploySql);
40
+ return {
41
+ revert: (0, scripts_1.revertFor)(facts),
42
+ verify: (0, scripts_1.verifyFor)(facts)
43
+ };
44
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Semantic diff driver: identity-keyed schema delta between two deploy
3
+ * surfaces.
4
+ *
5
+ * Where `diffBundles` (in `@pgpmjs/bundle`) compares change names and content
6
+ * digests — which breaks the moment a dial regroups or renames changes — this
7
+ * driver re-keys the comparison on object identity (`identityOf`), so the
8
+ * same schema authored at different granularities or partitions diffs as
9
+ * equal. Objects are compared at the AST level (normalized parse trees, so
10
+ * formatting never matters); tables are compared column-by-column and
11
+ * constraint-by-constraint so an added column emits `ALTER TABLE ADD COLUMN`,
12
+ * not a table rebuild.
13
+ *
14
+ * The emitted delta is a set of pgpm changes: DROPs for removed objects
15
+ * (reverse topological order), then CREATE/ALTER changes for added and
16
+ * modified objects routed through the granularity pipeline
17
+ * (`restructureChanges`), so names come from the naming spec and `requires`
18
+ * from the statement graph — the same derivation every other projection uses.
19
+ * Every emitted change carries a full deploy/revert/verify triple: inverses
20
+ * and existence checks are composed at the AST level via `@pgsql/scripts`'
21
+ * node-level API (`invertStatement`/`existenceCheck`) — never hand-templated
22
+ * and never round-tripped through deparsed text — deparsing only once at the
23
+ * end. Statements the node layer cannot derive fall back to the text layer
24
+ * (`revertFor`/`verifyFor`) solely to surface its not-derivable comment and
25
+ * warning.
26
+ *
27
+ * Requires `loadModule()` from `plpgsql-parser` before use.
28
+ */
29
+ import { PathStyle } from '@pgpmjs/naming-spec';
30
+ import type { ObjectIdentity } from '@pgsql/transform';
31
+ import { Granularity } from '@pgsql/transform';
32
+ import { GranularityChange } from './granularity-driver';
33
+ /** How one object differs between the two sides. */
34
+ export type ObjectDelta = 'added' | 'removed' | 'modified';
35
+ export interface SemanticObjectDiff {
36
+ identity: ObjectIdentity;
37
+ /** Canonical naming-spec path for the object. */
38
+ path: string;
39
+ delta: ObjectDelta;
40
+ /** For modified tables: the column/constraint-level detail. */
41
+ columnsAdded?: string[];
42
+ columnsRemoved?: string[];
43
+ columnsModified?: string[];
44
+ }
45
+ export interface SemanticDiffOptions {
46
+ /** Granularity for the emitted CREATE/ALTER changes (default `object`). */
47
+ granularity?: Granularity;
48
+ /** Naming-spec rendering style (default `directory`). */
49
+ style?: PathStyle;
50
+ }
51
+ /** One emitted delta change: a full deploy/revert/verify triple. */
52
+ export interface SemanticDeltaChange extends GranularityChange {
53
+ revert: string;
54
+ verify: string;
55
+ }
56
+ export interface SemanticDiffResult {
57
+ /** True when both sides describe the same objects with the same shapes. */
58
+ identical: boolean;
59
+ /** Per-object dispositions, in `to`-then-removed order. */
60
+ objects: SemanticObjectDiff[];
61
+ /**
62
+ * The migration delta: DROP changes for removed objects first (reverse
63
+ * topological order), then CREATE/ALTER changes named and ordered by the
64
+ * granularity pipeline.
65
+ */
66
+ changes: SemanticDeltaChange[];
67
+ warnings: string[];
68
+ }
69
+ /**
70
+ * Diff two deploy surfaces by object identity and emit the migration delta.
71
+ *
72
+ * `from`/`to` are whole deploy scripts (flatten changes in plan order to
73
+ * diff bundles/modules). The result's `changes` deploy `to`'s shape on top
74
+ * of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
75
+ * ordered by `restructureChanges`.
76
+ */
77
+ export declare function diffSchemas(from: string, to: string, options?: SemanticDiffOptions): SemanticDiffResult;
78
+ /** A change's deploy surface (same seam as the other drivers). */
79
+ export interface DiffInputChange {
80
+ name: string;
81
+ dependencies: string[];
82
+ deploy: string;
83
+ }
84
+ /**
85
+ * Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
86
+ * in plan order and delegates to {@link diffSchemas}. Change names never
87
+ * enter the comparison — regrouping, renaming, or repartitioning the same
88
+ * schema yields an identical diff.
89
+ */
90
+ export declare function diffChangeSets(from: DiffInputChange[], to: DiffInputChange[], options?: SemanticDiffOptions): SemanticDiffResult;
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.diffSchemas = diffSchemas;
4
+ exports.diffChangeSets = diffChangeSets;
5
+ /**
6
+ * Semantic diff driver: identity-keyed schema delta between two deploy
7
+ * surfaces.
8
+ *
9
+ * Where `diffBundles` (in `@pgpmjs/bundle`) compares change names and content
10
+ * digests — which breaks the moment a dial regroups or renames changes — this
11
+ * driver re-keys the comparison on object identity (`identityOf`), so the
12
+ * same schema authored at different granularities or partitions diffs as
13
+ * equal. Objects are compared at the AST level (normalized parse trees, so
14
+ * formatting never matters); tables are compared column-by-column and
15
+ * constraint-by-constraint so an added column emits `ALTER TABLE ADD COLUMN`,
16
+ * not a table rebuild.
17
+ *
18
+ * The emitted delta is a set of pgpm changes: DROPs for removed objects
19
+ * (reverse topological order), then CREATE/ALTER changes for added and
20
+ * modified objects routed through the granularity pipeline
21
+ * (`restructureChanges`), so names come from the naming spec and `requires`
22
+ * from the statement graph — the same derivation every other projection uses.
23
+ * Every emitted change carries a full deploy/revert/verify triple: inverses
24
+ * and existence checks are composed at the AST level via `@pgsql/scripts`'
25
+ * node-level API (`invertStatement`/`existenceCheck`) — never hand-templated
26
+ * and never round-tripped through deparsed text — deparsing only once at the
27
+ * end. Statements the node layer cannot derive fall back to the text layer
28
+ * (`revertFor`/`verifyFor`) solely to surface its not-derivable comment and
29
+ * warning.
30
+ *
31
+ * Requires `loadModule()` from `plpgsql-parser` before use.
32
+ */
33
+ const naming_spec_1 = require("@pgpmjs/naming-spec");
34
+ const scripts_1 = require("@pgsql/scripts");
35
+ const transform_1 = require("@pgsql/transform");
36
+ const plpgsql_parser_1 = require("plpgsql-parser");
37
+ const granularity_driver_1 = require("./granularity-driver");
38
+ const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
39
+ /**
40
+ * Group one side's script into identity-keyed object units. Statements with
41
+ * no identity (grants, comments) attach to the unit of the first object they
42
+ * reference, falling back to the preceding statement's unit; truly homeless
43
+ * statements are reported via `warnings`.
44
+ */
45
+ function readSide(sql, warnings, label) {
46
+ const facts = (0, transform_1.classifyStatements)(sql);
47
+ const graph = (0, transform_1.buildStatementGraph)(facts);
48
+ const units = new Map();
49
+ const order = [];
50
+ const unitOf = new Array(facts.length).fill(null);
51
+ const byObject = new Map();
52
+ facts.forEach((f, i) => {
53
+ const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
54
+ if (!text)
55
+ return;
56
+ let identity = groupingIdentity(f);
57
+ if (identity && identity.kind === 'other') {
58
+ const asTable = identityKey({ kind: 'table', schema: identity.schema, name: identity.name });
59
+ if (units.has(asTable))
60
+ identity = units.get(asTable).identity;
61
+ }
62
+ let key = null;
63
+ if (identity) {
64
+ key = identityKey(identity);
65
+ if (!units.has(key)) {
66
+ units.set(key, { identity, statements: [], texts: [] });
67
+ order.push(key);
68
+ }
69
+ if (!identity.table) {
70
+ byObject.set(`${identity.schema ?? ''}.${identity.name}`, key);
71
+ }
72
+ }
73
+ else {
74
+ for (const r of f.references) {
75
+ const host = byObject.get(`${r.schema ?? ''}.${r.name}`);
76
+ if (host) {
77
+ key = host;
78
+ break;
79
+ }
80
+ }
81
+ if (!key && i > 0)
82
+ key = unitOf[i - 1];
83
+ }
84
+ if (!key) {
85
+ warnings.push(`${label}: statement ${i} (${f.kind}) belongs to no object; ignored in the diff`);
86
+ return;
87
+ }
88
+ unitOf[i] = key;
89
+ const unit = units.get(key);
90
+ unit.statements.push(i);
91
+ unit.texts.push(text.endsWith(';') ? text : `${text};`);
92
+ });
93
+ return { facts, units, order, graph };
94
+ }
95
+ /**
96
+ * The identity a statement groups under. Table-shaping statements — ALTER
97
+ * TABLE column/constraint additions, RLS enablement — fold into their table's
98
+ * unit so that atomic and consolidated authorships of the same table land in
99
+ * the same unit and compare shape-to-shape. Generic ALTERs (`kind: 'other'`)
100
+ * fold in `readSide` when a table unit for the same object already exists.
101
+ */
102
+ function groupingIdentity(f) {
103
+ const identity = (0, transform_1.identityOf)(f);
104
+ if (!identity)
105
+ return null;
106
+ if ((f.kind === 'table' || f.kind === 'constraint' || f.kind === 'rls_enable') &&
107
+ identity.kind !== 'table') {
108
+ return { kind: 'table', schema: identity.schema, name: identity.table ?? identity.name };
109
+ }
110
+ return identity;
111
+ }
112
+ /** Normalized-AST fingerprint of one statement (formatting/position-proof). */
113
+ function fingerprint(text) {
114
+ const parsed = (0, plpgsql_parser_1.parseSync)(text);
115
+ return JSON.stringify((0, transform_1.cleanTree)(parsed.sql.stmts.map(s => s.stmt)));
116
+ }
117
+ /** Multiset fingerprint of a whole unit. */
118
+ const unitFingerprint = (unit) => unit.texts.map(fingerprint).sort().join('\n');
119
+ /**
120
+ * Fold a table unit's statements (CREATE TABLE plus any ALTER TABLE
121
+ * ADD COLUMN / ADD CONSTRAINT) into an effective shape, so atomic and
122
+ * consolidated authorships of the same table compare equal.
123
+ */
124
+ function tableShape(unit) {
125
+ const shape = { relation: null, columns: new Map(), extras: new Map() };
126
+ const addColumn = (node) => {
127
+ const def = node;
128
+ const clean = (0, transform_1.cleanTree)(node);
129
+ shape.columns.set(def.colname ?? '', { def: clean, print: JSON.stringify(clean) });
130
+ };
131
+ const addExtra = (node) => {
132
+ const clean = (0, transform_1.cleanTree)(node);
133
+ shape.extras.set(JSON.stringify(clean), clean);
134
+ };
135
+ for (const text of unit.texts) {
136
+ for (const raw of (0, plpgsql_parser_1.parseSync)(text).sql.stmts) {
137
+ const stmt = raw.stmt;
138
+ if (stmt.CreateStmt) {
139
+ shape.relation = stmt.CreateStmt.relation;
140
+ const elts = stmt.CreateStmt.tableElts ?? [];
141
+ for (const elt of elts) {
142
+ if (elt.ColumnDef)
143
+ addColumn(elt.ColumnDef);
144
+ else
145
+ addExtra(elt);
146
+ }
147
+ }
148
+ else if (stmt.AlterTableStmt) {
149
+ shape.relation ??= stmt.AlterTableStmt.relation;
150
+ const cmds = stmt.AlterTableStmt.cmds ?? [];
151
+ for (const cmd of cmds) {
152
+ const at = cmd.AlterTableCmd;
153
+ if (!at)
154
+ continue;
155
+ const def = at.def;
156
+ if (at.subtype === 'AT_AddColumn' && def?.ColumnDef) {
157
+ addColumn(def.ColumnDef);
158
+ }
159
+ else {
160
+ addExtra({ AlterTableCmd: at });
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ addExtra(stmt);
166
+ }
167
+ }
168
+ }
169
+ return shape;
170
+ }
171
+ const deparseStmt = (node) => `${plpgsql_parser_1.Deparser.deparse(node, { pretty: false })};`;
172
+ /** Column-level ALTERs between two table shapes. */
173
+ function diffTable(from, to, diff, warnings) {
174
+ const stmts = [];
175
+ const relation = to.relation ?? from.relation;
176
+ const alter = (cmd) => deparseStmt({
177
+ AlterTableStmt: {
178
+ relation,
179
+ cmds: [{ AlterTableCmd: { behavior: 'DROP_RESTRICT', ...cmd } }],
180
+ objtype: 'OBJECT_TABLE'
181
+ }
182
+ });
183
+ diff.columnsAdded = [];
184
+ diff.columnsRemoved = [];
185
+ diff.columnsModified = [];
186
+ for (const [name, col] of to.columns) {
187
+ const prior = from.columns.get(name);
188
+ if (!prior) {
189
+ diff.columnsAdded.push(name);
190
+ stmts.push(alter({ subtype: 'AT_AddColumn', def: { ColumnDef: col.def } }));
191
+ }
192
+ else if (prior.print !== col.print) {
193
+ diff.columnsModified.push(name);
194
+ const fromDef = prior.def;
195
+ const toDef = col.def;
196
+ const typeChanged = JSON.stringify(fromDef.typeName) !== JSON.stringify(toDef.typeName);
197
+ const restChanged = JSON.stringify({ ...fromDef, typeName: null }) !==
198
+ JSON.stringify({ ...toDef, typeName: null });
199
+ if (typeChanged) {
200
+ stmts.push(alter({
201
+ subtype: 'AT_AlterColumnType',
202
+ name,
203
+ def: { ColumnDef: { typeName: toDef.typeName } }
204
+ }));
205
+ }
206
+ if (restChanged) {
207
+ warnings.push(`${diff.path}: column "${name}" changed beyond its type (constraints/defaults); emit a manual ALTER`);
208
+ }
209
+ }
210
+ }
211
+ for (const name of from.columns.keys()) {
212
+ if (!to.columns.has(name)) {
213
+ diff.columnsRemoved.push(name);
214
+ stmts.push(alter({ subtype: 'AT_DropColumn', name }));
215
+ }
216
+ }
217
+ for (const [print, extra] of to.extras) {
218
+ if (!from.extras.has(print)) {
219
+ const at = extra.AlterTableCmd;
220
+ if (at)
221
+ stmts.push(alter(at));
222
+ else if (extra.Constraint) {
223
+ stmts.push(alter({ subtype: 'AT_AddConstraint', def: extra }));
224
+ }
225
+ else {
226
+ warnings.push(`${diff.path}: unrecognized added table element; emit a manual ALTER`);
227
+ }
228
+ }
229
+ }
230
+ for (const [print, extra] of from.extras) {
231
+ if (to.extras.has(print))
232
+ continue;
233
+ const conname = extra.Constraint?.conname
234
+ ?? extra
235
+ .AlterTableCmd?.def?.Constraint?.conname;
236
+ if (conname) {
237
+ stmts.push(alter({ subtype: 'AT_DropConstraint', name: conname }));
238
+ }
239
+ else {
240
+ warnings.push(`${diff.path}: removed table element has no name to drop; emit a manual ALTER`);
241
+ }
242
+ }
243
+ return stmts;
244
+ }
245
+ /**
246
+ * Diff two deploy surfaces by object identity and emit the migration delta.
247
+ *
248
+ * `from`/`to` are whole deploy scripts (flatten changes in plan order to
249
+ * diff bundles/modules). The result's `changes` deploy `to`'s shape on top
250
+ * of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
251
+ * ordered by `restructureChanges`.
252
+ */
253
+ function diffSchemas(from, to, options = {}) {
254
+ const style = options.style ?? 'directory';
255
+ const warnings = [];
256
+ const a = readSide(from, warnings, 'from');
257
+ const b = readSide(to, warnings, 'to');
258
+ const objects = [];
259
+ const forwardSql = [];
260
+ const modifiedChanges = [];
261
+ const dropUnits = [];
262
+ /**
263
+ * `@pgsql/scripts` inverse of a deploy script (drops in reverse topo
264
+ * order), composed at the AST level via `invertStatement` and deparsed
265
+ * once. Underivable statements fall back to `revertFor` on the single
266
+ * statement to surface its not-derivable comment and warning.
267
+ */
268
+ const inverseOf = (deploy, context) => {
269
+ const facts = (0, transform_1.classifyStatements)(deploy);
270
+ const graph = (0, transform_1.buildStatementGraph)(facts);
271
+ const pieces = [];
272
+ for (const i of [...graph.order].reverse()) {
273
+ const nodes = (0, scripts_1.invertStatement)(facts[i]);
274
+ if (nodes === null) {
275
+ const { sql, warnings: w } = (0, scripts_1.revertFor)([facts[i]]);
276
+ warnings.push(...w.map(x => `${context}: ${x}`));
277
+ if (sql.trim())
278
+ pieces.push(sql);
279
+ continue;
280
+ }
281
+ pieces.push(...nodes.map(n => deparseStmt(n)));
282
+ }
283
+ return pieces.join('\n');
284
+ };
285
+ /**
286
+ * `@pgsql/scripts` existence checks for a deploy script, composed from
287
+ * `existenceCheck`'s SelectStmt nodes and deparsed once. Underivable
288
+ * statements fall back to `verifyFor` to surface the warning.
289
+ */
290
+ const verifyOf = (deploy, context) => {
291
+ const pieces = [];
292
+ for (const fact of (0, transform_1.classifyStatements)(deploy)) {
293
+ const nodes = (0, scripts_1.existenceCheck)(fact);
294
+ if (nodes === null) {
295
+ const { warnings: w } = (0, scripts_1.verifyFor)([fact]);
296
+ warnings.push(...w.map(x => `${context}: ${x}`));
297
+ continue;
298
+ }
299
+ pieces.push(...nodes.map(n => deparseStmt(n)));
300
+ }
301
+ return pieces.join('\n\n');
302
+ };
303
+ for (const key of b.order) {
304
+ const unit = b.units.get(key);
305
+ const prior = a.units.get(key);
306
+ const path = (0, naming_spec_1.pathFor)(unit.identity, { style });
307
+ if (!prior) {
308
+ objects.push({ identity: unit.identity, path, delta: 'added' });
309
+ forwardSql.push(...unit.texts);
310
+ continue;
311
+ }
312
+ if (unit.identity.kind === 'table') {
313
+ const shapeFrom = tableShape(prior);
314
+ const shapeTo = tableShape(unit);
315
+ const diff = { identity: unit.identity, path, delta: 'modified' };
316
+ const stmts = diffTable(shapeFrom, shapeTo, diff, warnings);
317
+ const columnsTouched = diff.columnsAdded.length + diff.columnsRemoved.length + diff.columnsModified.length > 0;
318
+ if (stmts.length > 0 || columnsTouched) {
319
+ objects.push(diff);
320
+ if (stmts.length > 0) {
321
+ // The inverse alteration is the same diff with the sides swapped.
322
+ const scratch = { identity: unit.identity, path, delta: 'modified' };
323
+ const deploy = stmts.join('\n');
324
+ modifiedChanges.push({
325
+ name: `${path}/alter`,
326
+ dependencies: [],
327
+ deploy,
328
+ revert: diffTable(shapeTo, shapeFrom, scratch, []).join('\n'),
329
+ verify: verifyOf(deploy, path)
330
+ });
331
+ }
332
+ }
333
+ continue;
334
+ }
335
+ if (unitFingerprint(prior) !== unitFingerprint(unit)) {
336
+ objects.push({ identity: unit.identity, path, delta: 'modified' });
337
+ const priorDeploy = prior.texts.join('\n');
338
+ const newDeploy = unit.texts.join('\n');
339
+ modifiedChanges.push({
340
+ name: path,
341
+ dependencies: [],
342
+ deploy: [inverseOf(priorDeploy, path), newDeploy].filter(Boolean).join('\n'),
343
+ revert: [inverseOf(newDeploy, path), priorDeploy].filter(Boolean).join('\n'),
344
+ verify: verifyOf(newDeploy, path)
345
+ });
346
+ }
347
+ }
348
+ for (const key of a.order) {
349
+ if (b.units.has(key))
350
+ continue;
351
+ const unit = a.units.get(key);
352
+ objects.push({ identity: unit.identity, path: (0, naming_spec_1.pathFor)(unit.identity, { style }), delta: 'removed' });
353
+ dropUnits.push(unit);
354
+ }
355
+ // Removed objects drop in reverse topological order of the `from` graph
356
+ // (dependents before dependencies), one change per object.
357
+ const position = new Map(dropUnits.map(u => [u, Math.min(...u.statements.map(s => a.graph.order.indexOf(s)))]));
358
+ dropUnits.sort((x, y) => position.get(y) - position.get(x));
359
+ const dropChanges = [];
360
+ for (const unit of dropUnits) {
361
+ const path = (0, naming_spec_1.pathFor)(unit.identity, { style });
362
+ const recreate = unit.texts.join('\n');
363
+ const drop = inverseOf(recreate, path);
364
+ if (!drop.trim() || drop.trim().startsWith('--')) {
365
+ warnings.push(`${path}: no DROP derivable for kind "${unit.identity.kind}"; drop manually`);
366
+ continue;
367
+ }
368
+ dropChanges.push({
369
+ name: `${path}/drop`,
370
+ dependencies: dropChanges.length > 0 ? [dropChanges[dropChanges.length - 1].name] : [],
371
+ deploy: drop,
372
+ revert: recreate,
373
+ verify: ''
374
+ });
375
+ }
376
+ let forwardChanges = [];
377
+ if (forwardSql.length > 0) {
378
+ const { changes, warnings: w } = (0, granularity_driver_1.restructureChanges)([{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }], { granularity: options.granularity ?? 'object' });
379
+ forwardChanges = changes.map(c => ({
380
+ ...c,
381
+ revert: inverseOf(c.deploy, c.name),
382
+ verify: verifyOf(c.deploy, c.name)
383
+ }));
384
+ warnings.push(...w);
385
+ }
386
+ return {
387
+ identical: objects.length === 0,
388
+ objects,
389
+ changes: [...dropChanges, ...forwardChanges, ...modifiedChanges],
390
+ warnings
391
+ };
392
+ }
393
+ /**
394
+ * Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
395
+ * in plan order and delegates to {@link diffSchemas}. Change names never
396
+ * enter the comparison — regrouping, renaming, or repartitioning the same
397
+ * schema yields an identical diff.
398
+ */
399
+ function diffChangeSets(from, to, options = {}) {
400
+ const flatten = (changes) => changes.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
401
+ return diffSchemas(flatten(from), flatten(to), options);
402
+ }