@pgpmjs/core 7.15.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.
@@ -15,8 +15,8 @@ export declare function bundleArtifactFileName(name: string, version: string): s
15
15
  export declare function resolveBundleArtifactPath(moduleDir: string): string | null;
16
16
  /**
17
17
  * Build the executable form of a module's bundle: the content-addressed AST
18
- * snapshot plus, per change, the deploy SQL with transaction / `CREATE
19
- * EXTENSION` statements stripped (`cleanSql`).
18
+ * snapshot plus, per change, the deploy SQL with transaction-control
19
+ * statements stripped (`cleanSql`).
20
20
  *
21
21
  * That pre-computation is the whole point of the artifact — the parse/deparse
22
22
  * work moves from every deploy to the one-time package step, while the digests
@@ -52,8 +52,8 @@ function resolveBundleArtifactPath(moduleDir) {
52
52
  }
53
53
  /**
54
54
  * Build the executable form of a module's bundle: the content-addressed AST
55
- * snapshot plus, per change, the deploy SQL with transaction / `CREATE
56
- * EXTENSION` statements stripped (`cleanSql`).
55
+ * snapshot plus, per change, the deploy SQL with transaction-control
56
+ * statements stripped (`cleanSql`).
57
57
  *
58
58
  * That pre-computation is the whole point of the artifact — the parse/deparse
59
59
  * work moves from every deploy to the one-time package step, while the digests
@@ -63,6 +63,7 @@ const client_1 = require("../../migrate/client");
63
63
  const modules_1 = require("../../modules/modules");
64
64
  const deploy_bundled_1 = require("../../bundle/deploy-bundled");
65
65
  const sync_versions_1 = require("../../packaging/sync-versions");
66
+ const resolve_install_1 = require("../../extensions/resolve-install");
66
67
  const deps_1 = require("../../resolution/deps");
67
68
  const fs_2 = require("../../utils/fs");
68
69
  const glob_1 = require("../../utils/glob");
@@ -1362,14 +1363,25 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1362
1363
  extensions = this.getModuleExtensionsFor(name);
1363
1364
  }
1364
1365
  const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
1366
+ const localModules = extensions.resolved.filter((m) => !extensions.external.includes(m));
1367
+ const roleMap = (0, resolve_install_1.roleMapFromRoles)(opts.db?.roles);
1365
1368
  const targetDescription = name === null ? 'all modules' : name;
1366
1369
  log.success(`🚀 Starting deployment to database ${opts.pg.database}...`);
1367
1370
  for (const extension of extensions.resolved) {
1368
1371
  try {
1369
1372
  if (extensions.external.includes(extension)) {
1370
- const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
1371
- log.info(`📥 Installing external extension: ${extension}`);
1372
- await pgPool.query(msg);
1373
+ const install = (0, resolve_install_1.findExtensionInstall)(extension, localModules, modules, this.workspacePath, { roleMap });
1374
+ if (install) {
1375
+ log.info(`📥 Installing external extension (declarative): ${extension}`);
1376
+ for (const stmt of install.deploy) {
1377
+ await pgPool.query(stmt);
1378
+ }
1379
+ }
1380
+ else {
1381
+ const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
1382
+ log.info(`📥 Installing external extension: ${extension}`);
1383
+ await pgPool.query(msg);
1384
+ }
1373
1385
  }
1374
1386
  else {
1375
1387
  const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
@@ -45,8 +45,8 @@ export function resolveBundleArtifactPath(moduleDir) {
45
45
  }
46
46
  /**
47
47
  * Build the executable form of a module's bundle: the content-addressed AST
48
- * snapshot plus, per change, the deploy SQL with transaction / `CREATE
49
- * EXTENSION` statements stripped (`cleanSql`).
48
+ * snapshot plus, per change, the deploy SQL with transaction-control
49
+ * statements stripped (`cleanSql`).
50
50
  *
51
51
  * That pre-computation is the whole point of the artifact — the parse/deparse
52
52
  * work moves from every deploy to the one-time package step, while the digests
@@ -24,6 +24,7 @@ import { PgpmMigrate } from '../../migrate/client';
24
24
  import { getExtensionsAndModules, getExtensionsAndModulesChanges, latestChange, latestChangeAndVersion } from '../../modules/modules';
25
25
  import { deployModuleFast, isBundledDeployResult } from '../../bundle/deploy-bundled';
26
26
  import { syncModuleVersions } from '../../packaging/sync-versions';
27
+ import { findExtensionInstall, roleMapFromRoles } from '../../extensions/resolve-install';
27
28
  import { resolveDependencies, resolveExtensionDependencies } from '../../resolution/deps';
28
29
  import { movePath } from '../../utils/fs';
29
30
  import { globPaths, globPattern, toPosixPath } from '../../utils/glob';
@@ -1323,14 +1324,25 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1323
1324
  extensions = this.getModuleExtensionsFor(name);
1324
1325
  }
1325
1326
  const pgPool = getPgPool(opts.pg);
1327
+ const localModules = extensions.resolved.filter((m) => !extensions.external.includes(m));
1328
+ const roleMap = roleMapFromRoles(opts.db?.roles);
1326
1329
  const targetDescription = name === null ? 'all modules' : name;
1327
1330
  log.success(`🚀 Starting deployment to database ${opts.pg.database}...`);
1328
1331
  for (const extension of extensions.resolved) {
1329
1332
  try {
1330
1333
  if (extensions.external.includes(extension)) {
1331
- const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
1332
- log.info(`📥 Installing external extension: ${extension}`);
1333
- await pgPool.query(msg);
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
+ }
1334
1346
  }
1335
1347
  else {
1336
1348
  const modulePath = await resolveEffectiveModulePath(extension, resolve(this.workspacePath, modules[extension].path), modules, this.workspacePath);
@@ -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,4 @@
1
+ export * from './compile';
2
+ export * from './extensions';
3
+ export * from './manifest';
4
+ export * from './resolve-install';
@@ -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
@@ -1,6 +1,6 @@
1
1
  export * from './core/class/pgpm';
2
2
  export * from './rebundle';
3
- export * from './extensions/extensions';
3
+ export * from './extensions';
4
4
  export * from './modules/modules';
5
5
  export * from './packaging/package';
6
6
  export * from './packaging/sync-versions';
@@ -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 };
@@ -8,6 +8,7 @@ import { resolveEffectiveModulePath } from '../apply/materialize';
8
8
  import { hasApplySpec } from '../apply/apply-spec';
9
9
  import { deployModuleFast, isBundledDeployResult } from '../bundle/deploy-bundled';
10
10
  import { PgpmPackage } from '../core/class/pgpm';
11
+ import { findExtensionInstall, roleMapFromRoles } from '../extensions';
11
12
  import { PgpmMigrate } from '../migrate/client';
12
13
  import { resolveExtensionDependencies } from '../resolution/deps';
13
14
  const log = new Logger('deploy');
@@ -27,14 +28,28 @@ export const deployProject = async (opts, name, database, pkg, toChange) => {
27
28
  ? resolveExtensionDependencies(name, modules)
28
29
  : new PgpmPackage(modulePath, { extensionsDir: pkg.extensionsDir }).getModuleExtensions();
29
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);
30
35
  log.success(`🚀 Starting deployment to database ${database}...`);
31
36
  for (const extension of extensions.resolved) {
32
37
  try {
33
38
  if (extensions.external.includes(extension)) {
34
- const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
35
- log.info(`📥 Installing external extension: ${extension}`);
36
- log.debug(`> ${msg}`);
37
- await pgPool.query(msg);
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
+ }
38
53
  }
39
54
  else {
40
55
  const sourcePath = resolve(pkg.workspacePath, modules[extension].path);
@@ -0,0 +1,35 @@
1
+ import { ExtensionProvide } from './manifest';
2
+ /** Result of compiling one `provides` entry into deployable SQL. */
3
+ export interface CompiledExtensionInstall {
4
+ /** Deploy statements, in order (schema, extension, grants). */
5
+ deploy: string[];
6
+ /** Revert statements, in reverse order (drop extension, optionally schema). */
7
+ revert: string[];
8
+ /** Verify statements that error when the install is absent. */
9
+ verify: string[];
10
+ }
11
+ export interface CompileExtensionInstallOptions {
12
+ /**
13
+ * Optional role-name map applied to grant targets (workspace portability
14
+ * profile). Renames identifiers only — never touches privilege semantics.
15
+ * Special roles (`PUBLIC`, `CURRENT_USER`, `SESSION_USER`) pass through.
16
+ */
17
+ roleMap?: Record<string, string>;
18
+ /**
19
+ * Control-file schema for a fixed-schema (non-relocatable) extension, when
20
+ * known. Used to validate that a `false`-relocatable extension is only
21
+ * "routed" to its own fixed schema.
22
+ */
23
+ fixedSchema?: string | null;
24
+ }
25
+ /**
26
+ * Compile a single `provides` declaration into deploy / revert / verify SQL.
27
+ *
28
+ * Pure and deterministic: given the same input it returns byte-identical SQL,
29
+ * with no dynamic `EXECUTE`. This is the nuts-and-bolts primitive; wiring it
30
+ * into the deploy path (and eventually into real plan changes exempt from
31
+ * `filterStatements` stripping) composes on top of it.
32
+ */
33
+ export declare function compileExtensionInstall(extname: string, provide: ExtensionProvide, options?: CompileExtensionInstallOptions): CompiledExtensionInstall;
34
+ /** Compile every `provides` entry in a manifest, keyed by extension name. */
35
+ export declare function compileExtensionInstalls(provides: Record<string, ExtensionProvide>, options?: CompileExtensionInstallOptions): Record<string, CompiledExtensionInstall>;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compileExtensionInstall = compileExtensionInstall;
4
+ exports.compileExtensionInstalls = compileExtensionInstalls;
5
+ const SPECIAL_ROLES = new Set(['public', 'current_user', 'session_user']);
6
+ const quoteIdent = (name) => `"${name.replace(/"/g, '""')}"`;
7
+ const routeRole = (role, roleMap) => {
8
+ if (!roleMap)
9
+ return role;
10
+ if (SPECIAL_ROLES.has(role.toLowerCase()))
11
+ return role;
12
+ return roleMap[role] ?? role;
13
+ };
14
+ const grantObjectClause = (on, schema) => {
15
+ const s = quoteIdent(schema);
16
+ switch (on) {
17
+ case 'schema':
18
+ return `SCHEMA ${s}`;
19
+ case 'all-tables':
20
+ return `ALL TABLES IN SCHEMA ${s}`;
21
+ case 'all-sequences':
22
+ return `ALL SEQUENCES IN SCHEMA ${s}`;
23
+ case 'all-functions':
24
+ return `ALL FUNCTIONS IN SCHEMA ${s}`;
25
+ }
26
+ };
27
+ const grantSql = (verb, grant, schema, roleMap) => {
28
+ const roles = (Array.isArray(grant.to) ? grant.to : [grant.to])
29
+ .map((r) => routeRole(r, roleMap))
30
+ .join(', ');
31
+ const obj = grantObjectClause(grant.on, schema);
32
+ return verb === 'GRANT'
33
+ ? `GRANT ${grant.privileges} ON ${obj} TO ${roles};`
34
+ : `REVOKE ${grant.privileges} ON ${obj} FROM ${roles};`;
35
+ };
36
+ /**
37
+ * Compile a single `provides` declaration into deploy / revert / verify SQL.
38
+ *
39
+ * Pure and deterministic: given the same input it returns byte-identical SQL,
40
+ * with no dynamic `EXECUTE`. This is the nuts-and-bolts primitive; wiring it
41
+ * into the deploy path (and eventually into real plan changes exempt from
42
+ * `filterStatements` stripping) composes on top of it.
43
+ */
44
+ function compileExtensionInstall(extname, provide, options = {}) {
45
+ const { roleMap, fixedSchema } = options;
46
+ const schema = provide.schema === undefined ? null : provide.schema;
47
+ const relocatable = provide.relocatable ?? true;
48
+ const ifNotExists = provide.ifNotExists ?? true;
49
+ const cascade = provide.cascade ?? false;
50
+ const createSchema = provide.createSchema ?? schema !== null;
51
+ const dropSchema = provide.dropSchema ?? false;
52
+ const grants = provide.grants ?? [];
53
+ // A fixed-schema extension can only be placed at its own fixed schema.
54
+ if (!relocatable && schema !== null && fixedSchema != null && schema !== fixedSchema) {
55
+ throw new Error(`Extension "${extname}" is non-relocatable (fixed schema "${fixedSchema}") and cannot be installed into "${schema}".`);
56
+ }
57
+ if (grants.length > 0 && schema === null) {
58
+ throw new Error(`Extension "${extname}" declares grants but no schema; schema-qualified grants require an install schema.`);
59
+ }
60
+ const ext = quoteIdent(extname);
61
+ const deploy = [];
62
+ const revert = [];
63
+ const verify = [];
64
+ if (schema !== null && createSchema) {
65
+ deploy.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)};`);
66
+ }
67
+ const createParts = ['CREATE EXTENSION'];
68
+ if (ifNotExists)
69
+ createParts.push('IF NOT EXISTS');
70
+ createParts.push(ext);
71
+ if (schema !== null)
72
+ createParts.push(`WITH SCHEMA ${quoteIdent(schema)}`);
73
+ if (cascade)
74
+ createParts.push('CASCADE');
75
+ deploy.push(`${createParts.join(' ')};`);
76
+ for (const g of grants) {
77
+ deploy.push(grantSql('GRANT', g, schema, roleMap));
78
+ }
79
+ // Revert: drop grants (best-effort), then extension, then optionally schema.
80
+ for (const g of [...grants].reverse()) {
81
+ revert.push(grantSql('REVOKE', g, schema, roleMap));
82
+ }
83
+ revert.push(`DROP EXTENSION IF EXISTS ${ext};`);
84
+ if (schema !== null && createSchema && dropSchema) {
85
+ revert.push(`DROP SCHEMA IF EXISTS ${quoteIdent(schema)};`);
86
+ }
87
+ // Verify: assert the extension exists in the expected namespace.
88
+ if (schema !== null) {
89
+ verify.push([
90
+ 'SELECT 1 / count(*)::int AS ok',
91
+ 'FROM pg_catalog.pg_extension e',
92
+ 'JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace',
93
+ `WHERE e.extname = ${quoteLiteral(extname)} AND n.nspname = ${quoteLiteral(schema)};`
94
+ ].join('\n'));
95
+ }
96
+ else {
97
+ verify.push([
98
+ 'SELECT 1 / count(*)::int AS ok',
99
+ 'FROM pg_catalog.pg_extension e',
100
+ `WHERE e.extname = ${quoteLiteral(extname)};`
101
+ ].join('\n'));
102
+ }
103
+ return { deploy, revert, verify };
104
+ }
105
+ const quoteLiteral = (s) => `'${s.replace(/'/g, "''")}'`;
106
+ /** Compile every `provides` entry in a manifest, keyed by extension name. */
107
+ function compileExtensionInstalls(provides, options = {}) {
108
+ const out = {};
109
+ for (const [extname, provide] of Object.entries(provides)) {
110
+ out[extname] = compileExtensionInstall(extname, provide, options);
111
+ }
112
+ return out;
113
+ }
@@ -0,0 +1,4 @@
1
+ export * from './compile';
2
+ export * from './extensions';
3
+ export * from './manifest';
4
+ export * from './resolve-install';
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./compile"), exports);
18
+ __exportStar(require("./extensions"), exports);
19
+ __exportStar(require("./manifest"), exports);
20
+ __exportStar(require("./resolve-install"), exports);
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Declarative extension manifest (`extensions.json`) that lives next to a
3
+ * module's `pgpm.plan` / `.control`. It makes a module self-describing about
4
+ * the PostgreSQL extensions it relates to:
5
+ *
6
+ * - `provides` — the module *is* the extension wrapper: it installs the
7
+ * extension into a chosen schema (with grants). This is what replaces the
8
+ * dynamic `DO $$ EXECUTE 'CREATE EXTENSION ... SCHEMA x' $$` hack, since a
9
+ * literal `CREATE EXTENSION` is stripped at package time.
10
+ * - `consumes` — the module merely *uses* symbols from an extension installed
11
+ * elsewhere (e.g. a bare `crypt()` call) and needs them qualified to wherever
12
+ * the extension actually landed. This is the transform/routing side.
13
+ *
14
+ * `.control` `requires` still names the extension (dependency ordering is
15
+ * unchanged); this manifest only adds the *where + grants* that `.control`
16
+ * cannot express.
17
+ */
18
+ /** Candidate file names, in precedence order. */
19
+ export declare const EXTENSIONS_MANIFEST_FILES: readonly ["pgpm.extensions.json", "extensions.json"];
20
+ /**
21
+ * What a declared grant targets. Kept structural (not free-form SQL) so it can
22
+ * be classified, role-routed, and reverted deterministically.
23
+ */
24
+ export type ExtensionGrantTarget = 'schema' | 'all-tables' | 'all-sequences' | 'all-functions';
25
+ export interface ExtensionGrant {
26
+ /** Privilege list, e.g. `USAGE`, `ALL`, `EXECUTE`, `SELECT`. */
27
+ privileges: string;
28
+ /** Object class the grant applies to, resolved against the install schema. */
29
+ on: ExtensionGrantTarget;
30
+ /** Role name(s). Routed through the workspace role map when one is supplied. */
31
+ to: string | string[];
32
+ }
33
+ export interface ExtensionProvide {
34
+ /**
35
+ * Target schema for the install. A string routes the extension there; `null`
36
+ * means default/unqualified (control-file location, typically `public`).
37
+ */
38
+ schema?: string | null;
39
+ /**
40
+ * Author's assertion that the extension is relocatable. Defaults to `true`.
41
+ * A fixed-schema (`false`) extension must not be routed to a schema that
42
+ * differs from its control-file schema — {@link compileExtensionInstall}
43
+ * refuses that combination.
44
+ */
45
+ relocatable?: boolean;
46
+ /** Emit `IF NOT EXISTS` on `CREATE EXTENSION`. Defaults to `true`. */
47
+ ifNotExists?: boolean;
48
+ /** Emit `CASCADE` on `CREATE EXTENSION`. Defaults to `false`. */
49
+ cascade?: boolean;
50
+ /**
51
+ * Create `schema` before installing (idempotent). Defaults to `true` when a
52
+ * schema is given. Set `false` when the schema is owned by another module.
53
+ */
54
+ createSchema?: boolean;
55
+ /** Also `DROP SCHEMA` on revert. Defaults to `false` (only drop the extension). */
56
+ dropSchema?: boolean;
57
+ /** Grants applied after install. */
58
+ grants?: ExtensionGrant[];
59
+ }
60
+ export interface ExtensionConsume {
61
+ /** Bare symbol names this module references (functions/types/operators). */
62
+ symbols?: string[];
63
+ }
64
+ export interface ExtensionsManifest {
65
+ provides?: Record<string, ExtensionProvide>;
66
+ consumes?: Record<string, ExtensionConsume>;
67
+ }
68
+ /**
69
+ * Validate + normalize a parsed manifest object. Throws with a precise message
70
+ * on malformed input so authors get actionable errors rather than silent
71
+ * misconfiguration.
72
+ */
73
+ export declare function validateExtensionsManifest(raw: unknown, source?: string): ExtensionsManifest;
74
+ /**
75
+ * Read + validate the `extensions.json` (or `pgpm.extensions.json`) manifest for
76
+ * a module directory. Returns `undefined` when the module declares no manifest.
77
+ */
78
+ export declare function readExtensionsManifest(moduleDir: string): ExtensionsManifest | undefined;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXTENSIONS_MANIFEST_FILES = void 0;
4
+ exports.validateExtensionsManifest = validateExtensionsManifest;
5
+ exports.readExtensionsManifest = readExtensionsManifest;
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ /**
9
+ * Declarative extension manifest (`extensions.json`) that lives next to a
10
+ * module's `pgpm.plan` / `.control`. It makes a module self-describing about
11
+ * the PostgreSQL extensions it relates to:
12
+ *
13
+ * - `provides` — the module *is* the extension wrapper: it installs the
14
+ * extension into a chosen schema (with grants). This is what replaces the
15
+ * dynamic `DO $$ EXECUTE 'CREATE EXTENSION ... SCHEMA x' $$` hack, since a
16
+ * literal `CREATE EXTENSION` is stripped at package time.
17
+ * - `consumes` — the module merely *uses* symbols from an extension installed
18
+ * elsewhere (e.g. a bare `crypt()` call) and needs them qualified to wherever
19
+ * the extension actually landed. This is the transform/routing side.
20
+ *
21
+ * `.control` `requires` still names the extension (dependency ordering is
22
+ * unchanged); this manifest only adds the *where + grants* that `.control`
23
+ * cannot express.
24
+ */
25
+ /** Candidate file names, in precedence order. */
26
+ exports.EXTENSIONS_MANIFEST_FILES = ['pgpm.extensions.json', 'extensions.json'];
27
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
28
+ const GRANT_TARGETS = new Set([
29
+ 'schema',
30
+ 'all-tables',
31
+ 'all-sequences',
32
+ 'all-functions'
33
+ ]);
34
+ /**
35
+ * Validate + normalize a parsed manifest object. Throws with a precise message
36
+ * on malformed input so authors get actionable errors rather than silent
37
+ * misconfiguration.
38
+ */
39
+ function validateExtensionsManifest(raw, source = 'extensions.json') {
40
+ if (!isPlainObject(raw)) {
41
+ throw new Error(`${source}: manifest must be a JSON object`);
42
+ }
43
+ const manifest = {};
44
+ if (raw.provides !== undefined) {
45
+ if (!isPlainObject(raw.provides)) {
46
+ throw new Error(`${source}: "provides" must be an object keyed by extension name`);
47
+ }
48
+ const provides = {};
49
+ for (const [extname, value] of Object.entries(raw.provides)) {
50
+ provides[extname] = validateProvide(extname, value, source);
51
+ }
52
+ manifest.provides = provides;
53
+ }
54
+ if (raw.consumes !== undefined) {
55
+ if (!isPlainObject(raw.consumes)) {
56
+ throw new Error(`${source}: "consumes" must be an object keyed by extension name`);
57
+ }
58
+ const consumes = {};
59
+ for (const [extname, value] of Object.entries(raw.consumes)) {
60
+ if (!isPlainObject(value)) {
61
+ throw new Error(`${source}: consumes."${extname}" must be an object`);
62
+ }
63
+ const entry = {};
64
+ if (value.symbols !== undefined) {
65
+ if (!Array.isArray(value.symbols) || value.symbols.some((s) => typeof s !== 'string' || !s.trim())) {
66
+ throw new Error(`${source}: consumes."${extname}".symbols must be an array of non-empty strings`);
67
+ }
68
+ entry.symbols = value.symbols;
69
+ }
70
+ consumes[extname] = entry;
71
+ }
72
+ manifest.consumes = consumes;
73
+ }
74
+ return manifest;
75
+ }
76
+ function validateProvide(extname, value, source) {
77
+ if (!isPlainObject(value)) {
78
+ throw new Error(`${source}: provides."${extname}" must be an object`);
79
+ }
80
+ const provide = {};
81
+ if (value.schema !== undefined) {
82
+ if (value.schema !== null && (typeof value.schema !== 'string' || !value.schema.trim())) {
83
+ throw new Error(`${source}: provides."${extname}".schema must be a non-empty string or null`);
84
+ }
85
+ provide.schema = value.schema;
86
+ }
87
+ for (const flag of ['relocatable', 'ifNotExists', 'cascade', 'createSchema', 'dropSchema']) {
88
+ if (value[flag] !== undefined) {
89
+ if (typeof value[flag] !== 'boolean') {
90
+ throw new Error(`${source}: provides."${extname}".${flag} must be a boolean`);
91
+ }
92
+ provide[flag] = value[flag];
93
+ }
94
+ }
95
+ if (value.grants !== undefined) {
96
+ if (!Array.isArray(value.grants)) {
97
+ throw new Error(`${source}: provides."${extname}".grants must be an array`);
98
+ }
99
+ provide.grants = value.grants.map((g, i) => validateGrant(extname, g, i, source));
100
+ }
101
+ return provide;
102
+ }
103
+ function validateGrant(extname, value, index, source) {
104
+ const where = `provides."${extname}".grants[${index}]`;
105
+ if (!isPlainObject(value)) {
106
+ throw new Error(`${source}: ${where} must be an object`);
107
+ }
108
+ if (typeof value.privileges !== 'string' || !value.privileges.trim()) {
109
+ throw new Error(`${source}: ${where}.privileges must be a non-empty string`);
110
+ }
111
+ if (typeof value.on !== 'string' || !GRANT_TARGETS.has(value.on)) {
112
+ throw new Error(`${source}: ${where}.on must be one of ${[...GRANT_TARGETS].join(', ')}`);
113
+ }
114
+ const toValid = (typeof value.to === 'string' && value.to.trim()) ||
115
+ (Array.isArray(value.to) && value.to.length > 0 && value.to.every((r) => typeof r === 'string' && r.trim()));
116
+ if (!toValid) {
117
+ throw new Error(`${source}: ${where}.to must be a role name or non-empty array of role names`);
118
+ }
119
+ return {
120
+ privileges: value.privileges,
121
+ on: value.on,
122
+ to: value.to
123
+ };
124
+ }
125
+ /**
126
+ * Read + validate the `extensions.json` (or `pgpm.extensions.json`) manifest for
127
+ * a module directory. Returns `undefined` when the module declares no manifest.
128
+ */
129
+ function readExtensionsManifest(moduleDir) {
130
+ for (const file of exports.EXTENSIONS_MANIFEST_FILES) {
131
+ const p = (0, path_1.join)(moduleDir, file);
132
+ if ((0, fs_1.existsSync)(p)) {
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse((0, fs_1.readFileSync)(p, 'utf-8'));
136
+ }
137
+ catch (e) {
138
+ throw new Error(`Failed to parse ${file} at ${moduleDir}: ${e.message}`);
139
+ }
140
+ return validateExtensionsManifest(parsed, file);
141
+ }
142
+ }
143
+ return undefined;
144
+ }
@@ -0,0 +1,20 @@
1
+ import { RoleMapping } from '@pgpmjs/types';
2
+ import { CompiledExtensionInstall, CompileExtensionInstallOptions } from './compile';
3
+ /**
4
+ * Build a grant role-name map (workspace portability profile) from the
5
+ * configured {@link RoleMapping}. Keys are the conceptual role names a module
6
+ * author writes in its manifest (`anonymous`/`authenticated`/`administrator`);
7
+ * values are the actual role names for this database.
8
+ */
9
+ export declare function roleMapFromRoles(roles?: RoleMapping): Record<string, string> | undefined;
10
+ /**
11
+ * Find a declarative install for an external extension by scanning the resolved
12
+ * local modules for an `extensions.json` that `provides` it, and compiling it.
13
+ *
14
+ * Returns `undefined` when no module declares the extension — the caller then
15
+ * falls back to the legacy `CREATE EXTENSION ... CASCADE` (no schema, no grants).
16
+ * The first declaring module wins; a later contradictory declaration throws.
17
+ */
18
+ export declare function findExtensionInstall(extname: string, localModules: string[], modules: Record<string, {
19
+ path: string;
20
+ }>, workspacePath: string, options?: CompileExtensionInstallOptions): CompiledExtensionInstall | undefined;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.roleMapFromRoles = roleMapFromRoles;
4
+ exports.findExtensionInstall = findExtensionInstall;
5
+ const path_1 = require("path");
6
+ const compile_1 = require("./compile");
7
+ const manifest_1 = require("./manifest");
8
+ /**
9
+ * Build a grant role-name map (workspace portability profile) from the
10
+ * configured {@link RoleMapping}. Keys are the conceptual role names a module
11
+ * author writes in its manifest (`anonymous`/`authenticated`/`administrator`);
12
+ * values are the actual role names for this database.
13
+ */
14
+ function roleMapFromRoles(roles) {
15
+ if (!roles)
16
+ return undefined;
17
+ const map = {};
18
+ for (const key of ['anonymous', 'authenticated', 'administrator', 'authenticatedClient']) {
19
+ const value = roles[key];
20
+ if (value)
21
+ map[key] = value;
22
+ }
23
+ return Object.keys(map).length > 0 ? map : undefined;
24
+ }
25
+ /**
26
+ * Find a declarative install for an external extension by scanning the resolved
27
+ * local modules for an `extensions.json` that `provides` it, and compiling it.
28
+ *
29
+ * Returns `undefined` when no module declares the extension — the caller then
30
+ * falls back to the legacy `CREATE EXTENSION ... CASCADE` (no schema, no grants).
31
+ * The first declaring module wins; a later contradictory declaration throws.
32
+ */
33
+ function findExtensionInstall(extname, localModules, modules, workspacePath, options = {}) {
34
+ let winner;
35
+ for (const moduleName of localModules) {
36
+ const mod = modules[moduleName];
37
+ if (!mod)
38
+ continue;
39
+ const manifest = (0, manifest_1.readExtensionsManifest)((0, path_1.resolve)(workspacePath, mod.path));
40
+ const provide = manifest?.provides?.[extname];
41
+ if (!provide)
42
+ continue;
43
+ if (winner && !sameProvide(winner.provide, provide)) {
44
+ throw new Error(`Conflicting extensions.json "provides" for "${extname}": ` +
45
+ `module "${winner.moduleName}" and module "${moduleName}" declare incompatible ` +
46
+ `install schema/grants. Exactly one provider (or identical declarations) is required.`);
47
+ }
48
+ if (!winner)
49
+ winner = { moduleName, provide };
50
+ }
51
+ return winner ? (0, compile_1.compileExtensionInstall)(extname, winner.provide, options) : undefined;
52
+ }
53
+ /** Structural equality of two provide declarations (order-insensitive grants). */
54
+ function sameProvide(a, b) {
55
+ return normalizeProvide(a) === normalizeProvide(b);
56
+ }
57
+ function normalizeProvide(p) {
58
+ const grants = (p.grants ?? [])
59
+ .map(g => ({
60
+ privileges: g.privileges,
61
+ on: g.on,
62
+ to: (Array.isArray(g.to) ? [...g.to] : [g.to]).sort()
63
+ }))
64
+ .sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
65
+ return JSON.stringify({
66
+ schema: p.schema ?? null,
67
+ relocatable: p.relocatable ?? null,
68
+ ifNotExists: p.ifNotExists ?? null,
69
+ cascade: p.cascade ?? null,
70
+ createSchema: p.createSchema ?? null,
71
+ dropSchema: p.dropSchema ?? null,
72
+ grants
73
+ });
74
+ }
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from './core/class/pgpm';
2
2
  export * from './rebundle';
3
- export * from './extensions/extensions';
3
+ export * from './extensions';
4
4
  export * from './modules/modules';
5
5
  export * from './packaging/package';
6
6
  export * from './packaging/sync-versions';
package/index.js CHANGED
@@ -17,7 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.withTransaction = exports.executeQuery = exports.hashString = exports.hashFile = exports.parseAuthor = exports.PgpmInit = exports.PgpmMigrate = exports.cleanSql = void 0;
18
18
  __exportStar(require("./core/class/pgpm"), exports);
19
19
  __exportStar(require("./rebundle"), exports);
20
- __exportStar(require("./extensions/extensions"), exports);
20
+ __exportStar(require("./extensions"), exports);
21
21
  __exportStar(require("./modules/modules"), exports);
22
22
  __exportStar(require("./packaging/package"), exports);
23
23
  __exportStar(require("./packaging/sync-versions"), exports);
package/migrate/clean.js CHANGED
@@ -5,8 +5,7 @@ const pgsql_parser_1 = require("pgsql-parser");
5
5
  const filterStatements = (stmts) => {
6
6
  const filteredStmts = stmts.filter(node => {
7
7
  const stmt = node.stmt;
8
- return stmt && !stmt.hasOwnProperty('TransactionStmt') &&
9
- !stmt.hasOwnProperty('CreateExtensionStmt');
8
+ return stmt && !stmt.hasOwnProperty('TransactionStmt');
10
9
  });
11
10
  const hasFiltered = filteredStmts.length !== stmts.length;
12
11
  return { filteredStmts, hasFiltered };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgpmjs/core",
3
- "version": "7.15.0",
3
+ "version": "7.16.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "PGPM Package and Migration Tools",
6
6
  "main": "index.js",
@@ -48,24 +48,24 @@
48
48
  "makage": "^0.3.0"
49
49
  },
50
50
  "dependencies": {
51
- "@pgpmjs/ast": "^0.9.2",
52
- "@pgpmjs/bundle": "^0.12.0",
53
- "@pgpmjs/env": "^2.38.0",
51
+ "@pgpmjs/ast": "^0.9.3",
52
+ "@pgpmjs/bundle": "^0.12.1",
53
+ "@pgpmjs/env": "^2.39.0",
54
54
  "@pgpmjs/logger": "^2.22.0",
55
- "@pgpmjs/server-utils": "^3.23.1",
56
- "@pgpmjs/slice": "^0.10.1",
57
- "@pgpmjs/transform": "^0.11.0",
58
- "@pgpmjs/types": "^2.46.0",
55
+ "@pgpmjs/server-utils": "^3.23.2",
56
+ "@pgpmjs/slice": "^0.10.2",
57
+ "@pgpmjs/transform": "^0.11.1",
58
+ "@pgpmjs/types": "^2.47.0",
59
59
  "csv-to-pg": "^4.8.0",
60
60
  "genomic": "^5.6.2",
61
61
  "glob": "^13.0.6",
62
62
  "parse-package-name": "^1.0.0",
63
63
  "pg": "^8.21.0",
64
- "pg-cache": "^3.23.1",
64
+ "pg-cache": "^3.23.2",
65
65
  "pg-env": "^1.26.0",
66
66
  "pgsql-deparser": "^18.1.1",
67
67
  "pgsql-parser": "^18.1.1",
68
68
  "yanse": "^0.2.1"
69
69
  },
70
- "gitHead": "5ef8341033a8fbd85841b770e996e468dd15333b"
70
+ "gitHead": "0286d994d98717cb9b28842233d2db6d1e37c48e"
71
71
  }
@@ -44,6 +44,7 @@ const materialize_1 = require("../apply/materialize");
44
44
  const apply_spec_1 = require("../apply/apply-spec");
45
45
  const deploy_bundled_1 = require("../bundle/deploy-bundled");
46
46
  const pgpm_1 = require("../core/class/pgpm");
47
+ const extensions_1 = require("../extensions");
47
48
  const client_1 = require("../migrate/client");
48
49
  const deps_1 = require("../resolution/deps");
49
50
  const log = new logger_1.Logger('deploy');
@@ -63,14 +64,28 @@ const deployProject = async (opts, name, database, pkg, toChange) => {
63
64
  ? (0, deps_1.resolveExtensionDependencies)(name, modules)
64
65
  : new pgpm_1.PgpmPackage(modulePath, { extensionsDir: pkg.extensionsDir }).getModuleExtensions();
65
66
  const pgPool = (0, pg_cache_1.getPgPool)({ ...opts.pg, database });
67
+ // Local modules (in resolve order) that may carry an `extensions.json`
68
+ // declaring how an external extension should be installed (schema + grants).
69
+ const localModules = extensions.resolved.filter((m) => !extensions.external.includes(m));
70
+ const roleMap = (0, extensions_1.roleMapFromRoles)(mergedOpts.db?.roles);
66
71
  log.success(`🚀 Starting deployment to database ${database}...`);
67
72
  for (const extension of extensions.resolved) {
68
73
  try {
69
74
  if (extensions.external.includes(extension)) {
70
- const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
71
- log.info(`📥 Installing external extension: ${extension}`);
72
- log.debug(`> ${msg}`);
73
- await pgPool.query(msg);
75
+ const install = (0, extensions_1.findExtensionInstall)(extension, localModules, modules, pkg.workspacePath, { roleMap });
76
+ if (install) {
77
+ log.info(`📥 Installing external extension (declarative): ${extension}`);
78
+ for (const stmt of install.deploy) {
79
+ log.debug(`> ${stmt}`);
80
+ await pgPool.query(stmt);
81
+ }
82
+ }
83
+ else {
84
+ const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
85
+ log.info(`📥 Installing external extension: ${extension}`);
86
+ log.debug(`> ${msg}`);
87
+ await pgPool.query(msg);
88
+ }
74
89
  }
75
90
  else {
76
91
  const sourcePath = (0, path_1.resolve)(pkg.workspacePath, modules[extension].path);