@pgpmjs/core 6.32.2 → 6.34.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.
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateRenames = validateRenames;
4
+ exports.renameInPlanContent = renameInPlanContent;
5
+ exports.renameChanges = renameChanges;
6
+ exports.deriveRenamesFromSchemaMapping = deriveRenamesFromSchemaMapping;
7
+ const fs_1 = require("fs");
8
+ const path_1 = require("path");
9
+ const parser_1 = require("../files/plan/parser");
10
+ const header_1 = require("../files/sql/header");
11
+ const SCRIPT_DIRS = ['deploy', 'revert', 'verify'];
12
+ function escapeRe(str) {
13
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+ }
15
+ function findSqlFiles(dir) {
16
+ const files = [];
17
+ if (!(0, fs_1.existsSync)(dir))
18
+ return files;
19
+ const entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
20
+ for (const entry of entries) {
21
+ const fullPath = (0, path_1.join)(dir, entry.name);
22
+ if (entry.isDirectory()) {
23
+ files.push(...findSqlFiles(fullPath));
24
+ }
25
+ else if (entry.isFile() && entry.name.endsWith('.sql')) {
26
+ files.push(fullPath);
27
+ }
28
+ }
29
+ return files;
30
+ }
31
+ function removeEmptyDirs(dir, stopAt) {
32
+ let current = dir;
33
+ while (current !== stopAt && current.startsWith(stopAt)) {
34
+ if (!(0, fs_1.existsSync)(current) || (0, fs_1.readdirSync)(current).length > 0)
35
+ return;
36
+ (0, fs_1.rmdirSync)(current);
37
+ current = (0, path_1.dirname)(current);
38
+ }
39
+ }
40
+ /**
41
+ * Validate a rename map against a module directory: every source change must
42
+ * exist in the plan, no target may collide with an existing change, and no
43
+ * two sources may map to the same target.
44
+ *
45
+ * @throws on any violation.
46
+ */
47
+ function validateRenames(moduleDir, renames) {
48
+ const planPath = (0, path_1.join)(moduleDir, 'pgpm.plan');
49
+ const plan = (0, parser_1.parsePlanFileSimple)(planPath);
50
+ const changeNames = new Set(plan.changes.map(c => c.name));
51
+ const targets = new Set();
52
+ for (const [from, to] of renames) {
53
+ if (from === to) {
54
+ throw new Error(`Rename maps change to itself: ${from}`);
55
+ }
56
+ if (!changeNames.has(from)) {
57
+ throw new Error(`Cannot rename unknown change: ${from}`);
58
+ }
59
+ if (changeNames.has(to) && !renames.has(to)) {
60
+ throw new Error(`Rename target already exists in plan: ${to}`);
61
+ }
62
+ if (targets.has(to)) {
63
+ throw new Error(`Multiple changes rename to the same target: ${to}`);
64
+ }
65
+ targets.add(to);
66
+ }
67
+ }
68
+ /**
69
+ * Rewrite change names in a pgpm.plan's content according to a rename map.
70
+ * Textual and format-preserving: only the change-name token at the start of a
71
+ * change line, plain (same-package) dependency refs inside `[...]`, and tag
72
+ * lines' change field are rewritten; timestamps, planners, comments,
73
+ * cross-package refs, and tag refs are untouched.
74
+ */
75
+ function renameInPlanContent(content, renames) {
76
+ const froms = Array.from(renames.keys()).sort((a, b) => b.length - a.length);
77
+ return content
78
+ .split('\n')
79
+ .map(line => {
80
+ const trimmed = line.trim();
81
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('%'))
82
+ return line;
83
+ let result = line;
84
+ for (const from of froms) {
85
+ const to = renames.get(from);
86
+ const re = new RegExp(`(^|[\\s\\[])${escapeRe(from)}(?=$|[\\s\\]@])`, 'g');
87
+ result = result.replace(re, `$1${to}`);
88
+ }
89
+ return result;
90
+ })
91
+ .join('\n');
92
+ }
93
+ /**
94
+ * Atomically rename/move changes within a pgpm module: the coupled 4-way
95
+ * rewrite of (1) deploy/revert/verify script files on disk, (2) the moved
96
+ * scripts' own header identity, (3) `-- requires:` headers in every script
97
+ * that references a renamed change, and (4) the pgpm.plan.
98
+ *
99
+ * SQL statement bodies are NOT touched: schema-name rewrites inside SQL are a
100
+ * separate AST-level concern for callers to compose (e.g. transform passes).
101
+ */
102
+ function renameChanges(moduleDir, renames, options = {}) {
103
+ const dryRun = options.dryRun ?? false;
104
+ const result = {
105
+ filesMoved: [],
106
+ headersRewritten: [],
107
+ planRewritten: false
108
+ };
109
+ if (dryRun)
110
+ result.dryRunReport = [];
111
+ if (renames.size === 0)
112
+ return result;
113
+ validateRenames(moduleDir, renames);
114
+ // 1. Move script files
115
+ for (const scriptDir of SCRIPT_DIRS) {
116
+ for (const [from, to] of renames) {
117
+ const oldPath = (0, path_1.join)(moduleDir, scriptDir, `${from}.sql`);
118
+ if (!(0, fs_1.existsSync)(oldPath) || !(0, fs_1.statSync)(oldPath).isFile())
119
+ continue;
120
+ const newPath = (0, path_1.join)(moduleDir, scriptDir, `${to}.sql`);
121
+ if ((0, fs_1.existsSync)(newPath)) {
122
+ throw new Error(`Rename target file already exists: ${(0, path_1.relative)(moduleDir, newPath)}`);
123
+ }
124
+ if (dryRun) {
125
+ result.dryRunReport.push(`Would move: ${(0, path_1.relative)(moduleDir, oldPath)} -> ${(0, path_1.relative)(moduleDir, newPath)}`);
126
+ }
127
+ else {
128
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(newPath), { recursive: true });
129
+ (0, fs_1.renameSync)(oldPath, newPath);
130
+ removeEmptyDirs((0, path_1.dirname)(oldPath), (0, path_1.join)(moduleDir, scriptDir));
131
+ }
132
+ result.filesMoved.push({
133
+ from: [scriptDir, `${from}.sql`].join(path_1.sep),
134
+ to: [scriptDir, `${to}.sql`].join(path_1.sep)
135
+ });
136
+ }
137
+ }
138
+ // 2 + 3. Rewrite headers in every script (identity of moved files,
139
+ // requires of dependents)
140
+ for (const scriptDir of SCRIPT_DIRS) {
141
+ const dir = (0, path_1.join)(moduleDir, scriptDir);
142
+ for (const file of findSqlFiles(dir)) {
143
+ const content = (0, fs_1.readFileSync)(file, 'utf-8');
144
+ const script = (0, header_1.parsePgpmHeader)(content);
145
+ const count = (0, header_1.renameInHeader)(script, renames);
146
+ if (count === 0)
147
+ continue;
148
+ const rewritten = (0, header_1.writePgpmScript)(script);
149
+ if (rewritten === content)
150
+ continue;
151
+ if (dryRun) {
152
+ result.dryRunReport.push(`Would rewrite header: ${(0, path_1.relative)(moduleDir, file)}`);
153
+ }
154
+ else {
155
+ (0, fs_1.writeFileSync)(file, rewritten);
156
+ }
157
+ result.headersRewritten.push((0, path_1.relative)(moduleDir, file));
158
+ }
159
+ }
160
+ // In dry-run mode files were not moved, so dependents' headers are found at
161
+ // their original paths above; the moved files' own headers are still
162
+ // reported through the same scan.
163
+ // 4. Rewrite pgpm.plan
164
+ const planPath = (0, path_1.join)(moduleDir, 'pgpm.plan');
165
+ const planContent = (0, fs_1.readFileSync)(planPath, 'utf-8');
166
+ const newPlanContent = renameInPlanContent(planContent, renames);
167
+ if (newPlanContent !== planContent) {
168
+ if (dryRun) {
169
+ result.dryRunReport.push('Would rewrite: pgpm.plan');
170
+ }
171
+ else {
172
+ (0, fs_1.writeFileSync)(planPath, newPlanContent);
173
+ }
174
+ result.planRewritten = true;
175
+ }
176
+ return result;
177
+ }
178
+ /**
179
+ * Derive a change rename map from a schema mapping (old schema name -> new
180
+ * schema name) by rewriting the `schemas/<name>/` path segment of every plan
181
+ * change that lives under a renamed schema.
182
+ *
183
+ * This is the module-level counterpart of directory-based schema renames:
184
+ * `schemas/my-app/tables/users` with mapping `my-app -> my_app` yields
185
+ * `schemas/my_app/tables/users`.
186
+ */
187
+ function deriveRenamesFromSchemaMapping(moduleDir, schemaMapping) {
188
+ const renames = new Map();
189
+ const planPath = (0, path_1.join)(moduleDir, 'pgpm.plan');
190
+ const plan = (0, parser_1.parsePlanFileSimple)(planPath);
191
+ const schemas = Array.from(schemaMapping.keys()).sort((a, b) => b.length - a.length);
192
+ for (const change of plan.changes) {
193
+ let renamed = change.name;
194
+ for (const schema of schemas) {
195
+ const to = schemaMapping.get(schema);
196
+ if (!to || to === schema)
197
+ continue;
198
+ const re = new RegExp(`(^|/)(schemas/)${escapeRe(schema)}(/|$)`, 'g');
199
+ renamed = renamed.replace(re, `$1$2${to}$3`);
200
+ }
201
+ if (renamed !== change.name) {
202
+ renames.set(change.name, renamed);
203
+ }
204
+ }
205
+ return renames;
206
+ }
@@ -6,6 +6,7 @@ const glob_1 = require("glob");
6
6
  const path_1 = require("path");
7
7
  const pgpm_1 = require("../core/class/pgpm");
8
8
  const parser_1 = require("../files/plan/parser");
9
+ const header_1 = require("../files/sql/header");
9
10
  const types_1 = require("@pgpmjs/types");
10
11
  /**
11
12
  * Core dependency resolution algorithm that handles circular dependency detection
@@ -337,84 +338,46 @@ const resolveDependencies = (packageDir, extname, options = {}) => {
337
338
  const files = (0, glob_1.sync)(`${packageDir}/deploy/**/*.sql`).sort((a, b) => a.localeCompare(b));
338
339
  for (const file of files) {
339
340
  const data = (0, fs_1.readFileSync)(file, 'utf-8');
340
- const lines = data.split('\n');
341
341
  const key = '/' + (0, path_1.relative)(packageDir, file);
342
342
  deps[key] = [];
343
- for (const line of lines) {
344
- // Handle requires statements
345
- const requiresMatch = line.match(/^-- requires: (.*)/);
346
- if (requiresMatch) {
347
- const dep = requiresMatch[1].trim();
348
- // For 'preserve' mode, just add the dependency as-is (like original getDeps)
349
- if (tagResolution === 'preserve') {
350
- deps[key].push(dep);
351
- continue;
352
- }
353
- // For other modes, handle tag resolution
354
- if (dep.includes('@')) {
355
- const match = dep.match(/^([^:]+):@(.+)$/);
356
- if (match) {
357
- const [, projectName, tagName] = match;
358
- const taggedChange = resolveTagToChange(projectName, tagName);
359
- if (taggedChange) {
360
- if (tagResolution === 'resolve') {
361
- // Full resolution: replace tag with actual change
362
- const resolvedDep = `${projectName}:${taggedChange}`;
363
- deps[key].push(resolvedDep);
364
- }
365
- else if (tagResolution === 'internal') {
366
- // Internal resolution: keep tag in deps but track mapping
367
- tagMappings[dep] = `${projectName}:${taggedChange}`;
368
- deps[key].push(dep);
369
- }
343
+ const { requires } = (0, header_1.scanDeployScript)(data, { key, extname, makeKey });
344
+ for (const dep of requires) {
345
+ // For 'preserve' mode, just add the dependency as-is (like original getDeps)
346
+ if (tagResolution === 'preserve') {
347
+ deps[key].push(dep);
348
+ continue;
349
+ }
350
+ // For other modes, handle tag resolution
351
+ if (dep.includes('@')) {
352
+ const match = dep.match(/^([^:]+):@(.+)$/);
353
+ if (match) {
354
+ const [, projectName, tagName] = match;
355
+ const taggedChange = resolveTagToChange(projectName, tagName);
356
+ if (taggedChange) {
357
+ if (tagResolution === 'resolve') {
358
+ // Full resolution: replace tag with actual change
359
+ const resolvedDep = `${projectName}:${taggedChange}`;
360
+ deps[key].push(resolvedDep);
370
361
  }
371
- else {
372
- // Could not resolve tag, keep it as is
362
+ else if (tagResolution === 'internal') {
363
+ // Internal resolution: keep tag in deps but track mapping
364
+ tagMappings[dep] = `${projectName}:${taggedChange}`;
373
365
  deps[key].push(dep);
374
366
  }
375
367
  }
376
368
  else {
377
- // Invalid tag format, keep as is
369
+ // Could not resolve tag, keep it as is
378
370
  deps[key].push(dep);
379
371
  }
380
372
  }
381
373
  else {
382
- // Not a tag, keep as is
374
+ // Invalid tag format, keep as is
383
375
  deps[key].push(dep);
384
376
  }
385
- continue;
386
- }
387
- // Handle deploy statements - exactly as in original
388
- let m2;
389
- let keyToTest;
390
- if (/:/.test(line)) {
391
- m2 = line.match(/^-- Deploy ([^:]*):([\w\/]+)(?:\s+to\s+pg)?/);
392
- if (m2) {
393
- const actualProject = m2[1];
394
- keyToTest = m2[2];
395
- if (extname !== actualProject) {
396
- throw new Error(`Mismatched project name in deploy file:
397
- Expected project: ${extname}
398
- Found in line : ${actualProject}
399
- Line : ${line}`);
400
- }
401
- const expectedKey = makeKey(keyToTest);
402
- if (key !== expectedKey) {
403
- throw new Error(`Deployment script path or internal name mismatch:
404
- Expected key : ${key}
405
- Found in line : ${expectedKey}
406
- Line : ${line}`);
407
- }
408
- }
409
377
  }
410
378
  else {
411
- m2 = line.match(/^-- Deploy (.*?)(?:\s+to\s+pg)?\s*$/);
412
- if (m2) {
413
- keyToTest = m2[1].trim();
414
- if (key !== makeKey(keyToTest)) {
415
- throw new Error('deployment script in wrong place or is named wrong internally\n' + line);
416
- }
417
- }
379
+ // Not a tag, keep as is
380
+ deps[key].push(dep);
418
381
  }
419
382
  }
420
383
  }
package/slice/slice.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Change, Tag, ExtendedPlanFile } from '../files/types';
2
- import { SliceConfig, SliceResult, DependencyGraph, PackageOutput, GroupingStrategy, PatternStrategy } from './types';
2
+ import { SliceConfig, SliceResult, DependencyGraph, PackageOutput, GroupingStrategy, PatternStrategy, CrossPackageDepMode } from './types';
3
3
  /**
4
4
  * Build a dependency graph from a parsed plan file
5
5
  */
@@ -52,7 +52,7 @@ export declare function generateControlContent(pkgName: string, deps: Set<string
52
52
  /**
53
53
  * Generate a single package output
54
54
  */
55
- export declare function generateSinglePackage(pkgName: string, changes: Set<string>, graph: DependencyGraph, changeToPackage: Map<string, string>, pkgDeps: Set<string>, useTagsForCrossPackageDeps?: boolean): PackageOutput;
55
+ export declare function generateSinglePackage(pkgName: string, changes: Set<string>, graph: DependencyGraph, changeToPackage: Map<string, string>, pkgDeps: Set<string>, crossPackageDeps?: boolean | CrossPackageDepMode): PackageOutput;
56
56
  /**
57
57
  * Main slicing function
58
58
  */
package/slice/slice.js CHANGED
@@ -356,7 +356,9 @@ function generateControlContent(pkgName, deps) {
356
356
  /**
357
357
  * Generate a single package output
358
358
  */
359
- function generateSinglePackage(pkgName, changes, graph, changeToPackage, pkgDeps, useTagsForCrossPackageDeps = false) {
359
+ function generateSinglePackage(pkgName, changes, graph, changeToPackage, pkgDeps, crossPackageDeps = 'change') {
360
+ // Back-compat: earlier signature took useTagsForCrossPackageDeps: boolean
361
+ const crossPackageDepMode = crossPackageDeps === true ? 'tag' : crossPackageDeps === false ? 'change' : crossPackageDeps;
360
362
  // Sort changes in topological order within package
361
363
  const sortedChanges = topologicalSortWithinPackage(changes, graph);
362
364
  // Build plan entries with updated dependencies
@@ -377,7 +379,13 @@ function generateSinglePackage(pkgName, changes, graph, changeToPackage, pkgDeps
377
379
  }
378
380
  else {
379
381
  // Cross-package dependency
380
- if (useTagsForCrossPackageDeps) {
382
+ if (crossPackageDepMode === 'control-only') {
383
+ // Dropped from the plan line; carried by the control file's
384
+ // `requires` (extension install ordering deploys the whole
385
+ // dependency package first)
386
+ continue;
387
+ }
388
+ if (crossPackageDepMode === 'tag') {
381
389
  // Find latest tag in the dependency package
382
390
  const depPkgChanges = [...graph.nodes.keys()]
383
391
  .filter(c => changeToPackage.get(c) === depPkg);
@@ -477,7 +485,7 @@ function slicePlan(config) {
477
485
  const changes = assignments.get(pkgName);
478
486
  if (!changes || changes.size === 0)
479
487
  continue;
480
- const pkgOutput = generateSinglePackage(pkgName, changes, graph, changeToPackage, packageDeps.get(pkgName) || new Set(), config.useTagsForCrossPackageDeps || false);
488
+ const pkgOutput = generateSinglePackage(pkgName, changes, graph, changeToPackage, packageDeps.get(pkgName) || new Set(), config.crossPackageDepMode ?? (config.useTagsForCrossPackageDeps ? 'tag' : 'change'));
481
489
  packages.push(pkgOutput);
482
490
  // Check for heavy cross-package dependencies
483
491
  let crossDeps = 0;
package/slice/types.d.ts CHANGED
@@ -13,11 +13,26 @@ export interface SliceConfig {
13
13
  defaultPackage?: string;
14
14
  /** Whether to use tags for cross-package deps */
15
15
  useTagsForCrossPackageDeps?: boolean;
16
+ /**
17
+ * How cross-package dependencies are recorded in sliced plans:
18
+ * - 'change' (default): per-change refs (`{package}:{change}`)
19
+ * - 'tag': per-change tag refs (`{package}:@tag`) when available
20
+ * (equivalent to `useTagsForCrossPackageDeps: true`)
21
+ * - 'control-only': drop per-change cross-package refs from plan lines
22
+ * entirely; the dependency is carried only by the control file's
23
+ * `requires` (extension install ordering deploys the whole dependency
24
+ * package first, which subsumes any per-change ordering constraint)
25
+ */
26
+ crossPackageDepMode?: CrossPackageDepMode;
16
27
  /** Minimum changes per package (merge smaller groups) */
17
28
  minChangesPerPackage?: number;
18
29
  /** Author for generated plan files */
19
30
  author?: string;
20
31
  }
32
+ /**
33
+ * How cross-package dependencies are represented in sliced plan files
34
+ */
35
+ export type CrossPackageDepMode = 'change' | 'tag' | 'control-only';
21
36
  /**
22
37
  * Grouping strategy for slicing
23
38
  */