@pgpmjs/core 7.14.0 â 7.16.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.js +84 -9
- package/apply/index.d.ts +1 -0
- package/apply/index.js +1 -0
- package/apply/materialize.js +15 -2
- package/apply/reuse.d.ts +52 -0
- package/apply/reuse.js +226 -0
- package/apply/types.d.ts +89 -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.js +54 -57
- package/esm/apply/apply-spec.js +84 -9
- package/esm/apply/index.js +1 -0
- package/esm/apply/materialize.js +15 -2
- package/esm/apply/reuse.js +221 -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 +54 -57
- package/esm/extensions/compile.js +109 -0
- package/esm/extensions/index.js +4 -0
- package/esm/extensions/manifest.js +139 -0
- package/esm/extensions/resolve-install.js +70 -0
- package/esm/index.js +1 -1
- package/esm/migrate/clean.js +1 -2
- package/esm/packaging/package.js +8 -1
- package/esm/projects/deploy.js +36 -58
- package/extensions/compile.d.ts +35 -0
- package/extensions/compile.js +113 -0
- package/extensions/index.d.ts +4 -0
- package/extensions/index.js +20 -0
- package/extensions/manifest.d.ts +78 -0
- package/extensions/manifest.js +144 -0
- package/extensions/resolve-install.d.ts +20 -0
- package/extensions/resolve-install.js +74 -0
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/migrate/clean.js +1 -2
- package/package.json +10 -10
- package/packaging/package.d.ts +7 -1
- package/packaging/package.js +8 -1
- package/projects/deploy.js +36 -58
package/esm/core/class/pgpm.js
CHANGED
|
@@ -11,6 +11,7 @@ import { getPgPool } from 'pg-cache';
|
|
|
11
11
|
import yanse from 'yanse';
|
|
12
12
|
import { resolveEffectiveModulePath } from '../../apply/materialize';
|
|
13
13
|
import { hasApplySpec, readApplySpec } from '../../apply/apply-spec';
|
|
14
|
+
import { isReuseSpec, resolveSharedModuleName } from '../../apply/reuse';
|
|
14
15
|
import { APPLY_SPEC_FILE } from '../../apply/types';
|
|
15
16
|
import { getAvailableExtensions } from '../../extensions/extensions';
|
|
16
17
|
import { generatePlan, writePlan, writePlanFile } from '@pgpmjs/ast/files';
|
|
@@ -21,8 +22,9 @@ import { parsePlanFile } from '@pgpmjs/ast/files/plan/parser';
|
|
|
21
22
|
import { isValidChangeName, isValidTagName, parseReference } from '@pgpmjs/ast/files/plan/validators';
|
|
22
23
|
import { PgpmMigrate } from '../../migrate/client';
|
|
23
24
|
import { getExtensionsAndModules, getExtensionsAndModulesChanges, latestChange, latestChangeAndVersion } from '../../modules/modules';
|
|
24
|
-
import {
|
|
25
|
+
import { deployModuleFast, isBundledDeployResult } from '../../bundle/deploy-bundled';
|
|
25
26
|
import { syncModuleVersions } from '../../packaging/sync-versions';
|
|
27
|
+
import { findExtensionInstall, roleMapFromRoles } from '../../extensions/resolve-install';
|
|
26
28
|
import { resolveDependencies, resolveExtensionDependencies } from '../../resolution/deps';
|
|
27
29
|
import { movePath } from '../../utils/fs';
|
|
28
30
|
import { globPaths, globPattern, toPosixPath } from '../../utils/glob';
|
|
@@ -274,13 +276,30 @@ export class PgpmPackage {
|
|
|
274
276
|
if (moduleMap[name])
|
|
275
277
|
continue;
|
|
276
278
|
const source = moduleMap[spec.source.module];
|
|
277
|
-
|
|
278
|
-
path
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
const relPath = path
|
|
280
|
+
.dirname(path.relative(this.workspacePath, file))
|
|
281
|
+
.replace(/\\/g, '/');
|
|
282
|
+
const baseRequires = spec.requires ?? source?.requires ?? [];
|
|
283
|
+
const version = spec.version ?? source?.version ?? '0.0.1';
|
|
284
|
+
if (isReuseSpec(spec)) {
|
|
285
|
+
// A reuse proxy expands into two modules that both materialize from
|
|
286
|
+
// this one directory: a shared module (deployed once, reused across
|
|
287
|
+
// tenants) and the per-tenant module that `requires` it. Multiple
|
|
288
|
+
// tenant proxies over the same source/shared/seed config resolve to
|
|
289
|
+
// the same deterministic shared name, so it is added at most once.
|
|
290
|
+
const sharedName = resolveSharedModuleName(spec);
|
|
291
|
+
if (!moduleMap[sharedName]) {
|
|
292
|
+
moduleMap[sharedName] = { path: relPath, requires: baseRequires, version };
|
|
293
|
+
}
|
|
294
|
+
moduleMap[name] = {
|
|
295
|
+
path: relPath,
|
|
296
|
+
requires: [...baseRequires, sharedName],
|
|
297
|
+
version
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
moduleMap[name] = { path: relPath, requires: baseRequires, version };
|
|
302
|
+
}
|
|
284
303
|
}
|
|
285
304
|
return moduleMap;
|
|
286
305
|
}
|
|
@@ -1295,12 +1314,6 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1295
1314
|
const log = new Logger('deploy');
|
|
1296
1315
|
const { name, toChange } = this.parsePackageTarget(target);
|
|
1297
1316
|
if (recursive) {
|
|
1298
|
-
// Cache for fast deployment
|
|
1299
|
-
const deployFastCache = {};
|
|
1300
|
-
const getCacheKey = (pg, name, database) => {
|
|
1301
|
-
const { host, port, user } = pg ?? {};
|
|
1302
|
-
return `${host}:${port}:${user}:${database}:${name}`;
|
|
1303
|
-
};
|
|
1304
1317
|
const modules = this.getModuleMap();
|
|
1305
1318
|
let extensions;
|
|
1306
1319
|
if (name === null) {
|
|
@@ -1311,61 +1324,45 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
|
|
|
1311
1324
|
extensions = this.getModuleExtensionsFor(name);
|
|
1312
1325
|
}
|
|
1313
1326
|
const pgPool = getPgPool(opts.pg);
|
|
1327
|
+
const localModules = extensions.resolved.filter((m) => !extensions.external.includes(m));
|
|
1328
|
+
const roleMap = roleMapFromRoles(opts.db?.roles);
|
|
1314
1329
|
const targetDescription = name === null ? 'all modules' : name;
|
|
1315
1330
|
log.success(`đ Starting deployment to database ${opts.pg.database}...`);
|
|
1316
1331
|
for (const extension of extensions.resolved) {
|
|
1317
1332
|
try {
|
|
1318
1333
|
if (extensions.external.includes(extension)) {
|
|
1319
|
-
const
|
|
1320
|
-
|
|
1321
|
-
|
|
1334
|
+
const install = findExtensionInstall(extension, localModules, modules, this.workspacePath, { roleMap });
|
|
1335
|
+
if (install) {
|
|
1336
|
+
log.info(`đĨ Installing external extension (declarative): ${extension}`);
|
|
1337
|
+
for (const stmt of install.deploy) {
|
|
1338
|
+
await pgPool.query(stmt);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
else {
|
|
1342
|
+
const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
|
|
1343
|
+
log.info(`đĨ Installing external extension: ${extension}`);
|
|
1344
|
+
await pgPool.query(msg);
|
|
1345
|
+
}
|
|
1322
1346
|
}
|
|
1323
1347
|
else {
|
|
1324
1348
|
const modulePath = await resolveEffectiveModulePath(extension, resolve(this.workspacePath, modules[extension].path), modules, this.workspacePath);
|
|
1325
1349
|
log.info(`đ Deploying local module: ${extension}`);
|
|
1326
|
-
if (opts.deployment.fast) {
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1350
|
+
if (opts.deployment.fast || opts.deployment.bundled) {
|
|
1351
|
+
// Fast strategy: one-shot execution plus a bulk-written
|
|
1352
|
+
// pgpm_migrate ledger, from the module's verified bundle artifact
|
|
1353
|
+
// when it has one and from `deploy/` when it does not.
|
|
1354
|
+
const outcome = await deployModuleFast(modulePath, {
|
|
1355
|
+
config: opts.pg,
|
|
1356
|
+
logOnly: opts.deployment.logOnly,
|
|
1357
|
+
useTransaction: opts.deployment.useTx,
|
|
1358
|
+
hashMethod: opts.deployment.hashMethod
|
|
1359
|
+
});
|
|
1360
|
+
if (isBundledDeployResult(outcome)) {
|
|
1331
1361
|
continue;
|
|
1332
1362
|
}
|
|
1333
|
-
|
|
1334
|
-
try {
|
|
1335
|
-
pkg = await packageModule(modulePath, {
|
|
1336
|
-
usePlan: opts.deployment.usePlan,
|
|
1337
|
-
extension: false
|
|
1338
|
-
});
|
|
1339
|
-
}
|
|
1340
|
-
catch (err) {
|
|
1341
|
-
const errorLines = [];
|
|
1342
|
-
errorLines.push(`â Failed to package module "${extension}" at path: ${modulePath}`);
|
|
1343
|
-
errorLines.push(` Module Path: ${modulePath}`);
|
|
1344
|
-
errorLines.push(` Workspace Path: ${this.workspacePath}`);
|
|
1345
|
-
errorLines.push(` Error Code: ${err.code || 'N/A'}`);
|
|
1346
|
-
errorLines.push(` Error Message: ${err.message || 'Unknown error'}`);
|
|
1347
|
-
if (err.code === 'ENOENT') {
|
|
1348
|
-
errorLines.push('đĄ Hint: File or directory not found. Check if the module path is correct.');
|
|
1349
|
-
}
|
|
1350
|
-
else if (err.code === 'EACCES') {
|
|
1351
|
-
errorLines.push('đĄ Hint: Permission denied. Check file permissions.');
|
|
1352
|
-
}
|
|
1353
|
-
else if (err.message && err.message.includes('pgpm.plan')) {
|
|
1354
|
-
errorLines.push('đĄ Hint: pgpm.plan file issue. Check if the plan file exists and is valid.');
|
|
1355
|
-
}
|
|
1356
|
-
log.error(errorLines.join('\n'));
|
|
1357
|
-
console.error(err);
|
|
1358
|
-
throw errors.DEPLOYMENT_FAILED({
|
|
1359
|
-
type: 'Deployment',
|
|
1360
|
-
module: extension
|
|
1361
|
-
});
|
|
1362
|
-
}
|
|
1363
|
-
await pgPool.query(pkg.sql);
|
|
1364
|
-
if (opts.deployment.cache) {
|
|
1365
|
-
deployFastCache[cacheKey] = pkg;
|
|
1366
|
-
}
|
|
1363
|
+
log.info(`âŠī¸ Fast deploy unavailable for ${extension} (${outcome}); using per-change path.`);
|
|
1367
1364
|
}
|
|
1368
|
-
|
|
1365
|
+
{
|
|
1369
1366
|
try {
|
|
1370
1367
|
const client = new PgpmMigrate(opts.pg);
|
|
1371
1368
|
// Only apply toChange to the target module, not its dependencies
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const SPECIAL_ROLES = new Set(['public', 'current_user', 'session_user']);
|
|
2
|
+
const quoteIdent = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
3
|
+
const routeRole = (role, roleMap) => {
|
|
4
|
+
if (!roleMap)
|
|
5
|
+
return role;
|
|
6
|
+
if (SPECIAL_ROLES.has(role.toLowerCase()))
|
|
7
|
+
return role;
|
|
8
|
+
return roleMap[role] ?? role;
|
|
9
|
+
};
|
|
10
|
+
const grantObjectClause = (on, schema) => {
|
|
11
|
+
const s = quoteIdent(schema);
|
|
12
|
+
switch (on) {
|
|
13
|
+
case 'schema':
|
|
14
|
+
return `SCHEMA ${s}`;
|
|
15
|
+
case 'all-tables':
|
|
16
|
+
return `ALL TABLES IN SCHEMA ${s}`;
|
|
17
|
+
case 'all-sequences':
|
|
18
|
+
return `ALL SEQUENCES IN SCHEMA ${s}`;
|
|
19
|
+
case 'all-functions':
|
|
20
|
+
return `ALL FUNCTIONS IN SCHEMA ${s}`;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const grantSql = (verb, grant, schema, roleMap) => {
|
|
24
|
+
const roles = (Array.isArray(grant.to) ? grant.to : [grant.to])
|
|
25
|
+
.map((r) => routeRole(r, roleMap))
|
|
26
|
+
.join(', ');
|
|
27
|
+
const obj = grantObjectClause(grant.on, schema);
|
|
28
|
+
return verb === 'GRANT'
|
|
29
|
+
? `GRANT ${grant.privileges} ON ${obj} TO ${roles};`
|
|
30
|
+
: `REVOKE ${grant.privileges} ON ${obj} FROM ${roles};`;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Compile a single `provides` declaration into deploy / revert / verify SQL.
|
|
34
|
+
*
|
|
35
|
+
* Pure and deterministic: given the same input it returns byte-identical SQL,
|
|
36
|
+
* with no dynamic `EXECUTE`. This is the nuts-and-bolts primitive; wiring it
|
|
37
|
+
* into the deploy path (and eventually into real plan changes exempt from
|
|
38
|
+
* `filterStatements` stripping) composes on top of it.
|
|
39
|
+
*/
|
|
40
|
+
export function compileExtensionInstall(extname, provide, options = {}) {
|
|
41
|
+
const { roleMap, fixedSchema } = options;
|
|
42
|
+
const schema = provide.schema === undefined ? null : provide.schema;
|
|
43
|
+
const relocatable = provide.relocatable ?? true;
|
|
44
|
+
const ifNotExists = provide.ifNotExists ?? true;
|
|
45
|
+
const cascade = provide.cascade ?? false;
|
|
46
|
+
const createSchema = provide.createSchema ?? schema !== null;
|
|
47
|
+
const dropSchema = provide.dropSchema ?? false;
|
|
48
|
+
const grants = provide.grants ?? [];
|
|
49
|
+
// A fixed-schema extension can only be placed at its own fixed schema.
|
|
50
|
+
if (!relocatable && schema !== null && fixedSchema != null && schema !== fixedSchema) {
|
|
51
|
+
throw new Error(`Extension "${extname}" is non-relocatable (fixed schema "${fixedSchema}") and cannot be installed into "${schema}".`);
|
|
52
|
+
}
|
|
53
|
+
if (grants.length > 0 && schema === null) {
|
|
54
|
+
throw new Error(`Extension "${extname}" declares grants but no schema; schema-qualified grants require an install schema.`);
|
|
55
|
+
}
|
|
56
|
+
const ext = quoteIdent(extname);
|
|
57
|
+
const deploy = [];
|
|
58
|
+
const revert = [];
|
|
59
|
+
const verify = [];
|
|
60
|
+
if (schema !== null && createSchema) {
|
|
61
|
+
deploy.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)};`);
|
|
62
|
+
}
|
|
63
|
+
const createParts = ['CREATE EXTENSION'];
|
|
64
|
+
if (ifNotExists)
|
|
65
|
+
createParts.push('IF NOT EXISTS');
|
|
66
|
+
createParts.push(ext);
|
|
67
|
+
if (schema !== null)
|
|
68
|
+
createParts.push(`WITH SCHEMA ${quoteIdent(schema)}`);
|
|
69
|
+
if (cascade)
|
|
70
|
+
createParts.push('CASCADE');
|
|
71
|
+
deploy.push(`${createParts.join(' ')};`);
|
|
72
|
+
for (const g of grants) {
|
|
73
|
+
deploy.push(grantSql('GRANT', g, schema, roleMap));
|
|
74
|
+
}
|
|
75
|
+
// Revert: drop grants (best-effort), then extension, then optionally schema.
|
|
76
|
+
for (const g of [...grants].reverse()) {
|
|
77
|
+
revert.push(grantSql('REVOKE', g, schema, roleMap));
|
|
78
|
+
}
|
|
79
|
+
revert.push(`DROP EXTENSION IF EXISTS ${ext};`);
|
|
80
|
+
if (schema !== null && createSchema && dropSchema) {
|
|
81
|
+
revert.push(`DROP SCHEMA IF EXISTS ${quoteIdent(schema)};`);
|
|
82
|
+
}
|
|
83
|
+
// Verify: assert the extension exists in the expected namespace.
|
|
84
|
+
if (schema !== null) {
|
|
85
|
+
verify.push([
|
|
86
|
+
'SELECT 1 / count(*)::int AS ok',
|
|
87
|
+
'FROM pg_catalog.pg_extension e',
|
|
88
|
+
'JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace',
|
|
89
|
+
`WHERE e.extname = ${quoteLiteral(extname)} AND n.nspname = ${quoteLiteral(schema)};`
|
|
90
|
+
].join('\n'));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
verify.push([
|
|
94
|
+
'SELECT 1 / count(*)::int AS ok',
|
|
95
|
+
'FROM pg_catalog.pg_extension e',
|
|
96
|
+
`WHERE e.extname = ${quoteLiteral(extname)};`
|
|
97
|
+
].join('\n'));
|
|
98
|
+
}
|
|
99
|
+
return { deploy, revert, verify };
|
|
100
|
+
}
|
|
101
|
+
const quoteLiteral = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
102
|
+
/** Compile every `provides` entry in a manifest, keyed by extension name. */
|
|
103
|
+
export function compileExtensionInstalls(provides, options = {}) {
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [extname, provide] of Object.entries(provides)) {
|
|
106
|
+
out[extname] = compileExtensionInstall(extname, provide, options);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Declarative extension manifest (`extensions.json`) that lives next to a
|
|
5
|
+
* module's `pgpm.plan` / `.control`. It makes a module self-describing about
|
|
6
|
+
* the PostgreSQL extensions it relates to:
|
|
7
|
+
*
|
|
8
|
+
* - `provides` â the module *is* the extension wrapper: it installs the
|
|
9
|
+
* extension into a chosen schema (with grants). This is what replaces the
|
|
10
|
+
* dynamic `DO $$ EXECUTE 'CREATE EXTENSION ... SCHEMA x' $$` hack, since a
|
|
11
|
+
* literal `CREATE EXTENSION` is stripped at package time.
|
|
12
|
+
* - `consumes` â the module merely *uses* symbols from an extension installed
|
|
13
|
+
* elsewhere (e.g. a bare `crypt()` call) and needs them qualified to wherever
|
|
14
|
+
* the extension actually landed. This is the transform/routing side.
|
|
15
|
+
*
|
|
16
|
+
* `.control` `requires` still names the extension (dependency ordering is
|
|
17
|
+
* unchanged); this manifest only adds the *where + grants* that `.control`
|
|
18
|
+
* cannot express.
|
|
19
|
+
*/
|
|
20
|
+
/** Candidate file names, in precedence order. */
|
|
21
|
+
export const EXTENSIONS_MANIFEST_FILES = ['pgpm.extensions.json', 'extensions.json'];
|
|
22
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
23
|
+
const GRANT_TARGETS = new Set([
|
|
24
|
+
'schema',
|
|
25
|
+
'all-tables',
|
|
26
|
+
'all-sequences',
|
|
27
|
+
'all-functions'
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Validate + normalize a parsed manifest object. Throws with a precise message
|
|
31
|
+
* on malformed input so authors get actionable errors rather than silent
|
|
32
|
+
* misconfiguration.
|
|
33
|
+
*/
|
|
34
|
+
export function validateExtensionsManifest(raw, source = 'extensions.json') {
|
|
35
|
+
if (!isPlainObject(raw)) {
|
|
36
|
+
throw new Error(`${source}: manifest must be a JSON object`);
|
|
37
|
+
}
|
|
38
|
+
const manifest = {};
|
|
39
|
+
if (raw.provides !== undefined) {
|
|
40
|
+
if (!isPlainObject(raw.provides)) {
|
|
41
|
+
throw new Error(`${source}: "provides" must be an object keyed by extension name`);
|
|
42
|
+
}
|
|
43
|
+
const provides = {};
|
|
44
|
+
for (const [extname, value] of Object.entries(raw.provides)) {
|
|
45
|
+
provides[extname] = validateProvide(extname, value, source);
|
|
46
|
+
}
|
|
47
|
+
manifest.provides = provides;
|
|
48
|
+
}
|
|
49
|
+
if (raw.consumes !== undefined) {
|
|
50
|
+
if (!isPlainObject(raw.consumes)) {
|
|
51
|
+
throw new Error(`${source}: "consumes" must be an object keyed by extension name`);
|
|
52
|
+
}
|
|
53
|
+
const consumes = {};
|
|
54
|
+
for (const [extname, value] of Object.entries(raw.consumes)) {
|
|
55
|
+
if (!isPlainObject(value)) {
|
|
56
|
+
throw new Error(`${source}: consumes."${extname}" must be an object`);
|
|
57
|
+
}
|
|
58
|
+
const entry = {};
|
|
59
|
+
if (value.symbols !== undefined) {
|
|
60
|
+
if (!Array.isArray(value.symbols) || value.symbols.some((s) => typeof s !== 'string' || !s.trim())) {
|
|
61
|
+
throw new Error(`${source}: consumes."${extname}".symbols must be an array of non-empty strings`);
|
|
62
|
+
}
|
|
63
|
+
entry.symbols = value.symbols;
|
|
64
|
+
}
|
|
65
|
+
consumes[extname] = entry;
|
|
66
|
+
}
|
|
67
|
+
manifest.consumes = consumes;
|
|
68
|
+
}
|
|
69
|
+
return manifest;
|
|
70
|
+
}
|
|
71
|
+
function validateProvide(extname, value, source) {
|
|
72
|
+
if (!isPlainObject(value)) {
|
|
73
|
+
throw new Error(`${source}: provides."${extname}" must be an object`);
|
|
74
|
+
}
|
|
75
|
+
const provide = {};
|
|
76
|
+
if (value.schema !== undefined) {
|
|
77
|
+
if (value.schema !== null && (typeof value.schema !== 'string' || !value.schema.trim())) {
|
|
78
|
+
throw new Error(`${source}: provides."${extname}".schema must be a non-empty string or null`);
|
|
79
|
+
}
|
|
80
|
+
provide.schema = value.schema;
|
|
81
|
+
}
|
|
82
|
+
for (const flag of ['relocatable', 'ifNotExists', 'cascade', 'createSchema', 'dropSchema']) {
|
|
83
|
+
if (value[flag] !== undefined) {
|
|
84
|
+
if (typeof value[flag] !== 'boolean') {
|
|
85
|
+
throw new Error(`${source}: provides."${extname}".${flag} must be a boolean`);
|
|
86
|
+
}
|
|
87
|
+
provide[flag] = value[flag];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (value.grants !== undefined) {
|
|
91
|
+
if (!Array.isArray(value.grants)) {
|
|
92
|
+
throw new Error(`${source}: provides."${extname}".grants must be an array`);
|
|
93
|
+
}
|
|
94
|
+
provide.grants = value.grants.map((g, i) => validateGrant(extname, g, i, source));
|
|
95
|
+
}
|
|
96
|
+
return provide;
|
|
97
|
+
}
|
|
98
|
+
function validateGrant(extname, value, index, source) {
|
|
99
|
+
const where = `provides."${extname}".grants[${index}]`;
|
|
100
|
+
if (!isPlainObject(value)) {
|
|
101
|
+
throw new Error(`${source}: ${where} must be an object`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof value.privileges !== 'string' || !value.privileges.trim()) {
|
|
104
|
+
throw new Error(`${source}: ${where}.privileges must be a non-empty string`);
|
|
105
|
+
}
|
|
106
|
+
if (typeof value.on !== 'string' || !GRANT_TARGETS.has(value.on)) {
|
|
107
|
+
throw new Error(`${source}: ${where}.on must be one of ${[...GRANT_TARGETS].join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
const toValid = (typeof value.to === 'string' && value.to.trim()) ||
|
|
110
|
+
(Array.isArray(value.to) && value.to.length > 0 && value.to.every((r) => typeof r === 'string' && r.trim()));
|
|
111
|
+
if (!toValid) {
|
|
112
|
+
throw new Error(`${source}: ${where}.to must be a role name or non-empty array of role names`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
privileges: value.privileges,
|
|
116
|
+
on: value.on,
|
|
117
|
+
to: value.to
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Read + validate the `extensions.json` (or `pgpm.extensions.json`) manifest for
|
|
122
|
+
* a module directory. Returns `undefined` when the module declares no manifest.
|
|
123
|
+
*/
|
|
124
|
+
export function readExtensionsManifest(moduleDir) {
|
|
125
|
+
for (const file of EXTENSIONS_MANIFEST_FILES) {
|
|
126
|
+
const p = join(moduleDir, file);
|
|
127
|
+
if (existsSync(p)) {
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(readFileSync(p, 'utf-8'));
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
throw new Error(`Failed to parse ${file} at ${moduleDir}: ${e.message}`);
|
|
134
|
+
}
|
|
135
|
+
return validateExtensionsManifest(parsed, file);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import { compileExtensionInstall } from './compile';
|
|
3
|
+
import { readExtensionsManifest } from './manifest';
|
|
4
|
+
/**
|
|
5
|
+
* Build a grant role-name map (workspace portability profile) from the
|
|
6
|
+
* configured {@link RoleMapping}. Keys are the conceptual role names a module
|
|
7
|
+
* author writes in its manifest (`anonymous`/`authenticated`/`administrator`);
|
|
8
|
+
* values are the actual role names for this database.
|
|
9
|
+
*/
|
|
10
|
+
export function roleMapFromRoles(roles) {
|
|
11
|
+
if (!roles)
|
|
12
|
+
return undefined;
|
|
13
|
+
const map = {};
|
|
14
|
+
for (const key of ['anonymous', 'authenticated', 'administrator', 'authenticatedClient']) {
|
|
15
|
+
const value = roles[key];
|
|
16
|
+
if (value)
|
|
17
|
+
map[key] = value;
|
|
18
|
+
}
|
|
19
|
+
return Object.keys(map).length > 0 ? map : undefined;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Find a declarative install for an external extension by scanning the resolved
|
|
23
|
+
* local modules for an `extensions.json` that `provides` it, and compiling it.
|
|
24
|
+
*
|
|
25
|
+
* Returns `undefined` when no module declares the extension â the caller then
|
|
26
|
+
* falls back to the legacy `CREATE EXTENSION ... CASCADE` (no schema, no grants).
|
|
27
|
+
* The first declaring module wins; a later contradictory declaration throws.
|
|
28
|
+
*/
|
|
29
|
+
export function findExtensionInstall(extname, localModules, modules, workspacePath, options = {}) {
|
|
30
|
+
let winner;
|
|
31
|
+
for (const moduleName of localModules) {
|
|
32
|
+
const mod = modules[moduleName];
|
|
33
|
+
if (!mod)
|
|
34
|
+
continue;
|
|
35
|
+
const manifest = readExtensionsManifest(resolve(workspacePath, mod.path));
|
|
36
|
+
const provide = manifest?.provides?.[extname];
|
|
37
|
+
if (!provide)
|
|
38
|
+
continue;
|
|
39
|
+
if (winner && !sameProvide(winner.provide, provide)) {
|
|
40
|
+
throw new Error(`Conflicting extensions.json "provides" for "${extname}": ` +
|
|
41
|
+
`module "${winner.moduleName}" and module "${moduleName}" declare incompatible ` +
|
|
42
|
+
`install schema/grants. Exactly one provider (or identical declarations) is required.`);
|
|
43
|
+
}
|
|
44
|
+
if (!winner)
|
|
45
|
+
winner = { moduleName, provide };
|
|
46
|
+
}
|
|
47
|
+
return winner ? compileExtensionInstall(extname, winner.provide, options) : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Structural equality of two provide declarations (order-insensitive grants). */
|
|
50
|
+
function sameProvide(a, b) {
|
|
51
|
+
return normalizeProvide(a) === normalizeProvide(b);
|
|
52
|
+
}
|
|
53
|
+
function normalizeProvide(p) {
|
|
54
|
+
const grants = (p.grants ?? [])
|
|
55
|
+
.map(g => ({
|
|
56
|
+
privileges: g.privileges,
|
|
57
|
+
on: g.on,
|
|
58
|
+
to: (Array.isArray(g.to) ? [...g.to] : [g.to]).sort()
|
|
59
|
+
}))
|
|
60
|
+
.sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
|
|
61
|
+
return JSON.stringify({
|
|
62
|
+
schema: p.schema ?? null,
|
|
63
|
+
relocatable: p.relocatable ?? null,
|
|
64
|
+
ifNotExists: p.ifNotExists ?? null,
|
|
65
|
+
cascade: p.cascade ?? null,
|
|
66
|
+
createSchema: p.createSchema ?? null,
|
|
67
|
+
dropSchema: p.dropSchema ?? null,
|
|
68
|
+
grants
|
|
69
|
+
});
|
|
70
|
+
}
|
package/esm/index.js
CHANGED
package/esm/migrate/clean.js
CHANGED
|
@@ -2,8 +2,7 @@ import { deparse, parse } from 'pgsql-parser';
|
|
|
2
2
|
const filterStatements = (stmts) => {
|
|
3
3
|
const filteredStmts = stmts.filter(node => {
|
|
4
4
|
const stmt = node.stmt;
|
|
5
|
-
return stmt && !stmt.hasOwnProperty('TransactionStmt')
|
|
6
|
-
!stmt.hasOwnProperty('CreateExtensionStmt');
|
|
5
|
+
return stmt && !stmt.hasOwnProperty('TransactionStmt');
|
|
7
6
|
});
|
|
8
7
|
const hasFiltered = filteredStmts.length !== stmts.length;
|
|
9
8
|
return { filteredStmts, hasFiltered };
|
package/esm/packaging/package.js
CHANGED
|
@@ -4,6 +4,7 @@ import { relative } from 'path';
|
|
|
4
4
|
import { deparse } from 'pgsql-deparser';
|
|
5
5
|
import { parse } from 'pgsql-parser';
|
|
6
6
|
import { getExtensionName } from '@pgpmjs/ast/files';
|
|
7
|
+
import { writeBundleArtifact } from '../bundle/artifact';
|
|
7
8
|
import { resolve, resolveWithPlan } from '../resolution/resolve';
|
|
8
9
|
import { transformProps } from './transform';
|
|
9
10
|
const log = new Logger('package');
|
|
@@ -78,7 +79,7 @@ export const packageModule = async (packageDir, { usePlan = true, extension = tr
|
|
|
78
79
|
throw e;
|
|
79
80
|
}
|
|
80
81
|
};
|
|
81
|
-
export const writePackage = async ({ version, extension = true, usePlan = true, packageDir, outputDiff = false, }) => {
|
|
82
|
+
export const writePackage = async ({ version, extension = true, usePlan = true, packageDir, outputDiff = false, bundle = true, }) => {
|
|
82
83
|
const pkgPath = `${packageDir}/package.json`;
|
|
83
84
|
const pkg = require(pkgPath);
|
|
84
85
|
const extname = await getExtensionName(packageDir);
|
|
@@ -115,4 +116,10 @@ export const writePackage = async ({ version, extension = true, usePlan = true,
|
|
|
115
116
|
const writePath = `${outPath}/${sqlFileName}`;
|
|
116
117
|
writeFileSync(writePath, sql);
|
|
117
118
|
log.success(`${relative(packageDir, writePath)} written`);
|
|
119
|
+
if (bundle) {
|
|
120
|
+
const bundlePath = await writeBundleArtifact(packageDir, version, {
|
|
121
|
+
createdWith: '@pgpmjs/core'
|
|
122
|
+
});
|
|
123
|
+
log.success(`${relative(packageDir, bundlePath)} written`);
|
|
124
|
+
}
|
|
118
125
|
};
|
package/esm/projects/deploy.js
CHANGED
|
@@ -6,16 +6,11 @@ import * as path from 'path';
|
|
|
6
6
|
import { getPgPool } from 'pg-cache';
|
|
7
7
|
import { resolveEffectiveModulePath } from '../apply/materialize';
|
|
8
8
|
import { hasApplySpec } from '../apply/apply-spec';
|
|
9
|
+
import { deployModuleFast, isBundledDeployResult } from '../bundle/deploy-bundled';
|
|
9
10
|
import { PgpmPackage } from '../core/class/pgpm';
|
|
11
|
+
import { findExtensionInstall, roleMapFromRoles } from '../extensions';
|
|
10
12
|
import { PgpmMigrate } from '../migrate/client';
|
|
11
|
-
import { packageModule } from '../packaging/package';
|
|
12
13
|
import { resolveExtensionDependencies } from '../resolution/deps';
|
|
13
|
-
// Cache for fast deployment
|
|
14
|
-
const deployFastCache = {};
|
|
15
|
-
const getCacheKey = (pg, name, database) => {
|
|
16
|
-
const { host, port, user } = pg ?? {};
|
|
17
|
-
return `${host}:${port}:${user}:${database}:${name}`;
|
|
18
|
-
};
|
|
19
14
|
const log = new Logger('deploy');
|
|
20
15
|
export const deployProject = async (opts, name, database, pkg, toChange) => {
|
|
21
16
|
const mergedOpts = getEnvOptions(opts);
|
|
@@ -33,14 +28,28 @@ export const deployProject = async (opts, name, database, pkg, toChange) => {
|
|
|
33
28
|
? resolveExtensionDependencies(name, modules)
|
|
34
29
|
: new PgpmPackage(modulePath, { extensionsDir: pkg.extensionsDir }).getModuleExtensions();
|
|
35
30
|
const pgPool = getPgPool({ ...opts.pg, database });
|
|
31
|
+
// Local modules (in resolve order) that may carry an `extensions.json`
|
|
32
|
+
// declaring how an external extension should be installed (schema + grants).
|
|
33
|
+
const localModules = extensions.resolved.filter((m) => !extensions.external.includes(m));
|
|
34
|
+
const roleMap = roleMapFromRoles(mergedOpts.db?.roles);
|
|
36
35
|
log.success(`đ Starting deployment to database ${database}...`);
|
|
37
36
|
for (const extension of extensions.resolved) {
|
|
38
37
|
try {
|
|
39
38
|
if (extensions.external.includes(extension)) {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
const install = findExtensionInstall(extension, localModules, modules, pkg.workspacePath, { roleMap });
|
|
40
|
+
if (install) {
|
|
41
|
+
log.info(`đĨ Installing external extension (declarative): ${extension}`);
|
|
42
|
+
for (const stmt of install.deploy) {
|
|
43
|
+
log.debug(`> ${stmt}`);
|
|
44
|
+
await pgPool.query(stmt);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
|
|
49
|
+
log.info(`đĨ Installing external extension: ${extension}`);
|
|
50
|
+
log.debug(`> ${msg}`);
|
|
51
|
+
await pgPool.query(msg);
|
|
52
|
+
}
|
|
44
53
|
}
|
|
45
54
|
else {
|
|
46
55
|
const sourcePath = resolve(pkg.workspacePath, modules[extension].path);
|
|
@@ -50,56 +59,25 @@ export const deployProject = async (opts, name, database, pkg, toChange) => {
|
|
|
50
59
|
if (modulePath !== sourcePath) {
|
|
51
60
|
log.info(`đ Applying transpiled module (spec in ${sourcePath})`);
|
|
52
61
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
// The fast strategy is opt-in; everything else uses the per-change path.
|
|
63
|
+
if (mergedOpts.deployment.fast || mergedOpts.deployment.bundled) {
|
|
64
|
+
// Fast strategy: execute the module in one shot AND record the
|
|
65
|
+
// migration ledger, from the pre-built bundle artifact when one
|
|
66
|
+
// verifies and from `deploy/` otherwise. If the semantics cannot be
|
|
67
|
+
// honoured, fall through to the per-change migration path rather than
|
|
68
|
+
// deploying without a ledger.
|
|
69
|
+
const outcome = await deployModuleFast(modulePath, {
|
|
70
|
+
config: { ...mergedOpts.pg, database },
|
|
71
|
+
logOnly: mergedOpts.deployment.logOnly,
|
|
72
|
+
useTransaction: mergedOpts.deployment.useTx,
|
|
73
|
+
hashMethod: mergedOpts.deployment.hashMethod
|
|
74
|
+
});
|
|
75
|
+
if (isBundledDeployResult(outcome)) {
|
|
60
76
|
continue;
|
|
61
77
|
}
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
modulePackage = await packageModule(localProject.modulePath, {
|
|
65
|
-
usePlan: mergedOpts.deployment.usePlan,
|
|
66
|
-
extension: false
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
// Build comprehensive error message
|
|
71
|
-
const errorLines = [];
|
|
72
|
-
errorLines.push(`â Failed to package module "${extension}" at path: ${modulePath}`);
|
|
73
|
-
errorLines.push(` Module Path: ${modulePath}`);
|
|
74
|
-
errorLines.push(` Workspace Path: ${pkg.workspacePath}`);
|
|
75
|
-
errorLines.push(` Error Code: ${err.code || 'N/A'}`);
|
|
76
|
-
errorLines.push(` Error Message: ${err.message || 'Unknown error'}`);
|
|
77
|
-
// Provide debugging hints
|
|
78
|
-
if (err.code === 'ENOENT') {
|
|
79
|
-
errorLines.push('đĄ Hint: File or directory not found. Check if the module path is correct.');
|
|
80
|
-
}
|
|
81
|
-
else if (err.code === 'EACCES') {
|
|
82
|
-
errorLines.push('đĄ Hint: Permission denied. Check file permissions.');
|
|
83
|
-
}
|
|
84
|
-
else if (err.message && err.message.includes('pgpm.plan')) {
|
|
85
|
-
errorLines.push('đĄ Hint: pgpm.plan file issue. Check if the plan file exists and is valid.');
|
|
86
|
-
}
|
|
87
|
-
// Log the consolidated error message
|
|
88
|
-
log.error(errorLines.join('\n'));
|
|
89
|
-
console.error(err); // Preserve full stack trace
|
|
90
|
-
throw errors.DEPLOYMENT_FAILED({
|
|
91
|
-
type: 'Deployment',
|
|
92
|
-
module: extension
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
log.debug(`â Command: sqitch deploy db:pg:${database}`);
|
|
96
|
-
log.debug(`> ${modulePackage.sql}`);
|
|
97
|
-
await pgPool.query(modulePackage.sql);
|
|
98
|
-
if (mergedOpts.deployment.cache) {
|
|
99
|
-
deployFastCache[cacheKey] = modulePackage;
|
|
100
|
-
}
|
|
78
|
+
log.info(`âŠī¸ Fast deploy unavailable for ${extension} (${outcome}); using per-change path.`);
|
|
101
79
|
}
|
|
102
|
-
|
|
80
|
+
{
|
|
103
81
|
// Use new migration system
|
|
104
82
|
log.debug(`â Command: constructive migrate deploy db:pg:${database}`);
|
|
105
83
|
try {
|