@pgpmjs/transform 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,366 @@
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: drops are
20
+ * `@pgsql/scripts`' `revertFor` inverses of the object's own deploy
21
+ * statements (never hand-templated), reverts recreate what was dropped, and
22
+ * verifies come from `verifyFor`.
23
+ *
24
+ * Requires `loadModule()` from `plpgsql-parser` before use.
25
+ */
26
+ import { pathFor } from '@pgpmjs/naming-spec';
27
+ import { revertFor, verifyFor } from '@pgsql/scripts';
28
+ import { buildStatementGraph, classifyStatements, cleanTree, identityOf } from '@pgsql/transform';
29
+ import { Deparser, parseSync } from 'plpgsql-parser';
30
+ import { restructureChanges } from './granularity-driver';
31
+ const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
32
+ /**
33
+ * Group one side's script into identity-keyed object units. Statements with
34
+ * no identity (grants, comments) attach to the unit of the first object they
35
+ * reference, falling back to the preceding statement's unit; truly homeless
36
+ * statements are reported via `warnings`.
37
+ */
38
+ function readSide(sql, warnings, label) {
39
+ const facts = classifyStatements(sql);
40
+ const graph = buildStatementGraph(facts);
41
+ const units = new Map();
42
+ const order = [];
43
+ const unitOf = new Array(facts.length).fill(null);
44
+ const byObject = new Map();
45
+ facts.forEach((f, i) => {
46
+ const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
47
+ if (!text)
48
+ return;
49
+ let identity = groupingIdentity(f);
50
+ if (identity && identity.kind === 'other') {
51
+ const asTable = identityKey({ kind: 'table', schema: identity.schema, name: identity.name });
52
+ if (units.has(asTable))
53
+ identity = units.get(asTable).identity;
54
+ }
55
+ let key = null;
56
+ if (identity) {
57
+ key = identityKey(identity);
58
+ if (!units.has(key)) {
59
+ units.set(key, { identity, statements: [], texts: [] });
60
+ order.push(key);
61
+ }
62
+ if (!identity.table) {
63
+ byObject.set(`${identity.schema ?? ''}.${identity.name}`, key);
64
+ }
65
+ }
66
+ else {
67
+ for (const r of f.references) {
68
+ const host = byObject.get(`${r.schema ?? ''}.${r.name}`);
69
+ if (host) {
70
+ key = host;
71
+ break;
72
+ }
73
+ }
74
+ if (!key && i > 0)
75
+ key = unitOf[i - 1];
76
+ }
77
+ if (!key) {
78
+ warnings.push(`${label}: statement ${i} (${f.kind}) belongs to no object; ignored in the diff`);
79
+ return;
80
+ }
81
+ unitOf[i] = key;
82
+ const unit = units.get(key);
83
+ unit.statements.push(i);
84
+ unit.texts.push(text.endsWith(';') ? text : `${text};`);
85
+ });
86
+ return { facts, units, order, graph };
87
+ }
88
+ /**
89
+ * The identity a statement groups under. Table-shaping statements — ALTER
90
+ * TABLE column/constraint additions, RLS enablement — fold into their table's
91
+ * unit so that atomic and consolidated authorships of the same table land in
92
+ * the same unit and compare shape-to-shape. Generic ALTERs (`kind: 'other'`)
93
+ * fold in `readSide` when a table unit for the same object already exists.
94
+ */
95
+ function groupingIdentity(f) {
96
+ const identity = identityOf(f);
97
+ if (!identity)
98
+ return null;
99
+ if ((f.kind === 'table' || f.kind === 'constraint' || f.kind === 'rls_enable') &&
100
+ identity.kind !== 'table') {
101
+ return { kind: 'table', schema: identity.schema, name: identity.table ?? identity.name };
102
+ }
103
+ return identity;
104
+ }
105
+ /** Normalized-AST fingerprint of one statement (formatting/position-proof). */
106
+ function fingerprint(text) {
107
+ const parsed = parseSync(text);
108
+ return JSON.stringify(cleanTree(parsed.sql.stmts.map(s => s.stmt)));
109
+ }
110
+ /** Multiset fingerprint of a whole unit. */
111
+ const unitFingerprint = (unit) => unit.texts.map(fingerprint).sort().join('\n');
112
+ /**
113
+ * Fold a table unit's statements (CREATE TABLE plus any ALTER TABLE
114
+ * ADD COLUMN / ADD CONSTRAINT) into an effective shape, so atomic and
115
+ * consolidated authorships of the same table compare equal.
116
+ */
117
+ function tableShape(unit) {
118
+ const shape = { relation: null, columns: new Map(), extras: new Map() };
119
+ const addColumn = (node) => {
120
+ const def = node;
121
+ const clean = cleanTree(node);
122
+ shape.columns.set(def.colname ?? '', { def: clean, print: JSON.stringify(clean) });
123
+ };
124
+ const addExtra = (node) => {
125
+ const clean = cleanTree(node);
126
+ shape.extras.set(JSON.stringify(clean), clean);
127
+ };
128
+ for (const text of unit.texts) {
129
+ for (const raw of parseSync(text).sql.stmts) {
130
+ const stmt = raw.stmt;
131
+ if (stmt.CreateStmt) {
132
+ shape.relation = stmt.CreateStmt.relation;
133
+ const elts = stmt.CreateStmt.tableElts ?? [];
134
+ for (const elt of elts) {
135
+ if (elt.ColumnDef)
136
+ addColumn(elt.ColumnDef);
137
+ else
138
+ addExtra(elt);
139
+ }
140
+ }
141
+ else if (stmt.AlterTableStmt) {
142
+ shape.relation ??= stmt.AlterTableStmt.relation;
143
+ const cmds = stmt.AlterTableStmt.cmds ?? [];
144
+ for (const cmd of cmds) {
145
+ const at = cmd.AlterTableCmd;
146
+ if (!at)
147
+ continue;
148
+ const def = at.def;
149
+ if (at.subtype === 'AT_AddColumn' && def?.ColumnDef) {
150
+ addColumn(def.ColumnDef);
151
+ }
152
+ else {
153
+ addExtra({ AlterTableCmd: at });
154
+ }
155
+ }
156
+ }
157
+ else {
158
+ addExtra(stmt);
159
+ }
160
+ }
161
+ }
162
+ return shape;
163
+ }
164
+ const deparseStmt = (node) => `${Deparser.deparse(node, { pretty: false })};`;
165
+ /** Column-level ALTERs between two table shapes. */
166
+ function diffTable(from, to, diff, warnings) {
167
+ const stmts = [];
168
+ const relation = to.relation ?? from.relation;
169
+ const alter = (cmd) => deparseStmt({
170
+ AlterTableStmt: {
171
+ relation,
172
+ cmds: [{ AlterTableCmd: { behavior: 'DROP_RESTRICT', ...cmd } }],
173
+ objtype: 'OBJECT_TABLE'
174
+ }
175
+ });
176
+ diff.columnsAdded = [];
177
+ diff.columnsRemoved = [];
178
+ diff.columnsModified = [];
179
+ for (const [name, col] of to.columns) {
180
+ const prior = from.columns.get(name);
181
+ if (!prior) {
182
+ diff.columnsAdded.push(name);
183
+ stmts.push(alter({ subtype: 'AT_AddColumn', def: { ColumnDef: col.def } }));
184
+ }
185
+ else if (prior.print !== col.print) {
186
+ diff.columnsModified.push(name);
187
+ const fromDef = prior.def;
188
+ const toDef = col.def;
189
+ const typeChanged = JSON.stringify(fromDef.typeName) !== JSON.stringify(toDef.typeName);
190
+ const restChanged = JSON.stringify({ ...fromDef, typeName: null }) !==
191
+ JSON.stringify({ ...toDef, typeName: null });
192
+ if (typeChanged) {
193
+ stmts.push(alter({
194
+ subtype: 'AT_AlterColumnType',
195
+ name,
196
+ def: { ColumnDef: { typeName: toDef.typeName } }
197
+ }));
198
+ }
199
+ if (restChanged) {
200
+ warnings.push(`${diff.path}: column "${name}" changed beyond its type (constraints/defaults); emit a manual ALTER`);
201
+ }
202
+ }
203
+ }
204
+ for (const name of from.columns.keys()) {
205
+ if (!to.columns.has(name)) {
206
+ diff.columnsRemoved.push(name);
207
+ stmts.push(alter({ subtype: 'AT_DropColumn', name }));
208
+ }
209
+ }
210
+ for (const [print, extra] of to.extras) {
211
+ if (!from.extras.has(print)) {
212
+ const at = extra.AlterTableCmd;
213
+ if (at)
214
+ stmts.push(alter(at));
215
+ else if (extra.Constraint) {
216
+ stmts.push(alter({ subtype: 'AT_AddConstraint', def: extra }));
217
+ }
218
+ else {
219
+ warnings.push(`${diff.path}: unrecognized added table element; emit a manual ALTER`);
220
+ }
221
+ }
222
+ }
223
+ for (const [print, extra] of from.extras) {
224
+ if (to.extras.has(print))
225
+ continue;
226
+ const conname = extra.Constraint?.conname
227
+ ?? extra
228
+ .AlterTableCmd?.def?.Constraint?.conname;
229
+ if (conname) {
230
+ stmts.push(alter({ subtype: 'AT_DropConstraint', name: conname }));
231
+ }
232
+ else {
233
+ warnings.push(`${diff.path}: removed table element has no name to drop; emit a manual ALTER`);
234
+ }
235
+ }
236
+ return stmts;
237
+ }
238
+ /**
239
+ * Diff two deploy surfaces by object identity and emit the migration delta.
240
+ *
241
+ * `from`/`to` are whole deploy scripts (flatten changes in plan order to
242
+ * diff bundles/modules). The result's `changes` deploy `to`'s shape on top
243
+ * of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
244
+ * ordered by `restructureChanges`.
245
+ */
246
+ export function diffSchemas(from, to, options = {}) {
247
+ const style = options.style ?? 'directory';
248
+ const warnings = [];
249
+ const a = readSide(from, warnings, 'from');
250
+ const b = readSide(to, warnings, 'to');
251
+ const objects = [];
252
+ const forwardSql = [];
253
+ const modifiedChanges = [];
254
+ const dropUnits = [];
255
+ /** `@pgsql/scripts` inverse of a deploy script (drops in reverse topo order). */
256
+ const inverseOf = (deploy, context) => {
257
+ const { sql, warnings: w } = revertFor(classifyStatements(deploy));
258
+ warnings.push(...w.map(x => `${context}: ${x}`));
259
+ return sql;
260
+ };
261
+ /** `@pgsql/scripts` existence checks for a deploy script. */
262
+ const verifyOf = (deploy, context) => {
263
+ const { sql, warnings: w } = verifyFor(classifyStatements(deploy));
264
+ warnings.push(...w.map(x => `${context}: ${x}`));
265
+ return sql;
266
+ };
267
+ for (const key of b.order) {
268
+ const unit = b.units.get(key);
269
+ const prior = a.units.get(key);
270
+ const path = pathFor(unit.identity, { style });
271
+ if (!prior) {
272
+ objects.push({ identity: unit.identity, path, delta: 'added' });
273
+ forwardSql.push(...unit.texts);
274
+ continue;
275
+ }
276
+ if (unit.identity.kind === 'table') {
277
+ const shapeFrom = tableShape(prior);
278
+ const shapeTo = tableShape(unit);
279
+ const diff = { identity: unit.identity, path, delta: 'modified' };
280
+ const stmts = diffTable(shapeFrom, shapeTo, diff, warnings);
281
+ const columnsTouched = diff.columnsAdded.length + diff.columnsRemoved.length + diff.columnsModified.length > 0;
282
+ if (stmts.length > 0 || columnsTouched) {
283
+ objects.push(diff);
284
+ if (stmts.length > 0) {
285
+ // The inverse alteration is the same diff with the sides swapped.
286
+ const scratch = { identity: unit.identity, path, delta: 'modified' };
287
+ const deploy = stmts.join('\n');
288
+ modifiedChanges.push({
289
+ name: `${path}/alter`,
290
+ dependencies: [],
291
+ deploy,
292
+ revert: diffTable(shapeTo, shapeFrom, scratch, []).join('\n'),
293
+ verify: verifyOf(deploy, path)
294
+ });
295
+ }
296
+ }
297
+ continue;
298
+ }
299
+ if (unitFingerprint(prior) !== unitFingerprint(unit)) {
300
+ objects.push({ identity: unit.identity, path, delta: 'modified' });
301
+ const priorDeploy = prior.texts.join('\n');
302
+ const newDeploy = unit.texts.join('\n');
303
+ modifiedChanges.push({
304
+ name: path,
305
+ dependencies: [],
306
+ deploy: [inverseOf(priorDeploy, path), newDeploy].filter(Boolean).join('\n'),
307
+ revert: [inverseOf(newDeploy, path), priorDeploy].filter(Boolean).join('\n'),
308
+ verify: verifyOf(newDeploy, path)
309
+ });
310
+ }
311
+ }
312
+ for (const key of a.order) {
313
+ if (b.units.has(key))
314
+ continue;
315
+ const unit = a.units.get(key);
316
+ objects.push({ identity: unit.identity, path: pathFor(unit.identity, { style }), delta: 'removed' });
317
+ dropUnits.push(unit);
318
+ }
319
+ // Removed objects drop in reverse topological order of the `from` graph
320
+ // (dependents before dependencies), one change per object.
321
+ const position = new Map(dropUnits.map(u => [u, Math.min(...u.statements.map(s => a.graph.order.indexOf(s)))]));
322
+ dropUnits.sort((x, y) => position.get(y) - position.get(x));
323
+ const dropChanges = [];
324
+ for (const unit of dropUnits) {
325
+ const path = pathFor(unit.identity, { style });
326
+ const recreate = unit.texts.join('\n');
327
+ const drop = inverseOf(recreate, path);
328
+ if (!drop.trim() || drop.trim().startsWith('--')) {
329
+ warnings.push(`${path}: no DROP derivable for kind "${unit.identity.kind}"; drop manually`);
330
+ continue;
331
+ }
332
+ dropChanges.push({
333
+ name: `${path}/drop`,
334
+ dependencies: dropChanges.length > 0 ? [dropChanges[dropChanges.length - 1].name] : [],
335
+ deploy: drop,
336
+ revert: recreate,
337
+ verify: ''
338
+ });
339
+ }
340
+ let forwardChanges = [];
341
+ if (forwardSql.length > 0) {
342
+ const { changes, warnings: w } = restructureChanges([{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }], { granularity: options.granularity ?? 'object' });
343
+ forwardChanges = changes.map(c => ({
344
+ ...c,
345
+ revert: inverseOf(c.deploy, c.name),
346
+ verify: verifyOf(c.deploy, c.name)
347
+ }));
348
+ warnings.push(...w);
349
+ }
350
+ return {
351
+ identical: objects.length === 0,
352
+ objects,
353
+ changes: [...dropChanges, ...forwardChanges, ...modifiedChanges],
354
+ warnings
355
+ };
356
+ }
357
+ /**
358
+ * Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
359
+ * in plan order and delegates to {@link diffSchemas}. Change names never
360
+ * enter the comparison — regrouping, renaming, or repartitioning the same
361
+ * schema yields an identical diff.
362
+ */
363
+ export function diffChangeSets(from, to, options = {}) {
364
+ const flatten = (changes) => changes.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
365
+ return diffSchemas(flatten(from), flatten(to), options);
366
+ }
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.17.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": "45cd961cf956d2d28e4d09b20db4204982c33593"
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,87 @@
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: drops are
20
+ * `@pgsql/scripts`' `revertFor` inverses of the object's own deploy
21
+ * statements (never hand-templated), reverts recreate what was dropped, and
22
+ * verifies come from `verifyFor`.
23
+ *
24
+ * Requires `loadModule()` from `plpgsql-parser` before use.
25
+ */
26
+ import { PathStyle } from '@pgpmjs/naming-spec';
27
+ import type { ObjectIdentity } from '@pgsql/transform';
28
+ import { Granularity } from '@pgsql/transform';
29
+ import { GranularityChange } from './granularity-driver';
30
+ /** How one object differs between the two sides. */
31
+ export type ObjectDelta = 'added' | 'removed' | 'modified';
32
+ export interface SemanticObjectDiff {
33
+ identity: ObjectIdentity;
34
+ /** Canonical naming-spec path for the object. */
35
+ path: string;
36
+ delta: ObjectDelta;
37
+ /** For modified tables: the column/constraint-level detail. */
38
+ columnsAdded?: string[];
39
+ columnsRemoved?: string[];
40
+ columnsModified?: string[];
41
+ }
42
+ export interface SemanticDiffOptions {
43
+ /** Granularity for the emitted CREATE/ALTER changes (default `object`). */
44
+ granularity?: Granularity;
45
+ /** Naming-spec rendering style (default `directory`). */
46
+ style?: PathStyle;
47
+ }
48
+ /** One emitted delta change: a full deploy/revert/verify triple. */
49
+ export interface SemanticDeltaChange extends GranularityChange {
50
+ revert: string;
51
+ verify: string;
52
+ }
53
+ export interface SemanticDiffResult {
54
+ /** True when both sides describe the same objects with the same shapes. */
55
+ identical: boolean;
56
+ /** Per-object dispositions, in `to`-then-removed order. */
57
+ objects: SemanticObjectDiff[];
58
+ /**
59
+ * The migration delta: DROP changes for removed objects first (reverse
60
+ * topological order), then CREATE/ALTER changes named and ordered by the
61
+ * granularity pipeline.
62
+ */
63
+ changes: SemanticDeltaChange[];
64
+ warnings: string[];
65
+ }
66
+ /**
67
+ * Diff two deploy surfaces by object identity and emit the migration delta.
68
+ *
69
+ * `from`/`to` are whole deploy scripts (flatten changes in plan order to
70
+ * diff bundles/modules). The result's `changes` deploy `to`'s shape on top
71
+ * of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
72
+ * ordered by `restructureChanges`.
73
+ */
74
+ export declare function diffSchemas(from: string, to: string, options?: SemanticDiffOptions): SemanticDiffResult;
75
+ /** A change's deploy surface (same seam as the other drivers). */
76
+ export interface DiffInputChange {
77
+ name: string;
78
+ dependencies: string[];
79
+ deploy: string;
80
+ }
81
+ /**
82
+ * Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
83
+ * in plan order and delegates to {@link diffSchemas}. Change names never
84
+ * enter the comparison — regrouping, renaming, or repartitioning the same
85
+ * schema yields an identical diff.
86
+ */
87
+ export declare function diffChangeSets(from: DiffInputChange[], to: DiffInputChange[], options?: SemanticDiffOptions): SemanticDiffResult;
@@ -0,0 +1,370 @@
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: drops are
24
+ * `@pgsql/scripts`' `revertFor` inverses of the object's own deploy
25
+ * statements (never hand-templated), reverts recreate what was dropped, and
26
+ * verifies come from `verifyFor`.
27
+ *
28
+ * Requires `loadModule()` from `plpgsql-parser` before use.
29
+ */
30
+ const naming_spec_1 = require("@pgpmjs/naming-spec");
31
+ const scripts_1 = require("@pgsql/scripts");
32
+ const transform_1 = require("@pgsql/transform");
33
+ const plpgsql_parser_1 = require("plpgsql-parser");
34
+ const granularity_driver_1 = require("./granularity-driver");
35
+ const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
36
+ /**
37
+ * Group one side's script into identity-keyed object units. Statements with
38
+ * no identity (grants, comments) attach to the unit of the first object they
39
+ * reference, falling back to the preceding statement's unit; truly homeless
40
+ * statements are reported via `warnings`.
41
+ */
42
+ function readSide(sql, warnings, label) {
43
+ const facts = (0, transform_1.classifyStatements)(sql);
44
+ const graph = (0, transform_1.buildStatementGraph)(facts);
45
+ const units = new Map();
46
+ const order = [];
47
+ const unitOf = new Array(facts.length).fill(null);
48
+ const byObject = new Map();
49
+ facts.forEach((f, i) => {
50
+ const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
51
+ if (!text)
52
+ return;
53
+ let identity = groupingIdentity(f);
54
+ if (identity && identity.kind === 'other') {
55
+ const asTable = identityKey({ kind: 'table', schema: identity.schema, name: identity.name });
56
+ if (units.has(asTable))
57
+ identity = units.get(asTable).identity;
58
+ }
59
+ let key = null;
60
+ if (identity) {
61
+ key = identityKey(identity);
62
+ if (!units.has(key)) {
63
+ units.set(key, { identity, statements: [], texts: [] });
64
+ order.push(key);
65
+ }
66
+ if (!identity.table) {
67
+ byObject.set(`${identity.schema ?? ''}.${identity.name}`, key);
68
+ }
69
+ }
70
+ else {
71
+ for (const r of f.references) {
72
+ const host = byObject.get(`${r.schema ?? ''}.${r.name}`);
73
+ if (host) {
74
+ key = host;
75
+ break;
76
+ }
77
+ }
78
+ if (!key && i > 0)
79
+ key = unitOf[i - 1];
80
+ }
81
+ if (!key) {
82
+ warnings.push(`${label}: statement ${i} (${f.kind}) belongs to no object; ignored in the diff`);
83
+ return;
84
+ }
85
+ unitOf[i] = key;
86
+ const unit = units.get(key);
87
+ unit.statements.push(i);
88
+ unit.texts.push(text.endsWith(';') ? text : `${text};`);
89
+ });
90
+ return { facts, units, order, graph };
91
+ }
92
+ /**
93
+ * The identity a statement groups under. Table-shaping statements — ALTER
94
+ * TABLE column/constraint additions, RLS enablement — fold into their table's
95
+ * unit so that atomic and consolidated authorships of the same table land in
96
+ * the same unit and compare shape-to-shape. Generic ALTERs (`kind: 'other'`)
97
+ * fold in `readSide` when a table unit for the same object already exists.
98
+ */
99
+ function groupingIdentity(f) {
100
+ const identity = (0, transform_1.identityOf)(f);
101
+ if (!identity)
102
+ return null;
103
+ if ((f.kind === 'table' || f.kind === 'constraint' || f.kind === 'rls_enable') &&
104
+ identity.kind !== 'table') {
105
+ return { kind: 'table', schema: identity.schema, name: identity.table ?? identity.name };
106
+ }
107
+ return identity;
108
+ }
109
+ /** Normalized-AST fingerprint of one statement (formatting/position-proof). */
110
+ function fingerprint(text) {
111
+ const parsed = (0, plpgsql_parser_1.parseSync)(text);
112
+ return JSON.stringify((0, transform_1.cleanTree)(parsed.sql.stmts.map(s => s.stmt)));
113
+ }
114
+ /** Multiset fingerprint of a whole unit. */
115
+ const unitFingerprint = (unit) => unit.texts.map(fingerprint).sort().join('\n');
116
+ /**
117
+ * Fold a table unit's statements (CREATE TABLE plus any ALTER TABLE
118
+ * ADD COLUMN / ADD CONSTRAINT) into an effective shape, so atomic and
119
+ * consolidated authorships of the same table compare equal.
120
+ */
121
+ function tableShape(unit) {
122
+ const shape = { relation: null, columns: new Map(), extras: new Map() };
123
+ const addColumn = (node) => {
124
+ const def = node;
125
+ const clean = (0, transform_1.cleanTree)(node);
126
+ shape.columns.set(def.colname ?? '', { def: clean, print: JSON.stringify(clean) });
127
+ };
128
+ const addExtra = (node) => {
129
+ const clean = (0, transform_1.cleanTree)(node);
130
+ shape.extras.set(JSON.stringify(clean), clean);
131
+ };
132
+ for (const text of unit.texts) {
133
+ for (const raw of (0, plpgsql_parser_1.parseSync)(text).sql.stmts) {
134
+ const stmt = raw.stmt;
135
+ if (stmt.CreateStmt) {
136
+ shape.relation = stmt.CreateStmt.relation;
137
+ const elts = stmt.CreateStmt.tableElts ?? [];
138
+ for (const elt of elts) {
139
+ if (elt.ColumnDef)
140
+ addColumn(elt.ColumnDef);
141
+ else
142
+ addExtra(elt);
143
+ }
144
+ }
145
+ else if (stmt.AlterTableStmt) {
146
+ shape.relation ??= stmt.AlterTableStmt.relation;
147
+ const cmds = stmt.AlterTableStmt.cmds ?? [];
148
+ for (const cmd of cmds) {
149
+ const at = cmd.AlterTableCmd;
150
+ if (!at)
151
+ continue;
152
+ const def = at.def;
153
+ if (at.subtype === 'AT_AddColumn' && def?.ColumnDef) {
154
+ addColumn(def.ColumnDef);
155
+ }
156
+ else {
157
+ addExtra({ AlterTableCmd: at });
158
+ }
159
+ }
160
+ }
161
+ else {
162
+ addExtra(stmt);
163
+ }
164
+ }
165
+ }
166
+ return shape;
167
+ }
168
+ const deparseStmt = (node) => `${plpgsql_parser_1.Deparser.deparse(node, { pretty: false })};`;
169
+ /** Column-level ALTERs between two table shapes. */
170
+ function diffTable(from, to, diff, warnings) {
171
+ const stmts = [];
172
+ const relation = to.relation ?? from.relation;
173
+ const alter = (cmd) => deparseStmt({
174
+ AlterTableStmt: {
175
+ relation,
176
+ cmds: [{ AlterTableCmd: { behavior: 'DROP_RESTRICT', ...cmd } }],
177
+ objtype: 'OBJECT_TABLE'
178
+ }
179
+ });
180
+ diff.columnsAdded = [];
181
+ diff.columnsRemoved = [];
182
+ diff.columnsModified = [];
183
+ for (const [name, col] of to.columns) {
184
+ const prior = from.columns.get(name);
185
+ if (!prior) {
186
+ diff.columnsAdded.push(name);
187
+ stmts.push(alter({ subtype: 'AT_AddColumn', def: { ColumnDef: col.def } }));
188
+ }
189
+ else if (prior.print !== col.print) {
190
+ diff.columnsModified.push(name);
191
+ const fromDef = prior.def;
192
+ const toDef = col.def;
193
+ const typeChanged = JSON.stringify(fromDef.typeName) !== JSON.stringify(toDef.typeName);
194
+ const restChanged = JSON.stringify({ ...fromDef, typeName: null }) !==
195
+ JSON.stringify({ ...toDef, typeName: null });
196
+ if (typeChanged) {
197
+ stmts.push(alter({
198
+ subtype: 'AT_AlterColumnType',
199
+ name,
200
+ def: { ColumnDef: { typeName: toDef.typeName } }
201
+ }));
202
+ }
203
+ if (restChanged) {
204
+ warnings.push(`${diff.path}: column "${name}" changed beyond its type (constraints/defaults); emit a manual ALTER`);
205
+ }
206
+ }
207
+ }
208
+ for (const name of from.columns.keys()) {
209
+ if (!to.columns.has(name)) {
210
+ diff.columnsRemoved.push(name);
211
+ stmts.push(alter({ subtype: 'AT_DropColumn', name }));
212
+ }
213
+ }
214
+ for (const [print, extra] of to.extras) {
215
+ if (!from.extras.has(print)) {
216
+ const at = extra.AlterTableCmd;
217
+ if (at)
218
+ stmts.push(alter(at));
219
+ else if (extra.Constraint) {
220
+ stmts.push(alter({ subtype: 'AT_AddConstraint', def: extra }));
221
+ }
222
+ else {
223
+ warnings.push(`${diff.path}: unrecognized added table element; emit a manual ALTER`);
224
+ }
225
+ }
226
+ }
227
+ for (const [print, extra] of from.extras) {
228
+ if (to.extras.has(print))
229
+ continue;
230
+ const conname = extra.Constraint?.conname
231
+ ?? extra
232
+ .AlterTableCmd?.def?.Constraint?.conname;
233
+ if (conname) {
234
+ stmts.push(alter({ subtype: 'AT_DropConstraint', name: conname }));
235
+ }
236
+ else {
237
+ warnings.push(`${diff.path}: removed table element has no name to drop; emit a manual ALTER`);
238
+ }
239
+ }
240
+ return stmts;
241
+ }
242
+ /**
243
+ * Diff two deploy surfaces by object identity and emit the migration delta.
244
+ *
245
+ * `from`/`to` are whole deploy scripts (flatten changes in plan order to
246
+ * diff bundles/modules). The result's `changes` deploy `to`'s shape on top
247
+ * of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
248
+ * ordered by `restructureChanges`.
249
+ */
250
+ function diffSchemas(from, to, options = {}) {
251
+ const style = options.style ?? 'directory';
252
+ const warnings = [];
253
+ const a = readSide(from, warnings, 'from');
254
+ const b = readSide(to, warnings, 'to');
255
+ const objects = [];
256
+ const forwardSql = [];
257
+ const modifiedChanges = [];
258
+ const dropUnits = [];
259
+ /** `@pgsql/scripts` inverse of a deploy script (drops in reverse topo order). */
260
+ const inverseOf = (deploy, context) => {
261
+ const { sql, warnings: w } = (0, scripts_1.revertFor)((0, transform_1.classifyStatements)(deploy));
262
+ warnings.push(...w.map(x => `${context}: ${x}`));
263
+ return sql;
264
+ };
265
+ /** `@pgsql/scripts` existence checks for a deploy script. */
266
+ const verifyOf = (deploy, context) => {
267
+ const { sql, warnings: w } = (0, scripts_1.verifyFor)((0, transform_1.classifyStatements)(deploy));
268
+ warnings.push(...w.map(x => `${context}: ${x}`));
269
+ return sql;
270
+ };
271
+ for (const key of b.order) {
272
+ const unit = b.units.get(key);
273
+ const prior = a.units.get(key);
274
+ const path = (0, naming_spec_1.pathFor)(unit.identity, { style });
275
+ if (!prior) {
276
+ objects.push({ identity: unit.identity, path, delta: 'added' });
277
+ forwardSql.push(...unit.texts);
278
+ continue;
279
+ }
280
+ if (unit.identity.kind === 'table') {
281
+ const shapeFrom = tableShape(prior);
282
+ const shapeTo = tableShape(unit);
283
+ const diff = { identity: unit.identity, path, delta: 'modified' };
284
+ const stmts = diffTable(shapeFrom, shapeTo, diff, warnings);
285
+ const columnsTouched = diff.columnsAdded.length + diff.columnsRemoved.length + diff.columnsModified.length > 0;
286
+ if (stmts.length > 0 || columnsTouched) {
287
+ objects.push(diff);
288
+ if (stmts.length > 0) {
289
+ // The inverse alteration is the same diff with the sides swapped.
290
+ const scratch = { identity: unit.identity, path, delta: 'modified' };
291
+ const deploy = stmts.join('\n');
292
+ modifiedChanges.push({
293
+ name: `${path}/alter`,
294
+ dependencies: [],
295
+ deploy,
296
+ revert: diffTable(shapeTo, shapeFrom, scratch, []).join('\n'),
297
+ verify: verifyOf(deploy, path)
298
+ });
299
+ }
300
+ }
301
+ continue;
302
+ }
303
+ if (unitFingerprint(prior) !== unitFingerprint(unit)) {
304
+ objects.push({ identity: unit.identity, path, delta: 'modified' });
305
+ const priorDeploy = prior.texts.join('\n');
306
+ const newDeploy = unit.texts.join('\n');
307
+ modifiedChanges.push({
308
+ name: path,
309
+ dependencies: [],
310
+ deploy: [inverseOf(priorDeploy, path), newDeploy].filter(Boolean).join('\n'),
311
+ revert: [inverseOf(newDeploy, path), priorDeploy].filter(Boolean).join('\n'),
312
+ verify: verifyOf(newDeploy, path)
313
+ });
314
+ }
315
+ }
316
+ for (const key of a.order) {
317
+ if (b.units.has(key))
318
+ continue;
319
+ const unit = a.units.get(key);
320
+ objects.push({ identity: unit.identity, path: (0, naming_spec_1.pathFor)(unit.identity, { style }), delta: 'removed' });
321
+ dropUnits.push(unit);
322
+ }
323
+ // Removed objects drop in reverse topological order of the `from` graph
324
+ // (dependents before dependencies), one change per object.
325
+ const position = new Map(dropUnits.map(u => [u, Math.min(...u.statements.map(s => a.graph.order.indexOf(s)))]));
326
+ dropUnits.sort((x, y) => position.get(y) - position.get(x));
327
+ const dropChanges = [];
328
+ for (const unit of dropUnits) {
329
+ const path = (0, naming_spec_1.pathFor)(unit.identity, { style });
330
+ const recreate = unit.texts.join('\n');
331
+ const drop = inverseOf(recreate, path);
332
+ if (!drop.trim() || drop.trim().startsWith('--')) {
333
+ warnings.push(`${path}: no DROP derivable for kind "${unit.identity.kind}"; drop manually`);
334
+ continue;
335
+ }
336
+ dropChanges.push({
337
+ name: `${path}/drop`,
338
+ dependencies: dropChanges.length > 0 ? [dropChanges[dropChanges.length - 1].name] : [],
339
+ deploy: drop,
340
+ revert: recreate,
341
+ verify: ''
342
+ });
343
+ }
344
+ let forwardChanges = [];
345
+ if (forwardSql.length > 0) {
346
+ const { changes, warnings: w } = (0, granularity_driver_1.restructureChanges)([{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }], { granularity: options.granularity ?? 'object' });
347
+ forwardChanges = changes.map(c => ({
348
+ ...c,
349
+ revert: inverseOf(c.deploy, c.name),
350
+ verify: verifyOf(c.deploy, c.name)
351
+ }));
352
+ warnings.push(...w);
353
+ }
354
+ return {
355
+ identical: objects.length === 0,
356
+ objects,
357
+ changes: [...dropChanges, ...forwardChanges, ...modifiedChanges],
358
+ warnings
359
+ };
360
+ }
361
+ /**
362
+ * Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
363
+ * in plan order and delegates to {@link diffSchemas}. Change names never
364
+ * enter the comparison — regrouping, renaming, or repartitioning the same
365
+ * schema yields an identical diff.
366
+ */
367
+ function diffChangeSets(from, to, options = {}) {
368
+ const flatten = (changes) => changes.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
369
+ return diffSchemas(flatten(from), flatten(to), options);
370
+ }