Skip to content

Commit a4ef0d3

Browse files
authored
Merge pull request #1581 from constructive-io/feat/pgpm-diff-append
feat(pgpm): pgpm diff --append-module — append the delta into an existing module
2 parents 2eeea11 + 4bfb5aa commit a4ef0d3

4 files changed

Lines changed: 178 additions & 3 deletions

File tree

pgpm/cli/__tests__/diff-e2e.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,47 @@ describe('pgpm diff e2e', () => {
251251
expect(bundle.changes.length).toBeGreaterThan(0);
252252
});
253253

254+
it('appends the delta into an existing module and fresh-deploys to the v2 catalog', async () => {
255+
// A standalone module with v1's schema (named so pgpm can deploy it), so
256+
// the shared diff-v1 module is untouched.
257+
const appendDir = path.join(wsDir, 'diff-append');
258+
writeModule(appendDir, 'diff-append', V1);
259+
const originalChangeCount = V1.length;
260+
// existing change scripts must be left byte-for-byte untouched
261+
const sentinel = 'schemas/dfx/tables/products/table';
262+
const deployBefore = fs.readFileSync(path.join(appendDir, 'deploy', `${sentinel}.sql`), 'utf-8');
263+
264+
await fixture.runTerminalCommands(
265+
`
266+
cd ${WS}
267+
pgpm diff diff-append diff-v2 --append-module diff-append
268+
`,
269+
{}
270+
);
271+
272+
// existing changes are preserved (names + untouched scripts) and new ones appended
273+
const planAfter = fs.readFileSync(path.join(appendDir, 'pgpm.plan'), 'utf-8');
274+
for (const c of V1) expect(planAfter).toContain(c.name);
275+
const changeLines = planAfter
276+
.split('\n')
277+
.filter(l => l.trim() && !l.startsWith('%') && !l.startsWith('@'));
278+
expect(changeLines.length).toBeGreaterThan(originalChangeCount);
279+
expect(fs.readFileSync(path.join(appendDir, 'deploy', `${sentinel}.sql`), 'utf-8')).toBe(deployBefore);
280+
281+
// the appended module (v1 + delta) fresh-deploys to a v2-equivalent catalog
282+
const testDb = await fixture.setupTestDatabase();
283+
await fixture.runTerminalCommands(
284+
`
285+
cd ${WS}/diff-append
286+
pgpm deploy --database ${testDb.name} --package diff-append --yes
287+
`,
288+
{ database: testDb.name }
289+
);
290+
const snapAppended = await snapshotCatalog(testDb);
291+
const snapV2 = await snapshotCatalog(v2Db);
292+
expect(diffCatalogSnapshots(withoutColumnOrder(snapAppended), withoutColumnOrder(snapV2))).toEqual([]);
293+
});
294+
254295
it.each(['atomic', 'object', 'consolidated'])(
255296
'emits a %s-granularity migration that reaches the same v2 catalog (dial parity)',
256297
async granularity => {

pgpm/cli/src/commands/diff.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@pgpmjs/diff';
88
import { Logger } from '@pgpmjs/logger';
99
import {
10+
appendModule,
1011
diffChangeSets,
1112
EXPORT_GRANULARITIES,
1213
ExportGranularity,
@@ -58,6 +59,10 @@ Options:
5859
verify per change, spec-derived paths, graph-derived
5960
requires) into <dir>/<pkg>
6061
--emit-module <dir> Alias of --emit-migration
62+
--append-module <dir> Append the delta into an EXISTING pgpm module at
63+
<dir> (new changes only; existing changes, scripts,
64+
and .control are left untouched). Standalone: not
65+
combinable with --emit-*/--verify.
6166
--emit-sql <file|-> Also project the delta to a single consolidated SQL
6267
file (deparsed in plan order); - writes to stdout
6368
--emit-bundle <file> Also project the delta to a content-addressed
@@ -238,6 +243,10 @@ export default async (
238243
const emitModuleDir = typeof emitModuleRaw === 'string' && emitModuleRaw
239244
? path.resolve(cwd, emitModuleRaw)
240245
: undefined;
246+
const appendModuleRaw = argv['append-module'] ?? argv.appendModule;
247+
const appendModuleDir = typeof appendModuleRaw === 'string' && appendModuleRaw
248+
? path.resolve(cwd, appendModuleRaw)
249+
: undefined;
241250
const emitSqlRaw = argv['emit-sql'] ?? argv.emitSql;
242251
const emitSql = typeof emitSqlRaw === 'string' && emitSqlRaw
243252
? (emitSqlRaw === STDOUT_TARGET ? STDOUT_TARGET : path.resolve(cwd, emitSqlRaw))
@@ -249,6 +258,12 @@ export default async (
249258
const sqlToStdout = emitSql === STDOUT_TARGET;
250259
const pkgName = (argv.pkg as string) || 'diff-migration';
251260

261+
if (appendModuleDir && (emitModuleDir || emitSql || emitBundle || verify)) {
262+
await cliExitWithError(
263+
'--append-module is standalone; it cannot be combined with --emit-migration/--emit-module/--emit-sql/--emit-bundle/--verify.'
264+
);
265+
}
266+
252267
await loadModule();
253268

254269
let sideA: DiffSide;
@@ -287,6 +302,22 @@ export default async (
287302
if (!sqlToStdout) printSummary(result, sideA.label, sideB.label);
288303
}
289304

305+
if (appendModuleDir) {
306+
const rows = deltaChangesToRows(result.changes);
307+
if (rows.length === 0) {
308+
log.info('no migration changes to append (sides are identical).');
309+
} else {
310+
const appended = appendModule(appendModuleDir, rows);
311+
for (const w of appended.warnings) console.warn(`diff: ${w}`);
312+
log.success(
313+
`appended ${appended.added.length} change(s) to ${appended.dir}` +
314+
(appended.skipped.length ? ` (${appended.skipped.length} skipped)` : '')
315+
);
316+
}
317+
prompter.close();
318+
return argv;
319+
}
320+
290321
let migrationDir: string | undefined;
291322
const needModule = Boolean(emitModuleDir || emitSql || emitBundle) || (verify && !result.identical);
292323
if (needModule) {

pgpm/transform/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ export {
4141
defaultChangeName,
4242
restructureChanges,
4343
} from './granularity-driver';
44-
export type { PgpmModuleModel } from './module-emit';
45-
export { checkOverwrite, writeControlFile, writeModule } from './module-emit';
44+
export type { AppendModuleResult, PgpmModuleModel } from './module-emit';
45+
export { appendModule, checkOverwrite, writeControlFile, writeModule } from './module-emit';
4646
export type { ModuleSource, ModuleSourceChange } from './module-source';
4747
export { loadModuleSource, stripTransactionWrapper } from './module-source';
4848
export type { PartitionedPackageRows, PartitionExportRowsResult } from './partition';

pgpm/transform/src/module-emit.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@
99
* `pgpm import` all emit byte-identical module layouts instead of each
1010
* carrying its own copy of the writer.
1111
*/
12-
import { PgpmRow, SqlWriteOptions, writePgpmFiles, writePgpmPlan } from '@pgpmjs/ast';
12+
import {
13+
parseAuthor,
14+
parsePlanFile,
15+
PgpmRow,
16+
SqlWriteOptions,
17+
writePgpmFiles,
18+
writePgpmPlan,
19+
writePlanFile
20+
} from '@pgpmjs/ast';
1321
import * as fs from 'fs';
1422
import * as path from 'path';
1523

@@ -71,6 +79,101 @@ export const writeModule = (
7179
return dir;
7280
};
7381

82+
/** Result of appending changes into an existing module (see {@link appendModule}). */
83+
export interface AppendModuleResult {
84+
/** The module directory that was appended to. */
85+
dir: string;
86+
/** Change names that were added to the plan. */
87+
added: string[];
88+
/** Change names skipped because they already exist in the plan. */
89+
skipped: string[];
90+
/** Non-fatal notices (skips, dropped dangling dependencies). */
91+
warnings: string[];
92+
}
93+
94+
/**
95+
* Append plan-ordered {@link PgpmRow}s into an *existing* module rather than
96+
* writing a fresh package. Existing changes, their scripts, and the `.control`
97+
* file are left untouched; only the new changes are written (deploy/revert/
98+
* verify) and appended to `pgpm.plan` after the current changes.
99+
*
100+
* Rows whose change name already exists are skipped (never overwritten).
101+
* A new change's dependency bracket is filtered to names that resolve within
102+
* the plan (existing changes, other appended changes, or `pkg:`-external
103+
* references); a dangling internal dependency is dropped with a warning
104+
* (plan order still sequences it after the current changes).
105+
*/
106+
export const appendModule = (
107+
moduleDir: string,
108+
rows: PgpmRow[],
109+
options: { author?: string } = {}
110+
): AppendModuleResult => {
111+
const planPath = path.join(moduleDir, 'pgpm.plan');
112+
if (!fs.existsSync(planPath)) {
113+
throw new Error(
114+
`No pgpm.plan found at ${planPath}; append mode expects an existing pgpm module directory.`
115+
);
116+
}
117+
118+
const parsed = parsePlanFile(planPath);
119+
if (!parsed.data) {
120+
throw new Error(
121+
`Failed to parse ${planPath}: ${parsed.errors
122+
.map(e => `line ${e.line}: ${e.message}`)
123+
.join('; ')}`
124+
);
125+
}
126+
const plan = parsed.data;
127+
128+
const existingNames = new Set(plan.changes.map(c => c.name));
129+
const incomingNames = new Set(rows.map(r => r.deploy));
130+
const appended = new Set<string>();
131+
const added: string[] = [];
132+
const skipped: string[] = [];
133+
const warnings: string[] = [];
134+
const newRows: PgpmRow[] = [];
135+
136+
const { fullName, email } = parseAuthor(options.author || 'constructive');
137+
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
138+
139+
for (const row of rows) {
140+
if (existingNames.has(row.deploy) || appended.has(row.deploy)) {
141+
skipped.push(row.deploy);
142+
warnings.push(`change ${row.deploy} already exists in ${plan.package}; left untouched`);
143+
continue;
144+
}
145+
const deps = (row.deps ?? []).filter(dep => {
146+
if (dep.includes(':')) return true; // cross-package (pkg:change) external
147+
if (existingNames.has(dep) || incomingNames.has(dep)) return true;
148+
warnings.push(`change ${row.deploy}: dropped dependency ${dep} (not present in ${plan.package})`);
149+
return false;
150+
});
151+
plan.changes.push({
152+
name: row.deploy,
153+
dependencies: deps,
154+
timestamp,
155+
planner: fullName,
156+
email: email || `${fullName}@constructive.io`,
157+
comment: `add ${row.name ?? row.deploy}`
158+
});
159+
newRows.push({ ...row, deps });
160+
appended.add(row.deploy);
161+
added.push(row.deploy);
162+
}
163+
164+
if (newRows.length) {
165+
const opts: SqlWriteOptions = {
166+
outdir: path.dirname(moduleDir),
167+
name: path.basename(moduleDir),
168+
replacer: (str: string) => str
169+
};
170+
writePgpmFiles(newRows, opts);
171+
writePlanFile(planPath, plan);
172+
}
173+
174+
return { dir: moduleDir, added, skipped, warnings };
175+
};
176+
74177
/**
75178
* Guard against clobbering: writing into the source directory requires
76179
* `--write`, as does overwriting any existing package directory.

0 commit comments

Comments
 (0)