@pgpmjs/core 3.0.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.
Files changed (140) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +99 -0
  3. package/core/boilerplate-scanner.d.ts +41 -0
  4. package/core/boilerplate-scanner.js +106 -0
  5. package/core/boilerplate-types.d.ts +52 -0
  6. package/core/boilerplate-types.js +6 -0
  7. package/core/class/pgpm.d.ts +150 -0
  8. package/core/class/pgpm.js +1470 -0
  9. package/core/template-scaffold.d.ts +29 -0
  10. package/core/template-scaffold.js +168 -0
  11. package/esm/core/boilerplate-scanner.js +96 -0
  12. package/esm/core/boilerplate-types.js +5 -0
  13. package/esm/core/class/pgpm.js +1430 -0
  14. package/esm/core/template-scaffold.js +161 -0
  15. package/esm/export/export-meta.js +240 -0
  16. package/esm/export/export-migrations.js +180 -0
  17. package/esm/extensions/extensions.js +31 -0
  18. package/esm/files/extension/index.js +3 -0
  19. package/esm/files/extension/reader.js +79 -0
  20. package/esm/files/extension/writer.js +63 -0
  21. package/esm/files/index.js +6 -0
  22. package/esm/files/plan/generator.js +49 -0
  23. package/esm/files/plan/index.js +5 -0
  24. package/esm/files/plan/parser.js +296 -0
  25. package/esm/files/plan/validators.js +181 -0
  26. package/esm/files/plan/writer.js +114 -0
  27. package/esm/files/sql/index.js +1 -0
  28. package/esm/files/sql/writer.js +107 -0
  29. package/esm/files/sql-scripts/index.js +2 -0
  30. package/esm/files/sql-scripts/reader.js +19 -0
  31. package/esm/files/types/index.js +1 -0
  32. package/esm/files/types/package.js +1 -0
  33. package/esm/index.js +21 -0
  34. package/esm/init/client.js +144 -0
  35. package/esm/init/sql/bootstrap-roles.sql +55 -0
  36. package/esm/init/sql/bootstrap-test-roles.sql +72 -0
  37. package/esm/migrate/clean.js +23 -0
  38. package/esm/migrate/client.js +551 -0
  39. package/esm/migrate/index.js +5 -0
  40. package/esm/migrate/sql/procedures.sql +258 -0
  41. package/esm/migrate/sql/schema.sql +37 -0
  42. package/esm/migrate/types.js +1 -0
  43. package/esm/migrate/utils/event-logger.js +28 -0
  44. package/esm/migrate/utils/hash.js +27 -0
  45. package/esm/migrate/utils/transaction.js +125 -0
  46. package/esm/modules/modules.js +49 -0
  47. package/esm/packaging/package.js +96 -0
  48. package/esm/packaging/transform.js +70 -0
  49. package/esm/projects/deploy.js +123 -0
  50. package/esm/projects/revert.js +75 -0
  51. package/esm/projects/verify.js +61 -0
  52. package/esm/resolution/deps.js +526 -0
  53. package/esm/resolution/resolve.js +101 -0
  54. package/esm/utils/debug.js +147 -0
  55. package/esm/utils/target-utils.js +37 -0
  56. package/esm/workspace/paths.js +43 -0
  57. package/esm/workspace/utils.js +31 -0
  58. package/export/export-meta.d.ts +8 -0
  59. package/export/export-meta.js +244 -0
  60. package/export/export-migrations.d.ts +17 -0
  61. package/export/export-migrations.js +187 -0
  62. package/extensions/extensions.d.ts +5 -0
  63. package/extensions/extensions.js +35 -0
  64. package/files/extension/index.d.ts +2 -0
  65. package/files/extension/index.js +19 -0
  66. package/files/extension/reader.d.ts +24 -0
  67. package/files/extension/reader.js +86 -0
  68. package/files/extension/writer.d.ts +39 -0
  69. package/files/extension/writer.js +70 -0
  70. package/files/index.d.ts +5 -0
  71. package/files/index.js +22 -0
  72. package/files/plan/generator.d.ts +22 -0
  73. package/files/plan/generator.js +57 -0
  74. package/files/plan/index.d.ts +4 -0
  75. package/files/plan/index.js +21 -0
  76. package/files/plan/parser.d.ts +27 -0
  77. package/files/plan/parser.js +303 -0
  78. package/files/plan/validators.d.ts +52 -0
  79. package/files/plan/validators.js +187 -0
  80. package/files/plan/writer.d.ts +27 -0
  81. package/files/plan/writer.js +124 -0
  82. package/files/sql/index.d.ts +1 -0
  83. package/files/sql/index.js +17 -0
  84. package/files/sql/writer.d.ts +12 -0
  85. package/files/sql/writer.js +114 -0
  86. package/files/sql-scripts/index.d.ts +1 -0
  87. package/files/sql-scripts/index.js +18 -0
  88. package/files/sql-scripts/reader.d.ts +8 -0
  89. package/files/sql-scripts/reader.js +23 -0
  90. package/files/types/index.d.ts +46 -0
  91. package/files/types/index.js +17 -0
  92. package/files/types/package.d.ts +20 -0
  93. package/files/types/package.js +2 -0
  94. package/index.d.ts +21 -0
  95. package/index.js +45 -0
  96. package/init/client.d.ts +26 -0
  97. package/init/client.js +148 -0
  98. package/init/sql/bootstrap-roles.sql +55 -0
  99. package/init/sql/bootstrap-test-roles.sql +72 -0
  100. package/migrate/clean.d.ts +1 -0
  101. package/migrate/clean.js +27 -0
  102. package/migrate/client.d.ts +80 -0
  103. package/migrate/client.js +555 -0
  104. package/migrate/index.d.ts +5 -0
  105. package/migrate/index.js +21 -0
  106. package/migrate/sql/procedures.sql +258 -0
  107. package/migrate/sql/schema.sql +37 -0
  108. package/migrate/types.d.ts +67 -0
  109. package/migrate/types.js +2 -0
  110. package/migrate/utils/event-logger.d.ts +13 -0
  111. package/migrate/utils/event-logger.js +32 -0
  112. package/migrate/utils/hash.d.ts +12 -0
  113. package/migrate/utils/hash.js +32 -0
  114. package/migrate/utils/transaction.d.ts +27 -0
  115. package/migrate/utils/transaction.js +129 -0
  116. package/modules/modules.d.ts +31 -0
  117. package/modules/modules.js +56 -0
  118. package/package.json +70 -0
  119. package/packaging/package.d.ts +19 -0
  120. package/packaging/package.js +102 -0
  121. package/packaging/transform.d.ts +22 -0
  122. package/packaging/transform.js +75 -0
  123. package/projects/deploy.d.ts +8 -0
  124. package/projects/deploy.js +160 -0
  125. package/projects/revert.d.ts +15 -0
  126. package/projects/revert.js +112 -0
  127. package/projects/verify.d.ts +8 -0
  128. package/projects/verify.js +98 -0
  129. package/resolution/deps.d.ts +57 -0
  130. package/resolution/deps.js +531 -0
  131. package/resolution/resolve.d.ts +37 -0
  132. package/resolution/resolve.js +107 -0
  133. package/utils/debug.d.ts +21 -0
  134. package/utils/debug.js +153 -0
  135. package/utils/target-utils.d.ts +5 -0
  136. package/utils/target-utils.js +40 -0
  137. package/workspace/paths.d.ts +14 -0
  138. package/workspace/paths.js +50 -0
  139. package/workspace/utils.d.ts +8 -0
  140. package/workspace/utils.js +36 -0
@@ -0,0 +1,1430 @@
1
+ import { loadConfigSyncFromDir, resolvePgpmPath, walkUp } from '@pgpmjs/env';
2
+ import { Logger } from '@pgpmjs/logger';
3
+ import { errors } from '@pgpmjs/types';
4
+ import yanse from 'yanse';
5
+ import { execSync } from 'child_process';
6
+ import fs from 'fs';
7
+ import * as glob from 'glob';
8
+ import os from 'os';
9
+ import { parse } from 'parse-package-name';
10
+ import path, { dirname, resolve } from 'path';
11
+ import { getPgPool } from 'pg-cache';
12
+ import { DEFAULT_TEMPLATE_REPO, DEFAULT_TEMPLATE_TTL_MS, DEFAULT_TEMPLATE_TOOL_NAME, scaffoldTemplate } from '../template-scaffold';
13
+ import { getAvailableExtensions } from '../../extensions/extensions';
14
+ import { generatePlan, writePlan, writePlanFile } from '../../files';
15
+ import { parsePlanFile } from '../../files/plan/parser';
16
+ import { isValidTagName, isValidChangeName, parseReference } from '../../files/plan/validators';
17
+ import { getNow as getPlanTimestamp } from '../../files/plan/generator';
18
+ import { getExtensionInfo, getExtensionName, getInstalledExtensions, parseControlFile, writeExtensions, } from '../../files';
19
+ import { generateControlFileContent, writeExtensionMakefile } from '../../files/extension/writer';
20
+ import { PgpmMigrate } from '../../migrate/client';
21
+ import { getExtensionsAndModules, getExtensionsAndModulesChanges, latestChange, latestChangeAndVersion } from '../../modules/modules';
22
+ import { packageModule } from '../../packaging/package';
23
+ import { resolveExtensionDependencies, resolveDependencies } from '../../resolution/deps';
24
+ import { parseTarget } from '../../utils/target-utils';
25
+ const logger = new Logger('pgpm');
26
+ function getUTCTimestamp(d = new Date()) {
27
+ return (d.getUTCFullYear() +
28
+ '-' + String(d.getUTCMonth() + 1).padStart(2, '0') +
29
+ '-' + String(d.getUTCDate()).padStart(2, '0') +
30
+ 'T' + String(d.getUTCHours()).padStart(2, '0') +
31
+ ':' + String(d.getUTCMinutes()).padStart(2, '0') +
32
+ ':' + String(d.getUTCSeconds()).padStart(2, '0') +
33
+ 'Z');
34
+ }
35
+ function sortObjectByKey(obj) {
36
+ return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
37
+ }
38
+ const getNow = () => process.env.NODE_ENV === 'test'
39
+ ? getUTCTimestamp(new Date('2017-08-11T08:11:51Z'))
40
+ : getUTCTimestamp(new Date());
41
+ /**
42
+ * Truncates workspace extensions to include only modules from the target onwards.
43
+ * This prevents processing unnecessary modules that come before the target in dependency order.
44
+ *
45
+ * @param workspaceExtensions - The full workspace extension dependencies
46
+ * @param targetName - The target module name to truncate from
47
+ * @returns Truncated extensions starting from the target module
48
+ */
49
+ const truncateExtensionsToTarget = (workspaceExtensions, targetName) => {
50
+ const targetIndex = workspaceExtensions.resolved.indexOf(targetName);
51
+ if (targetIndex === -1) {
52
+ return workspaceExtensions;
53
+ }
54
+ return {
55
+ resolved: workspaceExtensions.resolved.slice(targetIndex),
56
+ external: workspaceExtensions.external
57
+ };
58
+ };
59
+ export var PackageContext;
60
+ (function (PackageContext) {
61
+ PackageContext["Outside"] = "outside";
62
+ PackageContext["Workspace"] = "workspace-root";
63
+ PackageContext["Module"] = "module";
64
+ PackageContext["ModuleInsideWorkspace"] = "module-in-workspace";
65
+ })(PackageContext || (PackageContext = {}));
66
+ export class PgpmPackage {
67
+ cwd;
68
+ workspacePath;
69
+ modulePath;
70
+ config;
71
+ allowedDirs = [];
72
+ allowedParentDirs = [];
73
+ _moduleMap;
74
+ _moduleInfo;
75
+ constructor(cwd = process.cwd()) {
76
+ this.resetCwd(cwd);
77
+ }
78
+ resetCwd(cwd) {
79
+ this.cwd = cwd;
80
+ this.workspacePath = resolvePgpmPath(this.cwd);
81
+ this.modulePath = this.resolveSqitchPath();
82
+ if (this.workspacePath) {
83
+ this.config = this.loadConfigSync();
84
+ this.allowedDirs = this.loadAllowedDirs();
85
+ this.allowedParentDirs = this.loadAllowedParentDirs();
86
+ }
87
+ }
88
+ resolveSqitchPath() {
89
+ try {
90
+ return walkUp(this.cwd, 'pgpm.plan');
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ loadConfigSync() {
97
+ return loadConfigSyncFromDir(this.workspacePath);
98
+ }
99
+ loadAllowedDirs() {
100
+ const globs = this.config?.packages ?? [];
101
+ const dirs = globs.flatMap(pattern => glob.sync(path.join(this.workspacePath, pattern)));
102
+ const resolvedDirs = dirs.map(dir => path.resolve(dir));
103
+ // Remove duplicates by converting to Set and back to array
104
+ return [...new Set(resolvedDirs)];
105
+ }
106
+ loadAllowedParentDirs() {
107
+ const globs = this.config?.packages ?? [];
108
+ const parentDirs = globs.map(pattern => {
109
+ // Remove glob characters (*, **, ?, etc.) to get the base path
110
+ const basePath = pattern.replace(/[*?[\]{}]/g, '').replace(/\/$/, '');
111
+ return path.resolve(this.workspacePath, basePath);
112
+ });
113
+ // Remove duplicates by converting to Set and back to array
114
+ return [...new Set(parentDirs)];
115
+ }
116
+ isInsideAllowedDirs(cwd) {
117
+ return this.allowedDirs.some(dir => cwd.startsWith(dir));
118
+ }
119
+ isParentOfAllowedDirs(cwd) {
120
+ const resolvedCwd = path.resolve(cwd);
121
+ return this.allowedDirs.some(dir => dir.startsWith(resolvedCwd + path.sep)) ||
122
+ this.allowedParentDirs.some(dir => path.resolve(dir) === resolvedCwd);
123
+ }
124
+ createModuleDirectory(modName) {
125
+ this.ensureWorkspace();
126
+ const isRoot = path.resolve(this.workspacePath) === path.resolve(this.cwd);
127
+ const isParentDir = this.isParentOfAllowedDirs(this.cwd);
128
+ const isInsideModule = this.isInsideAllowedDirs(this.cwd);
129
+ let targetPath;
130
+ if (isRoot) {
131
+ const packagesDir = path.join(this.cwd, 'packages');
132
+ fs.mkdirSync(packagesDir, { recursive: true });
133
+ targetPath = path.join(packagesDir, modName);
134
+ }
135
+ else if (isParentDir) {
136
+ targetPath = path.join(this.cwd, modName);
137
+ }
138
+ else if (isInsideModule) {
139
+ console.error(yanse.red(`Error: Cannot create a module inside an existing module. Please run 'lql init' from the workspace root or from a parent directory like 'packages/'.`));
140
+ process.exit(1);
141
+ }
142
+ else {
143
+ console.error(yanse.red(`Error: You must be inside the workspace root, a parent directory of modules (like 'packages/'), or inside one of the workspace packages: ${this.allowedDirs.join(', ')}`));
144
+ process.exit(1);
145
+ }
146
+ fs.mkdirSync(targetPath, { recursive: true });
147
+ return targetPath;
148
+ }
149
+ ensureModule() {
150
+ if (!this.modulePath)
151
+ throw new Error('Not inside a module');
152
+ }
153
+ ensureWorkspace() {
154
+ if (!this.workspacePath)
155
+ throw new Error('Not inside a workspace');
156
+ }
157
+ getContext() {
158
+ if (this.modulePath && this.workspacePath) {
159
+ const rel = path.relative(this.workspacePath, this.modulePath);
160
+ const nested = !rel.startsWith('..') && !path.isAbsolute(rel);
161
+ return nested ? PackageContext.ModuleInsideWorkspace : PackageContext.Module;
162
+ }
163
+ if (this.modulePath)
164
+ return PackageContext.Module;
165
+ if (this.workspacePath)
166
+ return PackageContext.Workspace;
167
+ return PackageContext.Outside;
168
+ }
169
+ isInWorkspace() {
170
+ return this.getContext() === PackageContext.Workspace;
171
+ }
172
+ isInModule() {
173
+ return (this.getContext() === PackageContext.Module ||
174
+ this.getContext() === PackageContext.ModuleInsideWorkspace);
175
+ }
176
+ getWorkspacePath() {
177
+ return this.workspacePath;
178
+ }
179
+ getModulePath() {
180
+ return this.modulePath;
181
+ }
182
+ clearCache() {
183
+ delete this._moduleInfo;
184
+ delete this._moduleMap;
185
+ }
186
+ // ──────────────── Workspace-wide ────────────────
187
+ async getModules() {
188
+ if (!this.workspacePath || !this.config)
189
+ return [];
190
+ const dirs = this.loadAllowedDirs();
191
+ const results = [];
192
+ for (const dir of dirs) {
193
+ const proj = new PgpmPackage(dir);
194
+ if (proj.isInModule()) {
195
+ results.push(proj);
196
+ }
197
+ }
198
+ return results;
199
+ }
200
+ /**
201
+ * List all modules by parsing .control files in the workspace directory.
202
+ * Handles naming collisions by preferring the shortest path.
203
+ */
204
+ listModules() {
205
+ if (!this.workspacePath)
206
+ return {};
207
+ const moduleFiles = glob.sync(`${this.workspacePath}/**/*.control`).filter((file) => !/node_modules/.test(file));
208
+ // Group files by module name to handle collisions
209
+ const filesByName = new Map();
210
+ moduleFiles.forEach((file) => {
211
+ const moduleName = path.basename(file).split('.control')[0];
212
+ if (!filesByName.has(moduleName)) {
213
+ filesByName.set(moduleName, []);
214
+ }
215
+ filesByName.get(moduleName).push(file);
216
+ });
217
+ // For each module name, pick the shortest path in case of collisions
218
+ const selectedFiles = new Map();
219
+ filesByName.forEach((files, moduleName) => {
220
+ if (files.length === 1) {
221
+ selectedFiles.set(moduleName, files[0]);
222
+ }
223
+ else {
224
+ // Multiple files with same name - pick shortest path
225
+ const shortestFile = files.reduce((shortest, current) => current.length < shortest.length ? current : shortest);
226
+ selectedFiles.set(moduleName, shortestFile);
227
+ }
228
+ });
229
+ // Parse the selected control files
230
+ return Array.from(selectedFiles.entries()).reduce((acc, [moduleName, file]) => {
231
+ const module = parseControlFile(file, this.workspacePath);
232
+ acc[moduleName] = module;
233
+ return acc;
234
+ }, {});
235
+ }
236
+ getModuleMap() {
237
+ if (!this.workspacePath)
238
+ return {};
239
+ if (this._moduleMap)
240
+ return this._moduleMap;
241
+ this._moduleMap = this.listModules();
242
+ return this._moduleMap;
243
+ }
244
+ getAvailableModules() {
245
+ const modules = this.getModuleMap();
246
+ return getAvailableExtensions(modules);
247
+ }
248
+ getModuleProject(name) {
249
+ this.ensureWorkspace();
250
+ if (this.isInModule() && name === this.getModuleName()) {
251
+ return this;
252
+ }
253
+ const modules = this.getModuleMap();
254
+ if (!modules[name]) {
255
+ throw errors.MODULE_NOT_FOUND({ name });
256
+ }
257
+ const modulePath = path.resolve(this.workspacePath, modules[name].path);
258
+ return new PgpmPackage(modulePath);
259
+ }
260
+ // ──────────────── Module-scoped ────────────────
261
+ getModuleInfo() {
262
+ this.ensureModule();
263
+ if (!this._moduleInfo) {
264
+ this._moduleInfo = getExtensionInfo(this.cwd);
265
+ }
266
+ return this._moduleInfo;
267
+ }
268
+ getModuleName() {
269
+ this.ensureModule();
270
+ return getExtensionName(this.cwd);
271
+ }
272
+ getRequiredModules() {
273
+ this.ensureModule();
274
+ const info = this.getModuleInfo();
275
+ return getInstalledExtensions(info.controlFile);
276
+ }
277
+ setModuleDependencies(modules) {
278
+ this.ensureModule();
279
+ // Validate for circular dependencies
280
+ this.validateModuleDependencies(modules);
281
+ writeExtensions(this.cwd, modules);
282
+ }
283
+ validateModuleDependencies(modules) {
284
+ const currentModuleName = this.getModuleName();
285
+ if (modules.includes(currentModuleName)) {
286
+ throw errors.CIRCULAR_DEPENDENCY({ module: currentModuleName, dependency: currentModuleName });
287
+ }
288
+ // Check for circular dependencies by examining each module's dependencies
289
+ const visited = new Set();
290
+ const visiting = new Set();
291
+ const checkCircular = (moduleName, path = []) => {
292
+ if (visiting.has(moduleName)) {
293
+ throw errors.CIRCULAR_DEPENDENCY({ module: path.join(' -> '), dependency: moduleName });
294
+ }
295
+ if (visited.has(moduleName)) {
296
+ return;
297
+ }
298
+ visiting.add(moduleName);
299
+ // More complex dependency resolution would require loading other modules' dependencies
300
+ visiting.delete(moduleName);
301
+ visited.add(moduleName);
302
+ };
303
+ modules.forEach(module => checkCircular(module, [currentModuleName]));
304
+ }
305
+ initModuleSqitch(modName, targetPath) {
306
+ const plan = generatePlan({
307
+ moduleName: modName,
308
+ uri: modName,
309
+ entries: []
310
+ });
311
+ writePlan(path.join(targetPath, 'pgpm.plan'), plan);
312
+ // Create deploy, revert, and verify directories
313
+ const dirs = ['deploy', 'revert', 'verify'];
314
+ dirs.forEach(dir => {
315
+ const dirPath = path.join(targetPath, dir);
316
+ if (!fs.existsSync(dirPath)) {
317
+ fs.mkdirSync(dirPath, { recursive: true });
318
+ }
319
+ });
320
+ }
321
+ async initModule(options) {
322
+ this.ensureWorkspace();
323
+ const targetPath = this.createModuleDirectory(options.name);
324
+ const answers = {
325
+ ...options.answers,
326
+ name: options.name,
327
+ moduleDesc: options.description,
328
+ description: options.description,
329
+ author: options.author,
330
+ extensions: options.extensions
331
+ };
332
+ await scaffoldTemplate({
333
+ type: 'module',
334
+ outputDir: targetPath,
335
+ templateRepo: options.templateRepo ?? DEFAULT_TEMPLATE_REPO,
336
+ branch: options.branch,
337
+ // Don't set default templatePath - let scaffoldTemplate use metadata-driven resolution
338
+ templatePath: options.templatePath,
339
+ answers,
340
+ noTty: options.noTty ?? false,
341
+ cacheTtlMs: options.cacheTtlMs ?? DEFAULT_TEMPLATE_TTL_MS,
342
+ toolName: options.toolName ?? DEFAULT_TEMPLATE_TOOL_NAME,
343
+ cwd: this.cwd
344
+ });
345
+ this.initModuleSqitch(options.name, targetPath);
346
+ writeExtensions(targetPath, options.extensions);
347
+ }
348
+ // ──────────────── Dependency Analysis ────────────────
349
+ getLatestChange(moduleName) {
350
+ const modules = this.getModuleMap();
351
+ return latestChange(moduleName, modules, this.workspacePath);
352
+ }
353
+ getLatestChangeAndVersion(moduleName) {
354
+ const modules = this.getModuleMap();
355
+ return latestChangeAndVersion(moduleName, modules, this.workspacePath);
356
+ }
357
+ getModuleExtensions() {
358
+ this.ensureModule();
359
+ const moduleName = this.getModuleName();
360
+ const moduleMap = this.getModuleMap();
361
+ return resolveExtensionDependencies(moduleName, moduleMap);
362
+ }
363
+ getModuleDependencies(moduleName) {
364
+ const modules = this.getModuleMap();
365
+ const { native, sqitch } = getExtensionsAndModules(moduleName, modules);
366
+ return { native, modules: sqitch };
367
+ }
368
+ getModuleDependencyChanges(moduleName) {
369
+ const modules = this.getModuleMap();
370
+ const { native, sqitch } = getExtensionsAndModulesChanges(moduleName, modules, this.workspacePath);
371
+ return { native, modules: sqitch };
372
+ }
373
+ // ──────────────── Plans ────────────────
374
+ getModulePlan() {
375
+ this.ensureModule();
376
+ const planPath = path.join(this.getModulePath(), 'pgpm.plan');
377
+ return fs.readFileSync(planPath, 'utf8');
378
+ }
379
+ getModuleControlFile() {
380
+ this.ensureModule();
381
+ const info = this.getModuleInfo();
382
+ return fs.readFileSync(info.controlFile, 'utf8');
383
+ }
384
+ getModuleMakefile() {
385
+ this.ensureModule();
386
+ const info = this.getModuleInfo();
387
+ return fs.readFileSync(info.Makefile, 'utf8');
388
+ }
389
+ getModuleSQL() {
390
+ this.ensureModule();
391
+ const info = this.getModuleInfo();
392
+ return fs.readFileSync(info.sqlFile, 'utf8');
393
+ }
394
+ generateModulePlan(options) {
395
+ this.ensureModule();
396
+ const info = this.getModuleInfo();
397
+ const moduleName = info.extname;
398
+ // Get raw dependencies and resolved list
399
+ const tagResolution = options.includeTags === true ? 'preserve' : 'internal';
400
+ let { resolved, deps } = resolveDependencies(this.cwd, moduleName, { tagResolution });
401
+ // Helper to extract module name from a change reference
402
+ const getModuleName = (change) => {
403
+ const colonIndex = change.indexOf(':');
404
+ return colonIndex > 0 ? change.substring(0, colonIndex) : null;
405
+ };
406
+ // Helper to determine if a change is truly from an external package
407
+ const isExternalChange = (change) => {
408
+ const changeModule = getModuleName(change);
409
+ return changeModule !== null && changeModule !== moduleName;
410
+ };
411
+ // Helper to normalize change name (remove package prefix)
412
+ const normalizeChangeName = (change) => {
413
+ return change.includes(':') ? change.split(':').pop() : change;
414
+ };
415
+ // Clean up the resolved list to handle both formats
416
+ const uniqueChangeNames = new Set();
417
+ const normalizedResolved = [];
418
+ // First, add local changes without prefixes
419
+ resolved.forEach(change => {
420
+ const normalized = normalizeChangeName(change);
421
+ // Skip if we've already added this change
422
+ if (uniqueChangeNames.has(normalized))
423
+ return;
424
+ // Skip truly external changes - they should only be in dependencies
425
+ if (isExternalChange(change))
426
+ return;
427
+ uniqueChangeNames.add(normalized);
428
+ normalizedResolved.push(normalized);
429
+ });
430
+ // Clean up the deps object
431
+ const normalizedDeps = {};
432
+ // Process each deps entry
433
+ Object.keys(deps).forEach(key => {
434
+ // Normalize the key - strip "/deploy/" and ".sql" if present
435
+ let normalizedKey = key;
436
+ if (normalizedKey.startsWith('/deploy/')) {
437
+ normalizedKey = normalizedKey.substring(8); // Remove "/deploy/"
438
+ }
439
+ if (normalizedKey.endsWith('.sql')) {
440
+ normalizedKey = normalizedKey.substring(0, normalizedKey.length - 4); // Remove ".sql"
441
+ }
442
+ // Skip keys for truly external changes - we only want local changes as keys
443
+ if (isExternalChange(normalizedKey))
444
+ return;
445
+ // Normalize the key for all changes, removing any same-package prefix
446
+ const cleanKey = normalizeChangeName(normalizedKey);
447
+ // Build the standard key format for our normalized deps
448
+ const standardKey = `/deploy/${cleanKey}.sql`;
449
+ // Initialize the dependencies array for this key if it doesn't exist
450
+ normalizedDeps[standardKey] = normalizedDeps[standardKey] || [];
451
+ // Add dependencies, handling both formats
452
+ const dependencies = deps[key] || [];
453
+ dependencies.forEach(dep => {
454
+ // For truly external dependencies, keep the full reference
455
+ if (isExternalChange(dep)) {
456
+ if (!normalizedDeps[standardKey].includes(dep)) {
457
+ normalizedDeps[standardKey].push(dep);
458
+ }
459
+ }
460
+ else {
461
+ // For same-package dependencies, normalize by removing prefix
462
+ const normalizedDep = normalizeChangeName(dep);
463
+ if (!normalizedDeps[standardKey].includes(normalizedDep)) {
464
+ normalizedDeps[standardKey].push(normalizedDep);
465
+ }
466
+ }
467
+ });
468
+ });
469
+ // Update with normalized versions
470
+ resolved = normalizedResolved;
471
+ deps = normalizedDeps;
472
+ // Process external dependencies if needed
473
+ const includePackages = options.includePackages === true;
474
+ const preferTags = options.includeTags === true;
475
+ if (includePackages && this.workspacePath) {
476
+ const depData = this.getModuleDependencyChanges(moduleName);
477
+ if (resolved.length > 0) {
478
+ const firstKey = `/deploy/${resolved[0]}.sql`;
479
+ deps[firstKey] = deps[firstKey] || [];
480
+ depData.modules.forEach(m => {
481
+ const extModuleName = m.name;
482
+ const hasTagDependency = deps[firstKey].some(dep => dep.startsWith(`${extModuleName}:@`));
483
+ let depToken = `${extModuleName}:${m.latest}`;
484
+ if (preferTags) {
485
+ try {
486
+ const moduleMap = this.getModuleMap();
487
+ const modInfo = moduleMap[extModuleName];
488
+ if (modInfo && this.workspacePath) {
489
+ const planPath = path.join(this.workspacePath, modInfo.path, 'pgpm.plan');
490
+ const parsed = parsePlanFile(planPath);
491
+ const changes = parsed.data?.changes || [];
492
+ const tags = parsed.data?.tags || [];
493
+ if (changes.length > 0 && tags.length > 0) {
494
+ const lastChangeName = changes[changes.length - 1]?.name;
495
+ const lastTag = tags[tags.length - 1];
496
+ if (lastTag && lastTag.change === lastChangeName) {
497
+ depToken = `${extModuleName}:@${lastTag.name}`;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ catch { }
503
+ }
504
+ if (!hasTagDependency && !deps[firstKey].includes(depToken)) {
505
+ deps[firstKey].push(depToken);
506
+ }
507
+ });
508
+ }
509
+ }
510
+ // For debugging - log the cleaned structures
511
+ // console.log("CLEAN DEPS GRAPH", JSON.stringify(deps, null, 2));
512
+ // console.log("CLEAN RES GRAPH", JSON.stringify(resolved, null, 2));
513
+ // Prepare entries for the plan file
514
+ const entries = resolved.map(res => {
515
+ const key = `/deploy/${res}.sql`;
516
+ const dependencies = deps[key] || [];
517
+ // Filter out dependencies that match the current change name
518
+ // This prevents listing a change as dependent on itself
519
+ const filteredDeps = dependencies.filter(dep => normalizeChangeName(dep) !== res);
520
+ return {
521
+ change: res,
522
+ dependencies: filteredDeps,
523
+ comment: `add ${res}`
524
+ };
525
+ });
526
+ // Use the package-files package to generate the plan
527
+ return generatePlan({
528
+ moduleName,
529
+ uri: options.uri,
530
+ entries
531
+ });
532
+ }
533
+ writeModulePlan(options) {
534
+ this.ensureModule();
535
+ const name = this.getModuleName();
536
+ const plan = this.generateModulePlan(options);
537
+ const moduleMap = this.getModuleMap();
538
+ const mod = moduleMap[name];
539
+ const planPath = path.join(this.workspacePath, mod.path, 'pgpm.plan');
540
+ // Use the package-files package to write the plan
541
+ writePlan(planPath, plan);
542
+ }
543
+ /**
544
+ * Add a tag to the current module's plan file
545
+ */
546
+ addTag(tagName, changeName, comment) {
547
+ this.ensureModule();
548
+ if (!this.modulePath) {
549
+ throw errors.PATH_NOT_FOUND({ path: 'module path', type: 'module' });
550
+ }
551
+ // Validate tag name
552
+ if (!isValidTagName(tagName)) {
553
+ throw errors.INVALID_NAME({ name: tagName, type: 'tag', rules: "Tag names must follow Sqitch naming rules and cannot contain '/'" });
554
+ }
555
+ const planPath = path.join(this.modulePath, 'pgpm.plan');
556
+ // Parse existing plan file
557
+ const planResult = parsePlanFile(planPath);
558
+ if (!planResult.data) {
559
+ throw errors.PLAN_PARSE_ERROR({ planPath, errors: planResult.errors.map(e => e.message).join(', ') });
560
+ }
561
+ const plan = planResult.data;
562
+ let targetChange = changeName;
563
+ if (!targetChange) {
564
+ if (plan.changes.length === 0) {
565
+ throw new Error('No changes found in plan file. Cannot add tag without a target change.');
566
+ }
567
+ targetChange = plan.changes[plan.changes.length - 1].name;
568
+ }
569
+ else {
570
+ // Validate that the specified change exists
571
+ const changeExists = plan.changes.some(c => c.name === targetChange);
572
+ if (!changeExists) {
573
+ throw errors.CHANGE_NOT_FOUND({ change: targetChange });
574
+ }
575
+ }
576
+ // Check if tag already exists
577
+ const existingTag = plan.tags.find(t => t.name === tagName);
578
+ if (existingTag) {
579
+ throw new Error(`Tag '${tagName}' already exists and points to change '${existingTag.change}'.`);
580
+ }
581
+ // Create new tag
582
+ const newTag = {
583
+ name: tagName,
584
+ change: targetChange,
585
+ timestamp: getPlanTimestamp(),
586
+ planner: 'launchql',
587
+ email: 'launchql@5b0c196eeb62',
588
+ comment
589
+ };
590
+ plan.tags.push(newTag);
591
+ // Write updated plan file
592
+ writePlanFile(planPath, plan);
593
+ }
594
+ /**
595
+ * Add a change to the current module's plan file and create SQL files
596
+ */
597
+ addChange(changeName, dependencies, comment) {
598
+ // Validate change name first
599
+ if (!changeName || !changeName.trim()) {
600
+ throw new Error('Change name is required');
601
+ }
602
+ if (!isValidChangeName(changeName)) {
603
+ throw errors.INVALID_NAME({ name: changeName, type: 'change', rules: "Change names must follow Sqitch naming rules" });
604
+ }
605
+ if (!this.isInWorkspace() && !this.isInModule()) {
606
+ throw new Error('This command must be run inside a PGPM workspace or module.');
607
+ }
608
+ if (this.isInModule()) {
609
+ this.ensureModule();
610
+ if (!this.modulePath) {
611
+ throw errors.PATH_NOT_FOUND({ path: 'module path', type: 'module' });
612
+ }
613
+ this.addChangeToModule(changeName, dependencies, comment);
614
+ return;
615
+ }
616
+ throw new Error('When running from workspace root, please specify --package or run from within a module directory.');
617
+ }
618
+ /**
619
+ * Add change to the current module (internal helper)
620
+ */
621
+ addChangeToModule(changeName, dependencies, comment) {
622
+ const planPath = path.join(this.modulePath, 'pgpm.plan');
623
+ // Parse existing plan file
624
+ const planResult = parsePlanFile(planPath);
625
+ if (!planResult.data) {
626
+ throw errors.PLAN_PARSE_ERROR({ planPath, errors: planResult.errors.map(e => e.message).join(', ') });
627
+ }
628
+ const plan = planResult.data;
629
+ // Check if change already exists
630
+ const existingChange = plan.changes.find(c => c.name === changeName);
631
+ if (existingChange) {
632
+ throw new Error(`Change '${changeName}' already exists in plan.`);
633
+ }
634
+ // Validate dependencies exist if provided
635
+ if (dependencies && dependencies.length > 0) {
636
+ const currentPackage = plan.package;
637
+ for (const dep of dependencies) {
638
+ // Parse the dependency to check if it's a cross-module reference
639
+ const parsed = parseReference(dep);
640
+ if (parsed && parsed.package && parsed.package !== currentPackage) {
641
+ continue;
642
+ }
643
+ const depExists = plan.changes.some(c => c.name === dep);
644
+ if (!depExists) {
645
+ throw new Error(`Dependency '${dep}' not found in plan. Add dependencies before referencing them.`);
646
+ }
647
+ }
648
+ }
649
+ // Create new change
650
+ const newChange = {
651
+ name: changeName,
652
+ dependencies: dependencies || [],
653
+ timestamp: getPlanTimestamp(),
654
+ planner: 'launchql',
655
+ email: 'launchql@5b0c196eeb62',
656
+ comment: comment || `add ${changeName}`
657
+ };
658
+ plan.changes.push(newChange);
659
+ // Write updated plan file
660
+ writePlanFile(planPath, plan);
661
+ // Create SQL files
662
+ this.createSqlFiles(changeName, dependencies || [], comment || `add ${changeName}`);
663
+ }
664
+ /**
665
+ * Create deploy/revert/verify SQL files for a change
666
+ */
667
+ createSqlFiles(changeName, dependencies, comment) {
668
+ if (!this.modulePath) {
669
+ throw errors.PATH_NOT_FOUND({ path: 'module path', type: 'module' });
670
+ }
671
+ const createdFiles = [];
672
+ const createSqlFile = (type, content) => {
673
+ const dir = path.dirname(changeName);
674
+ const fileName = path.basename(changeName);
675
+ const typeDir = path.join(this.modulePath, type);
676
+ const targetDir = path.join(typeDir, dir);
677
+ const filePath = path.join(targetDir, `${fileName}.sql`);
678
+ fs.mkdirSync(targetDir, { recursive: true });
679
+ fs.writeFileSync(filePath, content);
680
+ // Track the relative path from module root
681
+ const relativePath = path.relative(this.modulePath, filePath);
682
+ createdFiles.push(relativePath);
683
+ };
684
+ // Create deploy file
685
+ const deployContent = `-- Deploy: ${changeName}
686
+ -- made with <3 @ constructive.io
687
+
688
+ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join('\n') + '\n' : ''}
689
+ -- Add your deployment SQL here
690
+ `;
691
+ // Create revert file
692
+ const revertContent = `-- Revert: ${changeName}
693
+
694
+ -- Add your revert SQL here
695
+ `;
696
+ // Create verify file
697
+ const verifyContent = `-- Verify: ${changeName}
698
+
699
+ -- Add your verification SQL here
700
+ `;
701
+ createSqlFile('deploy', deployContent);
702
+ createSqlFile('revert', revertContent);
703
+ createSqlFile('verify', verifyContent);
704
+ // Log created files to stdout
705
+ process.stdout.write('\n✔ Files created\n\n');
706
+ createdFiles.forEach(file => {
707
+ process.stdout.write(` create ${file}\n`);
708
+ });
709
+ process.stdout.write('\n✨ All set!\n\n');
710
+ }
711
+ // ──────────────── Packaging and npm ────────────────
712
+ publishToDist(distFolder = 'dist') {
713
+ this.ensureModule();
714
+ const modPath = this.modulePath; // use modulePath, not cwd
715
+ const name = this.getModuleName();
716
+ const controlFile = `${name}.control`;
717
+ const fullDist = path.join(modPath, distFolder);
718
+ if (fs.existsSync(fullDist)) {
719
+ fs.rmSync(fullDist, { recursive: true, force: true });
720
+ }
721
+ fs.mkdirSync(fullDist, { recursive: true });
722
+ const folders = ['deploy', 'revert', 'sql', 'verify'];
723
+ const files = ['Makefile', 'package.json', 'pgpm.plan', controlFile];
724
+ // Add README file regardless of casing
725
+ const readmeFile = fs.readdirSync(modPath).find(f => /^readme\.md$/i.test(f));
726
+ if (readmeFile) {
727
+ files.push(readmeFile); // Include it in the list of files to copy
728
+ }
729
+ for (const folder of folders) {
730
+ const src = path.join(modPath, folder);
731
+ if (fs.existsSync(src)) {
732
+ fs.cpSync(src, path.join(fullDist, folder), { recursive: true });
733
+ }
734
+ }
735
+ for (const file of files) {
736
+ const src = path.join(modPath, file);
737
+ if (!fs.existsSync(src)) {
738
+ throw new Error(`Missing required file: ${file}`);
739
+ }
740
+ fs.cpSync(src, path.join(fullDist, file));
741
+ }
742
+ }
743
+ /**
744
+ * Installs an extension npm package into the local skitch extensions directory,
745
+ * and automatically adds it to the current module’s package.json dependencies.
746
+ */
747
+ async installModules(...pkgstrs) {
748
+ this.ensureWorkspace();
749
+ this.ensureModule();
750
+ const originalDir = process.cwd();
751
+ const skitchExtDir = path.join(this.workspacePath, 'extensions');
752
+ const pkgJsonPath = path.join(this.modulePath, 'package.json');
753
+ if (!fs.existsSync(pkgJsonPath)) {
754
+ throw new Error(`No package.json found at module path: ${this.modulePath}`);
755
+ }
756
+ const pkgData = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
757
+ pkgData.dependencies = pkgData.dependencies || {};
758
+ const newlyAdded = [];
759
+ for (const pkgstr of pkgstrs) {
760
+ const { name } = parse(pkgstr);
761
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lql-install-'));
762
+ try {
763
+ process.chdir(tempDir);
764
+ execSync(`npm install ${pkgstr} --production --prefix ./extensions`, {
765
+ stdio: 'inherit'
766
+ });
767
+ const matches = glob.sync('./extensions/**/pgpm.plan');
768
+ const installs = matches.map((conf) => {
769
+ const fullConf = resolve(conf);
770
+ const extDir = dirname(fullConf);
771
+ const relativeDir = extDir.split('node_modules/')[1];
772
+ const dstDir = path.join(skitchExtDir, relativeDir);
773
+ return { src: extDir, dst: dstDir, pkg: relativeDir };
774
+ });
775
+ for (const { src, dst, pkg } of installs) {
776
+ if (fs.existsSync(dst)) {
777
+ fs.rmSync(dst, { recursive: true, force: true });
778
+ }
779
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
780
+ execSync(`mv "${src}" "${dst}"`);
781
+ logger.success(`✔ installed ${pkg}`);
782
+ const pkgJsonFile = path.join(dst, 'package.json');
783
+ if (!fs.existsSync(pkgJsonFile)) {
784
+ throw new Error(`Missing package.json in installed extension: ${dst}`);
785
+ }
786
+ const { version } = JSON.parse(fs.readFileSync(pkgJsonFile, 'utf-8'));
787
+ pkgData.dependencies[name] = `${version}`;
788
+ const extensionName = getExtensionName(dst);
789
+ newlyAdded.push(extensionName);
790
+ }
791
+ }
792
+ finally {
793
+ fs.rmSync(tempDir, { recursive: true, force: true });
794
+ process.chdir(originalDir);
795
+ }
796
+ }
797
+ const { dependencies, devDependencies, ...rest } = pkgData;
798
+ const finalPkgData = { ...rest };
799
+ if (dependencies) {
800
+ finalPkgData.dependencies = sortObjectByKey(dependencies);
801
+ }
802
+ if (devDependencies) {
803
+ finalPkgData.devDependencies = sortObjectByKey(devDependencies);
804
+ }
805
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(finalPkgData, null, 2));
806
+ logger.success(`📦 Updated package.json with: ${pkgstrs.join(', ')}`);
807
+ // ─── Update .control file with actual extension names ──────────────
808
+ const currentDeps = this.getRequiredModules();
809
+ const updatedDeps = Array.from(new Set([...currentDeps, ...newlyAdded])).sort();
810
+ writeExtensions(this.modulePath, updatedDeps);
811
+ }
812
+ // ──────────────── Package Operations ────────────────
813
+ /**
814
+ * Get the set of modules that have been deployed to the database
815
+ */
816
+ async getDeployedModules(pgConfig) {
817
+ try {
818
+ const client = new PgpmMigrate(pgConfig);
819
+ await client.initialize();
820
+ const status = await client.status();
821
+ return new Set(status.map(s => s.package));
822
+ }
823
+ catch (error) {
824
+ if (error.code === '42P01' || error.code === '3F000') {
825
+ return new Set();
826
+ }
827
+ throw error;
828
+ }
829
+ }
830
+ async resolveWorkspaceExtensionDependencies(opts) {
831
+ const modules = this.getModuleMap();
832
+ const allModuleNames = Object.keys(modules);
833
+ if (allModuleNames.length === 0) {
834
+ return { resolved: [], external: [] };
835
+ }
836
+ // Create a virtual module that depends on all workspace modules
837
+ const virtualModuleName = '_virtual/workspace';
838
+ const virtualModuleMap = {
839
+ ...modules,
840
+ [virtualModuleName]: {
841
+ requires: allModuleNames
842
+ }
843
+ };
844
+ const { resolved, external } = resolveExtensionDependencies(virtualModuleName, virtualModuleMap);
845
+ let filteredResolved = resolved.filter((moduleName) => moduleName !== virtualModuleName);
846
+ // Filter by deployment status if requested
847
+ if (opts?.filterDeployed && opts?.pgConfig) {
848
+ const deployedModules = await this.getDeployedModules(opts.pgConfig);
849
+ filteredResolved = filteredResolved.filter(module => deployedModules.has(module));
850
+ }
851
+ return {
852
+ resolved: filteredResolved,
853
+ external: external
854
+ };
855
+ }
856
+ parsePackageTarget(target) {
857
+ let name;
858
+ let toChange;
859
+ if (!target) {
860
+ const context = this.getContext();
861
+ if (context === PackageContext.Module || context === PackageContext.ModuleInsideWorkspace) {
862
+ name = this.getModuleName();
863
+ }
864
+ else if (context === PackageContext.Workspace) {
865
+ const modules = this.getModuleMap();
866
+ const moduleNames = Object.keys(modules);
867
+ if (moduleNames.length === 0) {
868
+ throw new Error('No modules found in workspace');
869
+ }
870
+ name = null; // Indicates workspace-wide operation
871
+ }
872
+ else {
873
+ throw new Error('Not in a PGPM workspace or module');
874
+ }
875
+ }
876
+ else {
877
+ const parsed = parseTarget(target);
878
+ name = parsed.packageName;
879
+ toChange = parsed.toChange;
880
+ }
881
+ return { name, toChange };
882
+ }
883
+ async deploy(opts, target, recursive = true) {
884
+ const log = new Logger('deploy');
885
+ const { name, toChange } = this.parsePackageTarget(target);
886
+ if (recursive) {
887
+ // Cache for fast deployment
888
+ const deployFastCache = {};
889
+ const getCacheKey = (pg, name, database) => {
890
+ const { host, port, user } = pg ?? {};
891
+ return `${host}:${port}:${user}:${database}:${name}`;
892
+ };
893
+ const modules = this.getModuleMap();
894
+ let extensions;
895
+ if (name === null) {
896
+ // When name is null, deploy ALL modules in the workspace
897
+ extensions = await this.resolveWorkspaceExtensionDependencies();
898
+ }
899
+ else {
900
+ const moduleProject = this.getModuleProject(name);
901
+ extensions = moduleProject.getModuleExtensions();
902
+ }
903
+ const pgPool = getPgPool(opts.pg);
904
+ const targetDescription = name === null ? 'all modules' : name;
905
+ log.success(`🚀 Starting deployment to database ${opts.pg.database}...`);
906
+ for (const extension of extensions.resolved) {
907
+ try {
908
+ if (extensions.external.includes(extension)) {
909
+ const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
910
+ log.info(`📥 Installing external extension: ${extension}`);
911
+ await pgPool.query(msg);
912
+ }
913
+ else {
914
+ const modulePath = resolve(this.workspacePath, modules[extension].path);
915
+ log.info(`📂 Deploying local module: ${extension}`);
916
+ if (opts.deployment.fast) {
917
+ const localProject = this.getModuleProject(extension);
918
+ const cacheKey = getCacheKey(opts.pg, extension, opts.pg.database);
919
+ if (opts.deployment.cache && deployFastCache[cacheKey]) {
920
+ log.warn(`⚡ Using cached pkg for ${extension}.`);
921
+ await pgPool.query(deployFastCache[cacheKey].sql);
922
+ continue;
923
+ }
924
+ let pkg;
925
+ try {
926
+ pkg = await packageModule(localProject.modulePath, {
927
+ usePlan: opts.deployment.usePlan,
928
+ extension: false
929
+ });
930
+ }
931
+ catch (err) {
932
+ const errorLines = [];
933
+ errorLines.push(`❌ Failed to package module "${extension}" at path: ${modulePath}`);
934
+ errorLines.push(` Module Path: ${modulePath}`);
935
+ errorLines.push(` Workspace Path: ${this.workspacePath}`);
936
+ errorLines.push(` Error Code: ${err.code || 'N/A'}`);
937
+ errorLines.push(` Error Message: ${err.message || 'Unknown error'}`);
938
+ if (err.code === 'ENOENT') {
939
+ errorLines.push('💡 Hint: File or directory not found. Check if the module path is correct.');
940
+ }
941
+ else if (err.code === 'EACCES') {
942
+ errorLines.push('💡 Hint: Permission denied. Check file permissions.');
943
+ }
944
+ else if (err.message && err.message.includes('pgpm.plan')) {
945
+ errorLines.push('💡 Hint: pgpm.plan file issue. Check if the plan file exists and is valid.');
946
+ }
947
+ log.error(errorLines.join('\n'));
948
+ console.error(err);
949
+ throw errors.DEPLOYMENT_FAILED({
950
+ type: 'Deployment',
951
+ module: extension
952
+ });
953
+ }
954
+ await pgPool.query(pkg.sql);
955
+ if (opts.deployment.cache) {
956
+ deployFastCache[cacheKey] = pkg;
957
+ }
958
+ }
959
+ else {
960
+ try {
961
+ const client = new PgpmMigrate(opts.pg);
962
+ // Only apply toChange to the target module, not its dependencies
963
+ const moduleToChange = extension === name ? toChange : undefined;
964
+ const result = await client.deploy({
965
+ modulePath,
966
+ toChange: moduleToChange,
967
+ useTransaction: opts.deployment.useTx,
968
+ logOnly: opts.deployment.logOnly,
969
+ usePlan: opts.deployment.usePlan
970
+ });
971
+ if (result.failed) {
972
+ throw errors.OPERATION_FAILED({ operation: 'Deployment', target: result.failed });
973
+ }
974
+ }
975
+ catch (deployError) {
976
+ log.error(`❌ Deployment failed for module ${extension}`);
977
+ console.error(deployError);
978
+ throw errors.DEPLOYMENT_FAILED({ type: 'Deployment', module: extension });
979
+ }
980
+ }
981
+ }
982
+ }
983
+ catch (err) {
984
+ log.error(`🛑 Error during deployment: ${err instanceof Error ? err.message : err}`);
985
+ console.error(err);
986
+ throw errors.DEPLOYMENT_FAILED({ type: 'Deployment', module: extension });
987
+ }
988
+ }
989
+ log.success(`✅ Deployment complete for ${targetDescription}.`);
990
+ }
991
+ else {
992
+ if (name === null) {
993
+ throw errors.WORKSPACE_OPERATION_ERROR({ operation: 'deployment' });
994
+ }
995
+ const moduleProject = this.getModuleProject(name);
996
+ const modulePath = moduleProject.getModulePath();
997
+ if (!modulePath) {
998
+ throw errors.PATH_NOT_FOUND({ path: name, type: 'module' });
999
+ }
1000
+ const client = new PgpmMigrate(opts.pg);
1001
+ const result = await client.deploy({
1002
+ modulePath,
1003
+ toChange,
1004
+ useTransaction: opts.deployment?.useTx,
1005
+ logOnly: opts.deployment?.logOnly,
1006
+ usePlan: opts.deployment?.usePlan
1007
+ });
1008
+ if (result.failed) {
1009
+ throw errors.OPERATION_FAILED({ operation: 'Deployment', target: result.failed });
1010
+ }
1011
+ log.success(`✅ Single module deployment complete for ${name}.`);
1012
+ }
1013
+ }
1014
+ /**
1015
+ * Reverts database changes for modules. Unlike verify operations, revert operations
1016
+ * modify database state and must ensure dependent modules are reverted before their
1017
+ * dependencies to prevent database constraint violations.
1018
+ */
1019
+ async revert(opts, target, recursive = true) {
1020
+ const log = new Logger('revert');
1021
+ const { name, toChange } = this.parsePackageTarget(target);
1022
+ if (recursive) {
1023
+ const modules = this.getModuleMap();
1024
+ // Mirror deploy logic: find all modules that depend on the target module
1025
+ let extensionsToRevert;
1026
+ if (name === null) {
1027
+ // When name is null, revert ALL deployed modules in the workspace
1028
+ extensionsToRevert = await this.resolveWorkspaceExtensionDependencies({
1029
+ filterDeployed: true,
1030
+ pgConfig: opts.pg
1031
+ });
1032
+ }
1033
+ else {
1034
+ // Always use workspace-wide resolution in recursive mode, but filter to deployed modules
1035
+ const workspaceExtensions = await this.resolveWorkspaceExtensionDependencies({
1036
+ filterDeployed: true,
1037
+ pgConfig: opts.pg
1038
+ });
1039
+ extensionsToRevert = truncateExtensionsToTarget(workspaceExtensions, name);
1040
+ }
1041
+ const pgPool = getPgPool(opts.pg);
1042
+ const targetDescription = name === null ? 'all modules' : name;
1043
+ log.success(`🧹 Starting revert process on database ${opts.pg.database}...`);
1044
+ const reversedExtensions = [...extensionsToRevert.resolved].reverse();
1045
+ for (const extension of reversedExtensions) {
1046
+ try {
1047
+ if (extensionsToRevert.external.includes(extension)) {
1048
+ const msg = `DROP EXTENSION IF EXISTS "${extension}" RESTRICT;`;
1049
+ log.warn(`⚠️ Dropping external extension: ${extension}`);
1050
+ try {
1051
+ await pgPool.query(msg);
1052
+ }
1053
+ catch (err) {
1054
+ if (err.code === '2BP01') {
1055
+ log.warn(`⚠️ Cannot drop extension ${extension} due to dependencies, skipping`);
1056
+ }
1057
+ else {
1058
+ throw err;
1059
+ }
1060
+ }
1061
+ }
1062
+ else {
1063
+ const modulePath = resolve(this.workspacePath, modules[extension].path);
1064
+ log.info(`📂 Reverting local module: ${extension}`);
1065
+ try {
1066
+ const client = new PgpmMigrate(opts.pg);
1067
+ // Only apply toChange to the target module, not its dependencies
1068
+ const moduleToChange = extension === name ? toChange : undefined;
1069
+ const result = await client.revert({
1070
+ modulePath,
1071
+ toChange: moduleToChange,
1072
+ useTransaction: opts.deployment.useTx
1073
+ });
1074
+ if (result.failed) {
1075
+ throw errors.OPERATION_FAILED({ operation: 'Revert', target: result.failed });
1076
+ }
1077
+ }
1078
+ catch (revertError) {
1079
+ log.error(`❌ Revert failed for module ${extension}`);
1080
+ throw errors.DEPLOYMENT_FAILED({ type: 'Revert', module: extension });
1081
+ }
1082
+ }
1083
+ }
1084
+ catch (e) {
1085
+ log.error(`🛑 Error during revert: ${e instanceof Error ? e.message : e}`);
1086
+ console.error(e);
1087
+ throw errors.DEPLOYMENT_FAILED({ type: 'Revert', module: extension });
1088
+ }
1089
+ }
1090
+ log.success(`✅ Revert complete for ${targetDescription}.`);
1091
+ }
1092
+ else {
1093
+ if (name === null) {
1094
+ throw errors.WORKSPACE_OPERATION_ERROR({ operation: 'revert' });
1095
+ }
1096
+ const moduleProject = this.getModuleProject(name);
1097
+ const modulePath = moduleProject.getModulePath();
1098
+ if (!modulePath) {
1099
+ throw errors.PATH_NOT_FOUND({ path: name, type: 'module' });
1100
+ }
1101
+ const client = new PgpmMigrate(opts.pg);
1102
+ const result = await client.revert({
1103
+ modulePath,
1104
+ toChange,
1105
+ useTransaction: opts.deployment?.useTx
1106
+ });
1107
+ if (result.failed) {
1108
+ throw errors.OPERATION_FAILED({ operation: 'Revert', target: result.failed });
1109
+ }
1110
+ log.success(`✅ Single module revert complete for ${name}.`);
1111
+ }
1112
+ }
1113
+ async verify(opts, target, recursive = true) {
1114
+ const log = new Logger('verify');
1115
+ const { name, toChange } = this.parsePackageTarget(target);
1116
+ if (recursive) {
1117
+ const modules = this.getModuleMap();
1118
+ let extensions;
1119
+ if (name === null) {
1120
+ // When name is null, verify ALL modules in the workspace
1121
+ extensions = await this.resolveWorkspaceExtensionDependencies();
1122
+ }
1123
+ else {
1124
+ const moduleProject = this.getModuleProject(name);
1125
+ extensions = moduleProject.getModuleExtensions();
1126
+ }
1127
+ const pgPool = getPgPool(opts.pg);
1128
+ const targetDescription = name === null ? 'all modules' : name;
1129
+ log.success(`🔎 Verifying deployment of ${targetDescription} on database ${opts.pg.database}...`);
1130
+ for (const extension of extensions.resolved) {
1131
+ try {
1132
+ if (extensions.external.includes(extension)) {
1133
+ const query = `SELECT 1/count(*) FROM pg_available_extensions WHERE name = $1`;
1134
+ log.info(`🔍 Verifying external extension: ${extension}`);
1135
+ await pgPool.query(query, [extension]);
1136
+ }
1137
+ else {
1138
+ const modulePath = resolve(this.workspacePath, modules[extension].path);
1139
+ log.info(`📂 Verifying local module: ${extension}`);
1140
+ try {
1141
+ const client = new PgpmMigrate(opts.pg);
1142
+ // Only apply toChange to the target module, not its dependencies
1143
+ const moduleToChange = extension === name ? toChange : undefined;
1144
+ const result = await client.verify({
1145
+ modulePath,
1146
+ toChange: moduleToChange
1147
+ });
1148
+ if (result.failed.length > 0) {
1149
+ throw errors.OPERATION_FAILED({ operation: 'Verification', reason: `${result.failed.length} changes: ${result.failed.join(', ')}` });
1150
+ }
1151
+ }
1152
+ catch (verifyError) {
1153
+ log.error(`❌ Verification failed for module ${extension}`);
1154
+ throw errors.DEPLOYMENT_FAILED({ type: 'Verify', module: extension });
1155
+ }
1156
+ }
1157
+ }
1158
+ catch (e) {
1159
+ log.error(`🛑 Error during verification: ${e instanceof Error ? e.message : e}`);
1160
+ console.error(e);
1161
+ throw errors.DEPLOYMENT_FAILED({ type: 'Verify', module: extension });
1162
+ }
1163
+ }
1164
+ log.success(`✅ Verification complete for ${targetDescription}.`);
1165
+ }
1166
+ else {
1167
+ if (name === null) {
1168
+ throw errors.WORKSPACE_OPERATION_ERROR({ operation: 'verification' });
1169
+ }
1170
+ const moduleProject = this.getModuleProject(name);
1171
+ const modulePath = moduleProject.getModulePath();
1172
+ if (!modulePath) {
1173
+ throw errors.PATH_NOT_FOUND({ path: name, type: 'module' });
1174
+ }
1175
+ const client = new PgpmMigrate(opts.pg);
1176
+ const result = await client.verify({
1177
+ modulePath,
1178
+ toChange
1179
+ });
1180
+ if (result.failed.length > 0) {
1181
+ throw errors.OPERATION_FAILED({ operation: 'Verification', reason: `${result.failed.length} changes: ${result.failed.join(', ')}` });
1182
+ }
1183
+ log.success(`✅ Single module verification complete for ${name}.`);
1184
+ }
1185
+ }
1186
+ async removeFromPlan(toChange) {
1187
+ const log = new Logger('remove');
1188
+ const modulePath = this.getModulePath();
1189
+ if (!modulePath) {
1190
+ throw errors.PATH_NOT_FOUND({ path: 'module path', type: 'module' });
1191
+ }
1192
+ const planPath = path.join(modulePath, 'pgpm.plan');
1193
+ const result = parsePlanFile(planPath);
1194
+ if (result.errors.length > 0) {
1195
+ throw errors.PLAN_PARSE_ERROR({ planPath, errors: result.errors.map(e => e.message).join(', ') });
1196
+ }
1197
+ const plan = result.data;
1198
+ if (toChange.startsWith('@')) {
1199
+ const tagName = toChange.substring(1); // Remove the '@' prefix
1200
+ const tagToRemove = plan.tags.find(tag => tag.name === tagName);
1201
+ if (!tagToRemove) {
1202
+ throw errors.TAG_NOT_FOUND({ tag: toChange });
1203
+ }
1204
+ const tagChangeIndex = plan.changes.findIndex(c => c.name === tagToRemove.change);
1205
+ if (tagChangeIndex === -1) {
1206
+ throw errors.CHANGE_NOT_FOUND({ change: tagToRemove.change, plan: `for tag '${toChange}'` });
1207
+ }
1208
+ const changesToRemove = plan.changes.slice(tagChangeIndex);
1209
+ plan.changes = plan.changes.slice(0, tagChangeIndex);
1210
+ plan.tags = plan.tags.filter(tag => tag.name !== tagName && !changesToRemove.some(change => change.name === tag.change));
1211
+ for (const change of changesToRemove) {
1212
+ for (const scriptType of ['deploy', 'revert', 'verify']) {
1213
+ const scriptPath = path.join(modulePath, scriptType, `${change.name}.sql`);
1214
+ if (fs.existsSync(scriptPath)) {
1215
+ fs.unlinkSync(scriptPath);
1216
+ log.info(`Deleted ${scriptType}/${change.name}.sql`);
1217
+ }
1218
+ }
1219
+ }
1220
+ // Write updated plan file
1221
+ writePlanFile(planPath, plan);
1222
+ log.success(`Removed tag ${toChange} and ${changesToRemove.length} subsequent changes from plan`);
1223
+ return;
1224
+ }
1225
+ const targetIndex = plan.changes.findIndex(c => c.name === toChange);
1226
+ if (targetIndex === -1) {
1227
+ throw errors.CHANGE_NOT_FOUND({ change: toChange });
1228
+ }
1229
+ const changesToRemove = plan.changes.slice(targetIndex);
1230
+ plan.changes = plan.changes.slice(0, targetIndex);
1231
+ plan.tags = plan.tags.filter(tag => !changesToRemove.some(change => change.name === tag.change));
1232
+ for (const change of changesToRemove) {
1233
+ for (const scriptType of ['deploy', 'revert', 'verify']) {
1234
+ const scriptPath = path.join(modulePath, scriptType, `${change.name}.sql`);
1235
+ if (fs.existsSync(scriptPath)) {
1236
+ fs.unlinkSync(scriptPath);
1237
+ log.info(`Deleted ${scriptType}/${change.name}.sql`);
1238
+ }
1239
+ }
1240
+ }
1241
+ // Write updated plan file
1242
+ writePlanFile(planPath, plan);
1243
+ log.success(`Removed ${changesToRemove.length} changes from plan`);
1244
+ }
1245
+ analyzeModule() {
1246
+ this.ensureModule();
1247
+ const info = this.getModuleInfo();
1248
+ const modPath = this.getModulePath();
1249
+ const issues = [];
1250
+ const exists = (p) => fs.existsSync(p);
1251
+ const read = (p) => (exists(p) ? fs.readFileSync(p, 'utf8') : undefined);
1252
+ const planPath = path.join(modPath, 'pgpm.plan');
1253
+ if (!exists(planPath))
1254
+ issues.push({ code: 'missing_plan', message: 'Missing pgpm.plan', file: planPath });
1255
+ const pkgJsonPath = path.join(modPath, 'package.json');
1256
+ if (!exists(pkgJsonPath))
1257
+ issues.push({ code: 'missing_package_json', message: 'Missing package.json', file: pkgJsonPath });
1258
+ const makefilePath = info.Makefile;
1259
+ if (!exists(makefilePath))
1260
+ issues.push({ code: 'missing_makefile', message: 'Missing Makefile', file: makefilePath });
1261
+ const controlPath = info.controlFile;
1262
+ if (!exists(controlPath))
1263
+ issues.push({ code: 'missing_control', message: 'Missing control file', file: controlPath });
1264
+ const sqlCombined = info.sqlFile ? path.join(modPath, info.sqlFile) : path.join(modPath, 'sql', `${info.extname}--${info.version}.sql`);
1265
+ if (!exists(sqlCombined))
1266
+ issues.push({ code: 'missing_sql', message: 'Missing combined sql file', file: sqlCombined });
1267
+ const deployDir = path.join(modPath, 'deploy');
1268
+ if (!exists(deployDir))
1269
+ issues.push({ code: 'missing_deploy_dir', message: 'Missing deploy directory', file: deployDir });
1270
+ const revertDir = path.join(modPath, 'revert');
1271
+ if (!exists(revertDir))
1272
+ issues.push({ code: 'missing_revert_dir', message: 'Missing revert directory', file: revertDir });
1273
+ const verifyDir = path.join(modPath, 'verify');
1274
+ if (!exists(verifyDir))
1275
+ issues.push({ code: 'missing_verify_dir', message: 'Missing verify directory', file: verifyDir });
1276
+ if (exists(planPath)) {
1277
+ try {
1278
+ const parsed = parsePlanFile(planPath);
1279
+ const pkgName = parsed.data?.package;
1280
+ if (!pkgName)
1281
+ issues.push({ code: 'plan_missing_project', message: '%project missing', file: planPath });
1282
+ if (pkgName && pkgName !== info.extname)
1283
+ issues.push({ code: 'plan_project_mismatch', message: `pgpm.plan %project ${pkgName} != ${info.extname}`, file: planPath });
1284
+ const uri = parsed.data?.uri;
1285
+ if (uri && uri !== info.extname)
1286
+ issues.push({ code: 'plan_uri_mismatch', message: `pgpm.plan %uri ${uri} != ${info.extname}`, file: planPath });
1287
+ }
1288
+ catch (e) {
1289
+ issues.push({ code: 'plan_parse_error', message: e?.message || 'Plan parse error', file: planPath });
1290
+ }
1291
+ }
1292
+ if (exists(makefilePath)) {
1293
+ const mf = read(makefilePath) || '';
1294
+ const extMatch = mf.match(/^EXTENSION\s*=\s*(.+)$/m);
1295
+ const dataMatch = mf.match(/^DATA\s*=\s*sql\/(.+)\.sql$/m);
1296
+ if (!extMatch)
1297
+ issues.push({ code: 'makefile_missing_extension', message: 'Makefile missing EXTENSION', file: makefilePath });
1298
+ if (!dataMatch)
1299
+ issues.push({ code: 'makefile_missing_data', message: 'Makefile missing DATA', file: makefilePath });
1300
+ if (extMatch && extMatch[1].trim() !== info.extname)
1301
+ issues.push({ code: 'makefile_extension_mismatch', message: `Makefile EXTENSION ${extMatch[1].trim()} != ${info.extname}`, file: makefilePath });
1302
+ const expectedData = `${info.extname}--${info.version}`;
1303
+ if (dataMatch && dataMatch[1].trim() !== expectedData)
1304
+ issues.push({ code: 'makefile_data_mismatch', message: `Makefile DATA sql/${dataMatch[1].trim()}.sql != sql/${expectedData}.sql`, file: makefilePath });
1305
+ }
1306
+ if (exists(controlPath)) {
1307
+ const base = path.basename(controlPath);
1308
+ const expected = `${info.extname}.control`;
1309
+ if (base !== expected)
1310
+ issues.push({ code: 'control_filename_mismatch', message: `Control filename ${base} != ${expected}`, file: controlPath });
1311
+ }
1312
+ return { ok: issues.length === 0, name: info.extname, path: modPath, issues };
1313
+ }
1314
+ renameModule(newName, opts) {
1315
+ this.ensureModule();
1316
+ const info = this.getModuleInfo();
1317
+ const modPath = this.getModulePath();
1318
+ const changed = [];
1319
+ const warnings = [];
1320
+ const dry = !!opts?.dryRun;
1321
+ const valid = /^[a-z][a-z0-9_]*$/;
1322
+ if (!valid.test(newName)) {
1323
+ throw errors.INVALID_NAME({ name: newName, type: 'module', rules: 'lowercase letters, digits, underscores; must start with letter' });
1324
+ }
1325
+ const planPath = path.join(modPath, 'pgpm.plan');
1326
+ if (fs.existsSync(planPath)) {
1327
+ try {
1328
+ const parsed = parsePlanFile(planPath);
1329
+ if (parsed.data) {
1330
+ parsed.data.package = newName;
1331
+ parsed.data.uri = newName;
1332
+ if (!dry)
1333
+ writePlanFile(planPath, parsed.data);
1334
+ changed.push(planPath);
1335
+ }
1336
+ }
1337
+ catch (e) {
1338
+ warnings.push(`failed to update pgpm.plan`);
1339
+ }
1340
+ }
1341
+ else {
1342
+ warnings.push('missing pgpm.plan');
1343
+ }
1344
+ const pkgJsonPath = path.join(modPath, 'package.json');
1345
+ if (fs.existsSync(pkgJsonPath) && opts?.syncPackageJsonName) {
1346
+ try {
1347
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
1348
+ const oldName = pkg.name;
1349
+ if (oldName) {
1350
+ if (oldName.startsWith('@')) {
1351
+ const parts = oldName.split('/');
1352
+ if (parts.length === 2)
1353
+ pkg.name = `${parts[0]}/${newName}`;
1354
+ else
1355
+ pkg.name = newName;
1356
+ }
1357
+ else {
1358
+ pkg.name = newName;
1359
+ }
1360
+ }
1361
+ else {
1362
+ pkg.name = newName;
1363
+ }
1364
+ if (!dry)
1365
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2));
1366
+ changed.push(pkgJsonPath);
1367
+ }
1368
+ catch {
1369
+ warnings.push('failed to update package.json name');
1370
+ }
1371
+ }
1372
+ const oldControl = info.controlFile;
1373
+ const newControl = path.join(modPath, `${newName}.control`);
1374
+ const version = info.version;
1375
+ const requires = (() => {
1376
+ try {
1377
+ const c = fs.readFileSync(oldControl, 'utf8');
1378
+ const line = c.split('\n').find(l => /^requires/.test(l));
1379
+ if (!line)
1380
+ return [];
1381
+ return line.split('=')[1].split("'")[1].split(',').map(s => s.trim()).filter(Boolean);
1382
+ }
1383
+ catch {
1384
+ return [];
1385
+ }
1386
+ })();
1387
+ if (fs.existsSync(oldControl)) {
1388
+ if (!dry) {
1389
+ const content = generateControlFileContent({ name: newName, version, requires });
1390
+ fs.writeFileSync(newControl, content);
1391
+ if (oldControl !== newControl && fs.existsSync(oldControl))
1392
+ fs.rmSync(oldControl);
1393
+ }
1394
+ changed.push(newControl);
1395
+ }
1396
+ else {
1397
+ warnings.push('missing control file');
1398
+ }
1399
+ const makefilePath = info.Makefile;
1400
+ if (fs.existsSync(makefilePath)) {
1401
+ if (!dry)
1402
+ writeExtensionMakefile(makefilePath, newName, version);
1403
+ changed.push(makefilePath);
1404
+ }
1405
+ else {
1406
+ warnings.push('missing Makefile');
1407
+ }
1408
+ const oldSql = path.join(modPath, 'sql', `${info.extname}--${version}.sql`);
1409
+ const newSql = path.join(modPath, 'sql', `${newName}--${version}.sql`);
1410
+ if (fs.existsSync(oldSql)) {
1411
+ if (!dry) {
1412
+ if (oldSql !== newSql) {
1413
+ fs.mkdirSync(path.dirname(newSql), { recursive: true });
1414
+ fs.renameSync(oldSql, newSql);
1415
+ }
1416
+ }
1417
+ changed.push(newSql);
1418
+ }
1419
+ else {
1420
+ if (fs.existsSync(newSql)) {
1421
+ changed.push(newSql);
1422
+ }
1423
+ else {
1424
+ warnings.push('missing combined sql file');
1425
+ }
1426
+ }
1427
+ this.clearCache();
1428
+ return { changed, warnings };
1429
+ }
1430
+ }