@pgpmjs/core 7.13.0 → 7.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apply/apply-spec.d.ts +14 -0
- package/apply/apply-spec.js +165 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.js +20 -0
- package/apply/materialize.d.ts +40 -0
- package/apply/materialize.js +134 -0
- package/apply/reuse.d.ts +52 -0
- package/apply/reuse.js +226 -0
- package/apply/types.d.ts +172 -0
- package/apply/types.js +17 -0
- package/bundle/artifact.d.ts +35 -0
- package/bundle/artifact.js +96 -0
- package/bundle/deploy-bundled.d.ts +71 -0
- package/bundle/deploy-bundled.js +177 -0
- package/bundle/index.d.ts +2 -0
- package/bundle/index.js +4 -0
- package/core/class/pgpm.d.ts +20 -0
- package/core/class/pgpm.js +109 -62
- package/esm/apply/apply-spec.js +160 -0
- package/esm/apply/index.js +4 -0
- package/esm/apply/materialize.js +129 -0
- package/esm/apply/reuse.js +221 -0
- package/esm/apply/types.js +14 -0
- package/esm/bundle/artifact.js +89 -0
- package/esm/bundle/deploy-bundled.js +170 -0
- package/esm/bundle/index.js +4 -0
- package/esm/core/class/pgpm.js +109 -62
- package/esm/index.js +1 -0
- package/esm/packaging/package.js +8 -1
- package/esm/projects/deploy.js +30 -57
- package/esm/projects/revert.js +10 -3
- package/esm/projects/verify.js +10 -3
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +10 -9
- package/packaging/package.d.ts +7 -1
- package/packaging/package.js +8 -1
- package/projects/deploy.js +30 -57
- package/projects/revert.js +10 -3
- package/projects/verify.js +10 -3
package/core/class/pgpm.js
CHANGED
|
@@ -48,6 +48,10 @@ const parse_package_name_1 = require("parse-package-name");
|
|
|
48
48
|
const path_1 = __importStar(require("path"));
|
|
49
49
|
const pg_cache_1 = require("pg-cache");
|
|
50
50
|
const yanse_1 = __importDefault(require("yanse"));
|
|
51
|
+
const materialize_1 = require("../../apply/materialize");
|
|
52
|
+
const apply_spec_1 = require("../../apply/apply-spec");
|
|
53
|
+
const reuse_1 = require("../../apply/reuse");
|
|
54
|
+
const types_2 = require("../../apply/types");
|
|
51
55
|
const extensions_1 = require("../../extensions/extensions");
|
|
52
56
|
const files_1 = require("@pgpmjs/ast/files");
|
|
53
57
|
const files_2 = require("@pgpmjs/ast/files");
|
|
@@ -57,7 +61,7 @@ const parser_1 = require("@pgpmjs/ast/files/plan/parser");
|
|
|
57
61
|
const validators_1 = require("@pgpmjs/ast/files/plan/validators");
|
|
58
62
|
const client_1 = require("../../migrate/client");
|
|
59
63
|
const modules_1 = require("../../modules/modules");
|
|
60
|
-
const
|
|
64
|
+
const deploy_bundled_1 = require("../../bundle/deploy-bundled");
|
|
61
65
|
const sync_versions_1 = require("../../packaging/sync-versions");
|
|
62
66
|
const deps_1 = require("../../resolution/deps");
|
|
63
67
|
const fs_2 = require("../../utils/fs");
|
|
@@ -281,11 +285,61 @@ class PgpmPackage {
|
|
|
281
285
|
}
|
|
282
286
|
});
|
|
283
287
|
// Parse the selected control files
|
|
284
|
-
|
|
288
|
+
const moduleMap = Array.from(selectedFiles.entries()).reduce((acc, [moduleName, file]) => {
|
|
285
289
|
const module = (0, files_2.parseControlFile)(file, this.workspacePath);
|
|
286
290
|
acc[moduleName] = module;
|
|
287
291
|
return acc;
|
|
288
292
|
}, {});
|
|
293
|
+
return this.addApplyModules(moduleMap, packageDirs);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Discover apply-spec proxy modules (`pgpm.apply.json`) and synthesize
|
|
297
|
+
* module-map entries for them so they participate in dependency resolution
|
|
298
|
+
* by name like any other module. A proxy needs no `.control` or `pgpm.plan`;
|
|
299
|
+
* its `requires` default to the source module's requires. Control-file
|
|
300
|
+
* modules win name collisions.
|
|
301
|
+
*/
|
|
302
|
+
addApplyModules(moduleMap, packageDirs) {
|
|
303
|
+
const specFiles = [...new Set(packageDirs.flatMap(dir => glob.sync((0, glob_1.globPattern)(dir, `**/${types_2.APPLY_SPEC_FILE}`))))].filter((file) => !/node_modules/.test(file)).sort((a, b) => a.localeCompare(b));
|
|
304
|
+
for (const file of specFiles) {
|
|
305
|
+
let spec;
|
|
306
|
+
try {
|
|
307
|
+
spec = (0, apply_spec_1.readApplySpec)(path_1.default.dirname(file));
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
console.warn(`Skipping invalid apply spec ${file}: ${err instanceof Error ? err.message : err}`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const name = spec.name;
|
|
314
|
+
if (moduleMap[name])
|
|
315
|
+
continue;
|
|
316
|
+
const source = moduleMap[spec.source.module];
|
|
317
|
+
const relPath = path_1.default
|
|
318
|
+
.dirname(path_1.default.relative(this.workspacePath, file))
|
|
319
|
+
.replace(/\\/g, '/');
|
|
320
|
+
const baseRequires = spec.requires ?? source?.requires ?? [];
|
|
321
|
+
const version = spec.version ?? source?.version ?? '0.0.1';
|
|
322
|
+
if ((0, reuse_1.isReuseSpec)(spec)) {
|
|
323
|
+
// A reuse proxy expands into two modules that both materialize from
|
|
324
|
+
// this one directory: a shared module (deployed once, reused across
|
|
325
|
+
// tenants) and the per-tenant module that `requires` it. Multiple
|
|
326
|
+
// tenant proxies over the same source/shared/seed config resolve to
|
|
327
|
+
// the same deterministic shared name, so it is added at most once.
|
|
328
|
+
const sharedName = (0, reuse_1.resolveSharedModuleName)(spec);
|
|
329
|
+
if (!moduleMap[sharedName]) {
|
|
330
|
+
moduleMap[sharedName] = { path: relPath, requires: baseRequires, version };
|
|
331
|
+
}
|
|
332
|
+
moduleMap[name] = {
|
|
333
|
+
path: relPath,
|
|
334
|
+
requires: [...baseRequires, sharedName],
|
|
335
|
+
version
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
moduleMap[name] = { path: relPath, requires: baseRequires, version };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return moduleMap;
|
|
289
343
|
}
|
|
290
344
|
getModuleMap() {
|
|
291
345
|
if (!this.workspacePath)
|
|
@@ -1235,6 +1289,38 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1235
1289
|
external: external
|
|
1236
1290
|
};
|
|
1237
1291
|
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Dependency closure for a named module. Proxy (apply-spec) modules have no
|
|
1294
|
+
* pgpm.plan, so they resolve straight off the workspace module map instead
|
|
1295
|
+
* of instantiating a module-rooted package.
|
|
1296
|
+
*/
|
|
1297
|
+
getModuleExtensionsFor(name) {
|
|
1298
|
+
const modules = this.getModuleMap();
|
|
1299
|
+
if (!modules[name]) {
|
|
1300
|
+
throw types_1.errors.MODULE_NOT_FOUND({ name });
|
|
1301
|
+
}
|
|
1302
|
+
const modulePath = (0, path_1.resolve)(this.workspacePath, modules[name].path);
|
|
1303
|
+
if ((0, apply_spec_1.hasApplySpec)(modulePath)) {
|
|
1304
|
+
return (0, deps_1.resolveExtensionDependencies)(name, modules);
|
|
1305
|
+
}
|
|
1306
|
+
return this.getModuleProject(name).getModuleExtensions();
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Directory the migration engine should run against for a named module:
|
|
1310
|
+
* the module's own directory, or — for proxy (apply-spec) modules — the
|
|
1311
|
+
* materialized transpiled instance.
|
|
1312
|
+
*/
|
|
1313
|
+
async getEffectiveModulePathFor(name) {
|
|
1314
|
+
const modules = this.getModuleMap();
|
|
1315
|
+
if (!modules[name]) {
|
|
1316
|
+
throw types_1.errors.MODULE_NOT_FOUND({ name });
|
|
1317
|
+
}
|
|
1318
|
+
const modulePath = (0, path_1.resolve)(this.workspacePath, modules[name].path);
|
|
1319
|
+
if ((0, apply_spec_1.hasApplySpec)(modulePath)) {
|
|
1320
|
+
return (0, materialize_1.resolveEffectiveModulePath)(name, modulePath, modules, this.workspacePath);
|
|
1321
|
+
}
|
|
1322
|
+
return this.getModuleProject(name).getModulePath();
|
|
1323
|
+
}
|
|
1238
1324
|
parsePackageTarget(target) {
|
|
1239
1325
|
let name;
|
|
1240
1326
|
let toChange;
|
|
@@ -1266,12 +1352,6 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1266
1352
|
const log = new logger_1.Logger('deploy');
|
|
1267
1353
|
const { name, toChange } = this.parsePackageTarget(target);
|
|
1268
1354
|
if (recursive) {
|
|
1269
|
-
// Cache for fast deployment
|
|
1270
|
-
const deployFastCache = {};
|
|
1271
|
-
const getCacheKey = (pg, name, database) => {
|
|
1272
|
-
const { host, port, user } = pg ?? {};
|
|
1273
|
-
return `${host}:${port}:${user}:${database}:${name}`;
|
|
1274
|
-
};
|
|
1275
1355
|
const modules = this.getModuleMap();
|
|
1276
1356
|
let extensions;
|
|
1277
1357
|
if (name === null) {
|
|
@@ -1279,8 +1359,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1279
1359
|
extensions = await this.resolveWorkspaceExtensionDependencies();
|
|
1280
1360
|
}
|
|
1281
1361
|
else {
|
|
1282
|
-
|
|
1283
|
-
extensions = moduleProject.getModuleExtensions();
|
|
1362
|
+
extensions = this.getModuleExtensionsFor(name);
|
|
1284
1363
|
}
|
|
1285
1364
|
const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
|
|
1286
1365
|
const targetDescription = name === null ? 'all modules' : name;
|
|
@@ -1293,52 +1372,24 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1293
1372
|
await pgPool.query(msg);
|
|
1294
1373
|
}
|
|
1295
1374
|
else {
|
|
1296
|
-
const modulePath = (0, path_1.resolve)(this.workspacePath, modules[extension].path);
|
|
1375
|
+
const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
|
|
1297
1376
|
log.info(`📂 Deploying local module: ${extension}`);
|
|
1298
|
-
if (opts.deployment.fast) {
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1377
|
+
if (opts.deployment.fast || opts.deployment.bundled) {
|
|
1378
|
+
// Fast strategy: one-shot execution plus a bulk-written
|
|
1379
|
+
// pgpm_migrate ledger, from the module's verified bundle artifact
|
|
1380
|
+
// when it has one and from `deploy/` when it does not.
|
|
1381
|
+
const outcome = await (0, deploy_bundled_1.deployModuleFast)(modulePath, {
|
|
1382
|
+
config: opts.pg,
|
|
1383
|
+
logOnly: opts.deployment.logOnly,
|
|
1384
|
+
useTransaction: opts.deployment.useTx,
|
|
1385
|
+
hashMethod: opts.deployment.hashMethod
|
|
1386
|
+
});
|
|
1387
|
+
if ((0, deploy_bundled_1.isBundledDeployResult)(outcome)) {
|
|
1304
1388
|
continue;
|
|
1305
1389
|
}
|
|
1306
|
-
|
|
1307
|
-
try {
|
|
1308
|
-
pkg = await (0, package_1.packageModule)(localProject.modulePath, {
|
|
1309
|
-
usePlan: opts.deployment.usePlan,
|
|
1310
|
-
extension: false
|
|
1311
|
-
});
|
|
1312
|
-
}
|
|
1313
|
-
catch (err) {
|
|
1314
|
-
const errorLines = [];
|
|
1315
|
-
errorLines.push(`❌ Failed to package module "${extension}" at path: ${modulePath}`);
|
|
1316
|
-
errorLines.push(` Module Path: ${modulePath}`);
|
|
1317
|
-
errorLines.push(` Workspace Path: ${this.workspacePath}`);
|
|
1318
|
-
errorLines.push(` Error Code: ${err.code || 'N/A'}`);
|
|
1319
|
-
errorLines.push(` Error Message: ${err.message || 'Unknown error'}`);
|
|
1320
|
-
if (err.code === 'ENOENT') {
|
|
1321
|
-
errorLines.push('💡 Hint: File or directory not found. Check if the module path is correct.');
|
|
1322
|
-
}
|
|
1323
|
-
else if (err.code === 'EACCES') {
|
|
1324
|
-
errorLines.push('💡 Hint: Permission denied. Check file permissions.');
|
|
1325
|
-
}
|
|
1326
|
-
else if (err.message && err.message.includes('pgpm.plan')) {
|
|
1327
|
-
errorLines.push('💡 Hint: pgpm.plan file issue. Check if the plan file exists and is valid.');
|
|
1328
|
-
}
|
|
1329
|
-
log.error(errorLines.join('\n'));
|
|
1330
|
-
console.error(err);
|
|
1331
|
-
throw types_1.errors.DEPLOYMENT_FAILED({
|
|
1332
|
-
type: 'Deployment',
|
|
1333
|
-
module: extension
|
|
1334
|
-
});
|
|
1335
|
-
}
|
|
1336
|
-
await pgPool.query(pkg.sql);
|
|
1337
|
-
if (opts.deployment.cache) {
|
|
1338
|
-
deployFastCache[cacheKey] = pkg;
|
|
1339
|
-
}
|
|
1390
|
+
log.info(`↩️ Fast deploy unavailable for ${extension} (${outcome}); using per-change path.`);
|
|
1340
1391
|
}
|
|
1341
|
-
|
|
1392
|
+
{
|
|
1342
1393
|
try {
|
|
1343
1394
|
const client = new client_1.PgpmMigrate(opts.pg);
|
|
1344
1395
|
// Only apply toChange to the target module, not its dependencies
|
|
@@ -1374,8 +1425,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1374
1425
|
if (name === null) {
|
|
1375
1426
|
throw types_1.errors.WORKSPACE_OPERATION_ERROR({ operation: 'deployment' });
|
|
1376
1427
|
}
|
|
1377
|
-
const
|
|
1378
|
-
const modulePath = moduleProject.getModulePath();
|
|
1428
|
+
const modulePath = await this.getEffectiveModulePathFor(name);
|
|
1379
1429
|
if (!modulePath) {
|
|
1380
1430
|
throw types_1.errors.PATH_NOT_FOUND({ path: name, type: 'module' });
|
|
1381
1431
|
}
|
|
@@ -1442,7 +1492,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1442
1492
|
}
|
|
1443
1493
|
}
|
|
1444
1494
|
else {
|
|
1445
|
-
const modulePath = (0, path_1.resolve)(this.workspacePath, modules[extension].path);
|
|
1495
|
+
const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
|
|
1446
1496
|
log.info(`📂 Reverting local module: ${extension}`);
|
|
1447
1497
|
try {
|
|
1448
1498
|
const client = new client_1.PgpmMigrate(opts.pg);
|
|
@@ -1475,8 +1525,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1475
1525
|
if (name === null) {
|
|
1476
1526
|
throw types_1.errors.WORKSPACE_OPERATION_ERROR({ operation: 'revert' });
|
|
1477
1527
|
}
|
|
1478
|
-
const
|
|
1479
|
-
const modulePath = moduleProject.getModulePath();
|
|
1528
|
+
const modulePath = await this.getEffectiveModulePathFor(name);
|
|
1480
1529
|
if (!modulePath) {
|
|
1481
1530
|
throw types_1.errors.PATH_NOT_FOUND({ path: name, type: 'module' });
|
|
1482
1531
|
}
|
|
@@ -1503,8 +1552,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1503
1552
|
extensions = await this.resolveWorkspaceExtensionDependencies();
|
|
1504
1553
|
}
|
|
1505
1554
|
else {
|
|
1506
|
-
|
|
1507
|
-
extensions = moduleProject.getModuleExtensions();
|
|
1555
|
+
extensions = this.getModuleExtensionsFor(name);
|
|
1508
1556
|
}
|
|
1509
1557
|
const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
|
|
1510
1558
|
const targetDescription = name === null ? 'all modules' : name;
|
|
@@ -1517,7 +1565,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1517
1565
|
await pgPool.query(query, [extension]);
|
|
1518
1566
|
}
|
|
1519
1567
|
else {
|
|
1520
|
-
const modulePath = (0, path_1.resolve)(this.workspacePath, modules[extension].path);
|
|
1568
|
+
const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
|
|
1521
1569
|
log.info(`📂 Verifying local module: ${extension}`);
|
|
1522
1570
|
try {
|
|
1523
1571
|
const client = new client_1.PgpmMigrate(opts.pg);
|
|
@@ -1549,8 +1597,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1549
1597
|
if (name === null) {
|
|
1550
1598
|
throw types_1.errors.WORKSPACE_OPERATION_ERROR({ operation: 'verification' });
|
|
1551
1599
|
}
|
|
1552
|
-
const
|
|
1553
|
-
const modulePath = moduleProject.getModulePath();
|
|
1600
|
+
const modulePath = await this.getEffectiveModulePathFor(name);
|
|
1554
1601
|
if (!modulePath) {
|
|
1555
1602
|
throw types_1.errors.PATH_NOT_FOUND({ path: name, type: 'module' });
|
|
1556
1603
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { basename, dirname, join } from 'path';
|
|
3
|
+
import { APPLY_SPEC_FILE } from './types';
|
|
4
|
+
/** Whether a module directory is an apply-spec proxy module. */
|
|
5
|
+
export function hasApplySpec(moduleDir) {
|
|
6
|
+
return existsSync(join(moduleDir, APPLY_SPEC_FILE));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Read, validate, and normalize a proxy module's `pgpm.apply.json`.
|
|
10
|
+
*
|
|
11
|
+
* Normalizations: `source` is expanded to its object form and `name` defaults
|
|
12
|
+
* to the proxy directory's name.
|
|
13
|
+
*
|
|
14
|
+
* @throws when the file is missing, unparsable, or structurally invalid.
|
|
15
|
+
*/
|
|
16
|
+
export function readApplySpec(moduleDir) {
|
|
17
|
+
const specPath = join(moduleDir, APPLY_SPEC_FILE);
|
|
18
|
+
if (!existsSync(specPath)) {
|
|
19
|
+
throw new Error(`No ${APPLY_SPEC_FILE} found in ${moduleDir}`);
|
|
20
|
+
}
|
|
21
|
+
return parseApplySpec(readFileSync(specPath, 'utf-8'), specPath);
|
|
22
|
+
}
|
|
23
|
+
/** In-memory core of {@link readApplySpec}. */
|
|
24
|
+
export function parseApplySpec(content, specPath) {
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(content);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
throw new Error(`Failed to parse ${specPath}: ${err instanceof Error ? err.message : err}`);
|
|
31
|
+
}
|
|
32
|
+
const source = typeof parsed?.source === 'string' ? { module: parsed.source } : parsed?.source;
|
|
33
|
+
if (!source || typeof source.module !== 'string' || !source.module) {
|
|
34
|
+
throw new Error(`${specPath}: "source" (source module name, or { "module": ... }) is required`);
|
|
35
|
+
}
|
|
36
|
+
const isSchemaMap = (value) => typeof value === 'object' &&
|
|
37
|
+
value !== null &&
|
|
38
|
+
!Array.isArray(value) &&
|
|
39
|
+
Object.keys(value).length > 0 &&
|
|
40
|
+
Object.entries(value).every(([from, to]) => typeof from === 'string' && typeof to === 'string' && !!from && !!to);
|
|
41
|
+
const hasSchemas = parsed.schemas !== undefined;
|
|
42
|
+
if (hasSchemas && !isSchemaMap(parsed.schemas)) {
|
|
43
|
+
throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`);
|
|
44
|
+
}
|
|
45
|
+
const ROUTE_KINDS = ['table', 'view', 'function', 'procedure', 'type'];
|
|
46
|
+
const hasRoute = parsed.route !== undefined;
|
|
47
|
+
if (hasRoute) {
|
|
48
|
+
if (!Array.isArray(parsed.route) || parsed.route.length === 0) {
|
|
49
|
+
throw new Error(`${specPath}: "route" must be a non-empty array of route entries`);
|
|
50
|
+
}
|
|
51
|
+
for (const entry of parsed.route) {
|
|
52
|
+
if (!entry ||
|
|
53
|
+
typeof entry !== 'object' ||
|
|
54
|
+
typeof entry.fromSchema !== 'string' ||
|
|
55
|
+
!entry.fromSchema ||
|
|
56
|
+
typeof entry.name !== 'string' ||
|
|
57
|
+
!entry.name ||
|
|
58
|
+
typeof entry.toSchema !== 'string' ||
|
|
59
|
+
!entry.toSchema ||
|
|
60
|
+
!ROUTE_KINDS.includes(entry.kind)) {
|
|
61
|
+
throw new Error(`${specPath}: each "route" entry needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name, toSchema } as non-empty strings`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const hasReuse = parsed.reuse !== undefined;
|
|
66
|
+
if (hasReuse) {
|
|
67
|
+
const reuse = parsed.reuse;
|
|
68
|
+
if (typeof reuse !== 'object' || reuse === null || Array.isArray(reuse)) {
|
|
69
|
+
throw new Error(`${specPath}: "reuse" must be an object`);
|
|
70
|
+
}
|
|
71
|
+
if (!isSchemaMap(reuse.sharedSchema)) {
|
|
72
|
+
throw new Error(`${specPath}: "reuse.sharedSchema" must be a non-empty string → string map`);
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(reuse.perTenant) || reuse.perTenant.length === 0) {
|
|
75
|
+
throw new Error(`${specPath}: "reuse.perTenant" must be a non-empty array of seed objects`);
|
|
76
|
+
}
|
|
77
|
+
for (const seed of reuse.perTenant) {
|
|
78
|
+
if (!seed ||
|
|
79
|
+
typeof seed !== 'object' ||
|
|
80
|
+
typeof seed.fromSchema !== 'string' ||
|
|
81
|
+
!seed.fromSchema ||
|
|
82
|
+
typeof seed.name !== 'string' ||
|
|
83
|
+
!seed.name ||
|
|
84
|
+
!ROUTE_KINDS.includes(seed.kind)) {
|
|
85
|
+
throw new Error(`${specPath}: each "reuse.perTenant" seed needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name } as non-empty strings`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (reuse.sharedName !== undefined && (typeof reuse.sharedName !== 'string' || !reuse.sharedName)) {
|
|
89
|
+
throw new Error(`${specPath}: "reuse.sharedName" must be a non-empty string`);
|
|
90
|
+
}
|
|
91
|
+
if (!hasSchemas) {
|
|
92
|
+
throw new Error(`${specPath}: "reuse" requires "schemas" (the per-tenant source → target schema map)`);
|
|
93
|
+
}
|
|
94
|
+
for (const seed of reuse.perTenant) {
|
|
95
|
+
if (!(seed.fromSchema in parsed.schemas)) {
|
|
96
|
+
throw new Error(`${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no per-tenant ` +
|
|
97
|
+
`target in "schemas"`);
|
|
98
|
+
}
|
|
99
|
+
if (!(seed.fromSchema in reuse.sharedSchema)) {
|
|
100
|
+
throw new Error(`${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no shared ` +
|
|
101
|
+
`target in "reuse.sharedSchema"`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const hasExtensions = parsed.extensions !== undefined;
|
|
106
|
+
if (hasExtensions) {
|
|
107
|
+
const ext = parsed.extensions;
|
|
108
|
+
if (typeof ext !== 'object' || ext === null || Array.isArray(ext)) {
|
|
109
|
+
throw new Error(`${specPath}: "extensions" must be an object`);
|
|
110
|
+
}
|
|
111
|
+
const isSchemaOrNull = (v) => v === null || (typeof v === 'string' && !!v);
|
|
112
|
+
const hasRoutes = ext.routes !== undefined;
|
|
113
|
+
if (hasRoutes) {
|
|
114
|
+
if (typeof ext.routes !== 'object' || ext.routes === null || Array.isArray(ext.routes)) {
|
|
115
|
+
throw new Error(`${specPath}: "extensions.routes" must be an object keyed by extension name`);
|
|
116
|
+
}
|
|
117
|
+
for (const [name, route] of Object.entries(ext.routes)) {
|
|
118
|
+
if (!route || typeof route !== 'object' || !isSchemaOrNull(route.to)) {
|
|
119
|
+
throw new Error(`${specPath}: "extensions.routes.${name}" must be { to: schema | null, from?: (string|null)[] }`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (!('toSchema' in ext)) {
|
|
124
|
+
throw new Error(`${specPath}: "extensions" needs "toSchema" (schema | null) or "routes"`);
|
|
125
|
+
}
|
|
126
|
+
else if (!isSchemaOrNull(ext.toSchema)) {
|
|
127
|
+
throw new Error(`${specPath}: "extensions.toSchema" must be a non-empty string or null`);
|
|
128
|
+
}
|
|
129
|
+
if (ext.only !== undefined && (!Array.isArray(ext.only) || ext.only.some((e) => typeof e !== 'string' || !e))) {
|
|
130
|
+
throw new Error(`${specPath}: "extensions.only" must be an array of extension names`);
|
|
131
|
+
}
|
|
132
|
+
if (ext.serverVersion !== undefined && typeof ext.serverVersion !== 'number') {
|
|
133
|
+
throw new Error(`${specPath}: "extensions.serverVersion" must be a number`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const hasRoles = parsed.roles !== undefined;
|
|
137
|
+
if (hasRoles && !isSchemaMap(parsed.roles)) {
|
|
138
|
+
throw new Error(`${specPath}: "roles" must be a non-empty string → string map`);
|
|
139
|
+
}
|
|
140
|
+
if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles) {
|
|
141
|
+
throw new Error(`${specPath}: at least one of "schemas", "route", "extensions", or "roles" is required`);
|
|
142
|
+
}
|
|
143
|
+
const name = parsed.name ?? basename(dirname(specPath));
|
|
144
|
+
if (typeof name !== 'string' || !name) {
|
|
145
|
+
throw new Error(`${specPath}: "name" must be a non-empty string`);
|
|
146
|
+
}
|
|
147
|
+
if (name === source.module) {
|
|
148
|
+
throw new Error(`${specPath}: apply module "${name}" cannot apply itself`);
|
|
149
|
+
}
|
|
150
|
+
const requires = parsed.requires;
|
|
151
|
+
if (requires !== undefined &&
|
|
152
|
+
(!Array.isArray(requires) || requires.some((r) => typeof r !== 'string' || !r))) {
|
|
153
|
+
throw new Error(`${specPath}: "requires" must be an array of module names`);
|
|
154
|
+
}
|
|
155
|
+
if (requires?.includes(source.module)) {
|
|
156
|
+
throw new Error(`${specPath}: "requires" must not include the source module "${source.module}" — ` +
|
|
157
|
+
`it is source material, not a runtime dependency`);
|
|
158
|
+
}
|
|
159
|
+
return { ...parsed, name, source, requires };
|
|
160
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mkdtempSync } from 'fs';
|
|
2
|
+
import { tmpdir } from 'os';
|
|
3
|
+
import { join, resolve } from 'path';
|
|
4
|
+
import { bundleFromModule, materializeBundle, transpileBundle, verifyBundle } from '@pgpmjs/bundle';
|
|
5
|
+
import { loadModule, makeSchemaTranspiler } from '@pgpmjs/transform';
|
|
6
|
+
import { hasApplySpec, readApplySpec } from './apply-spec';
|
|
7
|
+
import { isReuseSpec, materializeReuseModule, resolveSharedModuleName } from './reuse';
|
|
8
|
+
// Functions that take a bare schema name as a string-literal first argument.
|
|
9
|
+
// The AST pass only remaps dotted literals ('schema.object'); these helpers
|
|
10
|
+
// (ubiquitous in verify scripts) name the schema alone, so a string-level
|
|
11
|
+
// pre-pass remaps them.
|
|
12
|
+
const SCHEMA_NAME_LITERAL_FUNCS = ['verify_schema', 'has_schema_privilege'];
|
|
13
|
+
const schemaNameLiteralPass = (content, schemaMapping) => {
|
|
14
|
+
const pattern = new RegExp(`\\b(${SCHEMA_NAME_LITERAL_FUNCS.join('|')})\\s*\\(\\s*'([^']+)'`, 'gi');
|
|
15
|
+
return content.replace(pattern, (match, _fn, schema) => {
|
|
16
|
+
const mapped = schemaMapping.get(schema);
|
|
17
|
+
return mapped ? match.replace(`'${schema}'`, `'${mapped}'`) : match;
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Transpile a source module per an apply spec and materialize the result as a
|
|
22
|
+
* deployable module directory carrying the instance's identity.
|
|
23
|
+
*
|
|
24
|
+
* Pure pipeline over existing primitives: `bundleFromModule` →
|
|
25
|
+
* `transpileBundle(makeSchemaTranspiler)` → `verifyBundle` →
|
|
26
|
+
* `materializeBundle`. Deterministic — identical source + spec always produce
|
|
27
|
+
* an identical bundle (and digest), which is what makes verify/revert able to
|
|
28
|
+
* re-derive the exact deployed artifact.
|
|
29
|
+
*/
|
|
30
|
+
export async function materializeApplyModule(options) {
|
|
31
|
+
const { sourceDir, spec } = options;
|
|
32
|
+
const instanceName = spec.name;
|
|
33
|
+
// the schema transpiler's SQL/PLpgSQL parser is WASM-backed and needs a
|
|
34
|
+
// one-time async init (idempotent)
|
|
35
|
+
await loadModule();
|
|
36
|
+
const source = bundleFromModule(sourceDir);
|
|
37
|
+
if (spec.source.bundleDigest && spec.source.bundleDigest !== source.manifest.digest) {
|
|
38
|
+
throw new Error(`Apply spec for "${instanceName}" pins source bundle digest ${spec.source.bundleDigest}, ` +
|
|
39
|
+
`but the installed source "${spec.source.module}" hashes to ${source.manifest.digest}. ` +
|
|
40
|
+
`Reinstall the pinned version or update the spec.`);
|
|
41
|
+
}
|
|
42
|
+
// Every distinct target schema is treated as "may already exist": apply
|
|
43
|
+
// deploys into consumer-chosen namespaces that can pre-exist (a shared schema,
|
|
44
|
+
// another tenant), so `CREATE SCHEMA` is emitted idempotently. This is what
|
|
45
|
+
// lets the same source apply cleanly whether or not the destination exists.
|
|
46
|
+
const targetSchemas = [
|
|
47
|
+
...new Set([
|
|
48
|
+
...Object.values(spec.schemas ?? {}),
|
|
49
|
+
...(spec.route ?? []).map(r => r.toSchema)
|
|
50
|
+
])
|
|
51
|
+
];
|
|
52
|
+
const { renameChange, transformScript, result } = makeSchemaTranspiler({
|
|
53
|
+
schemaMap: spec.schemas,
|
|
54
|
+
routes: spec.route,
|
|
55
|
+
extensions: spec.extensions,
|
|
56
|
+
roles: spec.roles,
|
|
57
|
+
transform: {
|
|
58
|
+
prePasses: [schemaNameLiteralPass],
|
|
59
|
+
assumeSchemasExist: targetSchemas
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
const transpiled = transpileBundle(source, {
|
|
63
|
+
renameChange,
|
|
64
|
+
transformScript,
|
|
65
|
+
renameModule: instanceName,
|
|
66
|
+
provenance: {
|
|
67
|
+
appliedFrom: spec.source.module,
|
|
68
|
+
...(spec.source.package ? { sourcePackage: spec.source.package } : {}),
|
|
69
|
+
...(spec.source.version ? { sourceVersion: spec.source.version } : {})
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
if (result.errors.length > 0) {
|
|
73
|
+
const detail = result.errors.map(e => `${e.file}: ${e.error}`).join('; ');
|
|
74
|
+
throw new Error(`Apply transpile of "${spec.source.module}" as "${instanceName}" failed: ${detail}`);
|
|
75
|
+
}
|
|
76
|
+
const issues = verifyBundle(transpiled);
|
|
77
|
+
if (issues.length > 0) {
|
|
78
|
+
throw new Error(`Transpiled bundle for "${instanceName}" failed integrity verification: ` +
|
|
79
|
+
issues.map(i => i.kind).join(', '));
|
|
80
|
+
}
|
|
81
|
+
const outDir = options.outDir ?? mkdtempSync(join(tmpdir(), `pgpm-apply-${instanceName}-`));
|
|
82
|
+
materializeBundle(transpiled, outDir);
|
|
83
|
+
return { bundle: transpiled, outDir };
|
|
84
|
+
}
|
|
85
|
+
// One materialization per proxy path per process: the pipeline is
|
|
86
|
+
// deterministic, so re-deriving within a process is pure waste.
|
|
87
|
+
const materializedCache = new Map();
|
|
88
|
+
/**
|
|
89
|
+
* Deploy-path dispatch seam: given a resolved module's directory, return the
|
|
90
|
+
* directory the migration engine should actually run against.
|
|
91
|
+
*
|
|
92
|
+
* Non-proxy modules pass through unchanged. For proxy modules (directories
|
|
93
|
+
* carrying a `pgpm.apply.json`), the source module is located in the workspace
|
|
94
|
+
* module map, transpiled per the spec, and materialized to a cached temp dir
|
|
95
|
+
* carrying the instance's identity.
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveEffectiveModulePath(moduleName, modulePath, moduleMap, workspacePath) {
|
|
98
|
+
if (!hasApplySpec(modulePath))
|
|
99
|
+
return modulePath;
|
|
100
|
+
// A reuse proxy resolves under two names (shared + per-tenant) from one
|
|
101
|
+
// directory, so the cache is keyed by the requested module name too.
|
|
102
|
+
const cacheKey = `${modulePath}::${moduleName}`;
|
|
103
|
+
const cached = materializedCache.get(cacheKey);
|
|
104
|
+
if (cached)
|
|
105
|
+
return cached;
|
|
106
|
+
const spec = readApplySpec(modulePath);
|
|
107
|
+
const sourceModule = moduleMap[spec.source.module];
|
|
108
|
+
if (!sourceModule) {
|
|
109
|
+
throw new Error(`Apply spec in "${moduleName}" references source module "${spec.source.module}", ` +
|
|
110
|
+
`which was not found in the workspace. Run \`pgpm install\` to install it` +
|
|
111
|
+
(spec.source.package ? ` (${spec.source.package})` : '') +
|
|
112
|
+
`.`);
|
|
113
|
+
}
|
|
114
|
+
const sourceDir = resolve(workspacePath, sourceModule.path);
|
|
115
|
+
if (isReuseSpec(spec)) {
|
|
116
|
+
const result = await materializeReuseModule({ sourceDir, spec });
|
|
117
|
+
materializedCache.set(`${modulePath}::${result.sharedName}`, result.shared.outDir);
|
|
118
|
+
materializedCache.set(`${modulePath}::${result.perTenantName}`, result.perTenant.outDir);
|
|
119
|
+
const outDir = moduleName === resolveSharedModuleName(spec) ? result.shared.outDir : result.perTenant.outDir;
|
|
120
|
+
return outDir;
|
|
121
|
+
}
|
|
122
|
+
const { outDir } = await materializeApplyModule({ sourceDir, spec });
|
|
123
|
+
materializedCache.set(cacheKey, outDir);
|
|
124
|
+
return outDir;
|
|
125
|
+
}
|
|
126
|
+
/** Test seam: clear the per-process materialization cache. */
|
|
127
|
+
export function clearApplyMaterializationCache() {
|
|
128
|
+
materializedCache.clear();
|
|
129
|
+
}
|