@pgpmjs/core 7.15.0 → 7.17.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.
@@ -54,16 +54,21 @@ function parseApplySpec(content, specPath) {
54
54
  throw new Error(`${specPath}: "route" must be a non-empty array of route entries`);
55
55
  }
56
56
  for (const entry of parsed.route) {
57
+ const validToSchema = entry?.toSchema === undefined ||
58
+ entry.toSchema === null ||
59
+ (typeof entry.toSchema === 'string' && !!entry.toSchema);
60
+ const validToName = entry?.toName === undefined || (typeof entry.toName === 'string' && !!entry.toName);
57
61
  if (!entry ||
58
62
  typeof entry !== 'object' ||
59
63
  typeof entry.fromSchema !== 'string' ||
60
64
  !entry.fromSchema ||
61
65
  typeof entry.name !== 'string' ||
62
66
  !entry.name ||
63
- typeof entry.toSchema !== 'string' ||
64
- !entry.toSchema ||
67
+ !validToSchema ||
68
+ !validToName ||
69
+ (entry.toSchema === undefined && entry.toName === undefined) ||
65
70
  !ROUTE_KINDS.includes(entry.kind)) {
66
- throw new Error(`${specPath}: each "route" entry needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name, toSchema } as non-empty strings`);
71
+ throw new Error(`${specPath}: each "route" entry needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name } plus "toSchema" (schema | null) and/or "toName"`);
67
72
  }
68
73
  }
69
74
  }
@@ -142,8 +147,20 @@ function parseApplySpec(content, specPath) {
142
147
  if (hasRoles && !isSchemaMap(parsed.roles)) {
143
148
  throw new Error(`${specPath}: "roles" must be a non-empty string → string map`);
144
149
  }
145
- if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles) {
146
- throw new Error(`${specPath}: at least one of "schemas", "route", "extensions", or "roles" is required`);
150
+ const hasExclude = parsed.exclude !== undefined;
151
+ if (hasExclude) {
152
+ const ex = parsed.exclude;
153
+ if (typeof ex !== 'object' ||
154
+ ex === null ||
155
+ Array.isArray(ex) ||
156
+ !Array.isArray(ex.schemas) ||
157
+ ex.schemas.length === 0 ||
158
+ ex.schemas.some((s) => typeof s !== 'string' || !s)) {
159
+ throw new Error(`${specPath}: "exclude" must be { schemas: [non-empty schema names] }`);
160
+ }
161
+ }
162
+ if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles && !hasExclude) {
163
+ throw new Error(`${specPath}: at least one of "schemas", "route", "extensions", "roles", or "exclude" is required`);
147
164
  }
148
165
  const name = parsed.name ?? (0, path_1.basename)((0, path_1.dirname)(specPath));
149
166
  if (typeof name !== 'string' || !name) {
package/apply/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './materialize';
2
+ export * from './profile';
2
3
  export * from './apply-spec';
3
4
  export * from './reuse';
4
5
  export * from './types';
package/apply/index.js CHANGED
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./materialize"), exports);
18
+ __exportStar(require("./profile"), exports);
18
19
  __exportStar(require("./apply-spec"), exports);
19
20
  __exportStar(require("./reuse"), exports);
20
21
  __exportStar(require("./types"), exports);
@@ -8,7 +8,9 @@ const os_1 = require("os");
8
8
  const path_1 = require("path");
9
9
  const bundle_1 = require("@pgpmjs/bundle");
10
10
  const transform_1 = require("@pgpmjs/transform");
11
+ const slice_1 = require("@pgpmjs/slice");
11
12
  const apply_spec_1 = require("./apply-spec");
13
+ const profile_1 = require("./profile");
12
14
  const reuse_1 = require("./reuse");
13
15
  // Functions that take a bare schema name as a string-literal first argument.
14
16
  // The AST pass only remaps dotted literals ('schema.object'); these helpers
@@ -22,6 +24,48 @@ const schemaNameLiteralPass = (content, schemaMapping) => {
22
24
  return mapped ? match.replace(`'${schema}'`, `'${mapped}'`) : match;
23
25
  });
24
26
  };
27
+ /**
28
+ * Build the per-script subsystem stripper for an `exclude` spec, verifying
29
+ * cascade safety across the *whole* source bundle first: every deploy-script
30
+ * reference into an excluded schema (FKs, calls, policy predicates) must have
31
+ * a rebind route, or materialization refuses with each unsatisfied reference
32
+ * named. Checking bundle-wide (not per script) is what catches cross-change
33
+ * dependencies on the excluded subsystem.
34
+ */
35
+ function makeSubsystemStripper(source, spec, instanceName) {
36
+ const selector = { schemas: spec.exclude.schemas };
37
+ const rebinds = (0, transform_1.buildSchemaRouter)({ schemaMap: spec.schemas, routes: spec.route });
38
+ const fullDeploySql = source.changes
39
+ .map(c => c.deploy?.sql ?? '')
40
+ .filter(Boolean)
41
+ .join('\n');
42
+ const analysis = (0, slice_1.excludeSubsystem)(fullDeploySql, selector, { rebinds });
43
+ if (analysis.unsatisfied.length > 0) {
44
+ const detail = [
45
+ ...new Set(analysis.unsatisfied.map(u => `${u.object.schema}.${u.object.name}${u.fk ? ' (foreign key target)' : ''}`))
46
+ ].join(', ');
47
+ throw new Error(`Cannot exclude schema(s) ${spec.exclude.schemas.join(', ')} from ` +
48
+ `"${spec.source.module}" as "${instanceName}": surviving statements still reference ` +
49
+ `${detail} with no route/rebind target. Add "route" entries substituting each ` +
50
+ `referenced object, or keep the subsystem.`);
51
+ }
52
+ // A change whose deploy consists entirely of subsystem statements (plus
53
+ // transaction control) is excluded *as a change*: its verify/revert scripts
54
+ // target dropped objects the classifier can't always see (bare DROPs,
55
+ // catalog probes), so all three scripts are blanked together.
56
+ const excludedChanges = new Set();
57
+ for (const change of source.changes) {
58
+ if (!change.deploy)
59
+ continue;
60
+ const stripped = (0, slice_1.stripSubsystemSql)(change.deploy.sql, selector);
61
+ const survivors = stripped.result.kept.filter(i => !stripped.dropped.includes(i) && stripped.result.statements[i].nodeTag !== 'TransactionStmt');
62
+ if (stripped.dropped.length > 0 && survivors.length === 0) {
63
+ excludedChanges.add(change.name);
64
+ }
65
+ }
66
+ const strip = (sql, change) => excludedChanges.has(change) ? (0, slice_1.blankScriptSql)(sql) : (0, slice_1.stripSubsystemSql)(sql, selector).sql;
67
+ return { strip, excludedChanges };
68
+ }
25
69
  /**
26
70
  * Transpile a source module per an apply spec and materialize the result as a
27
71
  * deployable module directory carrying the instance's identity.
@@ -51,9 +95,14 @@ async function materializeApplyModule(options) {
51
95
  const targetSchemas = [
52
96
  ...new Set([
53
97
  ...Object.values(spec.schemas ?? {}),
54
- ...(spec.route ?? []).map(r => r.toSchema)
98
+ ...(spec.route ?? [])
99
+ .map(r => r.toSchema)
100
+ .filter((s) => typeof s === 'string')
55
101
  ])
56
102
  ];
103
+ const stripSubsystem = spec.exclude
104
+ ? makeSubsystemStripper(source, spec, instanceName)
105
+ : undefined;
57
106
  const { renameChange, transformScript, result } = (0, transform_1.makeSchemaTranspiler)({
58
107
  schemaMap: spec.schemas,
59
108
  routes: spec.route,
@@ -64,9 +113,15 @@ async function materializeApplyModule(options) {
64
113
  assumeSchemasExist: targetSchemas
65
114
  }
66
115
  });
116
+ // Excluded changes keep their source identity (an emptied tombstone) —
117
+ // renaming them into the replacement provider's namespace would be a lie.
67
118
  const transpiled = (0, bundle_1.transpileBundle)(source, {
68
- renameChange,
69
- transformScript,
119
+ renameChange: stripSubsystem
120
+ ? (name) => (stripSubsystem.excludedChanges.has(name) ? name : renameChange(name))
121
+ : renameChange,
122
+ transformScript: stripSubsystem
123
+ ? (sql, ctx) => transformScript(stripSubsystem.strip(sql, ctx.change), ctx)
124
+ : transformScript,
70
125
  renameModule: instanceName,
71
126
  provenance: {
72
127
  appliedFrom: spec.source.module,
@@ -108,7 +163,7 @@ async function resolveEffectiveModulePath(moduleName, modulePath, moduleMap, wor
108
163
  const cached = materializedCache.get(cacheKey);
109
164
  if (cached)
110
165
  return cached;
111
- const spec = (0, apply_spec_1.readApplySpec)(modulePath);
166
+ const spec = (0, profile_1.resolveEffectiveApplySpec)((0, apply_spec_1.readApplySpec)(modulePath), (0, profile_1.loadWorkspaceRoutingProfile)(workspacePath));
112
167
  const sourceModule = moduleMap[spec.source.module];
113
168
  if (!sourceModule) {
114
169
  throw new Error(`Apply spec in "${moduleName}" references source module "${spec.source.module}", ` +
@@ -0,0 +1,14 @@
1
+ import { PgpmRoutingProfile } from '@pgpmjs/types';
2
+ import { ResolvedApplySpec } from './types';
3
+ /**
4
+ * Load the workspace-level routing profile (the `portability` field of the
5
+ * workspace `pgpm.json` / `pgpm.config.js`). Returns `undefined` when the
6
+ * workspace has no config file or no profile.
7
+ */
8
+ export declare function loadWorkspaceRoutingProfile(workspacePath: string): PgpmRoutingProfile | undefined;
9
+ /**
10
+ * Resolve the routing profile effective for one apply spec: defaults →
11
+ * workspace `portability` → the spec's own routing keys, merged per key
12
+ * (inner scope wins; see {@link mergeRoutingProfiles}).
13
+ */
14
+ export declare function resolveEffectiveApplySpec(spec: ResolvedApplySpec, workspaceProfile?: PgpmRoutingProfile): ResolvedApplySpec;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadWorkspaceRoutingProfile = loadWorkspaceRoutingProfile;
4
+ exports.resolveEffectiveApplySpec = resolveEffectiveApplySpec;
5
+ const env_1 = require("@pgpmjs/env");
6
+ const types_1 = require("@pgpmjs/types");
7
+ /**
8
+ * Load the workspace-level routing profile (the `portability` field of the
9
+ * workspace `pgpm.json` / `pgpm.config.js`). Returns `undefined` when the
10
+ * workspace has no config file or no profile.
11
+ */
12
+ function loadWorkspaceRoutingProfile(workspacePath) {
13
+ let config;
14
+ try {
15
+ config = (0, env_1.loadConfigSyncFromDir)(workspacePath);
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ return config?.portability;
21
+ }
22
+ /**
23
+ * Resolve the routing profile effective for one apply spec: defaults →
24
+ * workspace `portability` → the spec's own routing keys, merged per key
25
+ * (inner scope wins; see {@link mergeRoutingProfiles}).
26
+ */
27
+ function resolveEffectiveApplySpec(spec, workspaceProfile) {
28
+ if (!workspaceProfile)
29
+ return spec;
30
+ const merged = (0, types_1.mergeRoutingProfiles)(workspaceProfile, {
31
+ schemas: spec.schemas,
32
+ route: spec.route,
33
+ extensions: spec.extensions,
34
+ roles: spec.roles,
35
+ exclude: spec.exclude
36
+ });
37
+ return { ...spec, ...merged };
38
+ }
package/apply/types.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * module in memory and runs the normal migration path against the result;
11
11
  * transpiled code is never committed.
12
12
  */
13
+ import { PgpmExtensionsRouting, PgpmRolesRouting, PgpmRouteEntry, PgpmRoutingProfile } from '@pgpmjs/types';
13
14
  /** File name of the apply spec inside a proxy module directory. */
14
15
  export declare const APPLY_SPEC_FILE = "pgpm.apply.json";
15
16
  /** Expanded reference to the source module a proxy module applies. */
@@ -33,22 +34,11 @@ export interface ApplySpecSource {
33
34
  bundleDigest?: string;
34
35
  }
35
36
  /**
36
- * An object-level route in a `pgpm.apply.json` spec: send one named object out
37
- * of a source schema to a target schema, overriding the whole-schema default
38
- * for that object only. Kinds are expressed as separate properties (no dotted
39
- * identity strings), so a table and a function of the same name route
40
- * independently.
37
+ * An object-level route in a `pgpm.apply.json` spec. The shared
38
+ * routing-profile shape ({@link PgpmRouteEntry} from `@pgpmjs/types`), usable
39
+ * at both the workspace and per-import scopes.
41
40
  */
42
- export interface ApplyRouteEntry {
43
- /** Source schema the object is defined in (e.g. `users`). */
44
- fromSchema: string;
45
- /** Object namespace: `table`/`view` → relation, `procedure` → function. */
46
- kind: 'table' | 'view' | 'function' | 'procedure' | 'type';
47
- /** Unqualified object name (e.g. `accounts`). */
48
- name: string;
49
- /** Target schema the object is routed to (e.g. `reporting`). */
50
- toSchema: string;
51
- }
41
+ export type ApplyRouteEntry = PgpmRouteEntry;
52
42
  /**
53
43
  * A per-tenant seed object in a reuse spec: the objects that must be
54
44
  * materialized once *per tenant*. `@pgpmjs/slice`'s `partitionModule` starts
@@ -89,41 +79,20 @@ export interface ApplyReuseSpec {
89
79
  }
90
80
  /**
91
81
  * Extension-routing declaration in a `pgpm.apply.json`: where the transpiled
92
- * instance should install extensions and resolve the symbols they provide
93
- * (e.g. isolate `pgcrypto` in a dedicated `extensions` schema, qualifying bare
94
- * `crypt(...)` calls, or the reverse). A distinct dimension from schema
95
- * routing — driven by a version-aware symbol inventory, not by the objects the
96
- * SQL itself creates. Forwarded to `@pgpmjs/transform`'s extension router.
82
+ * instance should resolve the symbols extensions provide. The shared
83
+ * routing-profile shape ({@link PgpmExtensionsRouting} from `@pgpmjs/types`);
84
+ * forwarded to `@pgpmjs/transform`'s extension router.
97
85
  */
98
- export interface ApplyExtensionsSpec {
99
- /**
100
- * Install the matched extensions and route their provided symbols to this
101
- * schema. `null` strips qualification (rely on `search_path`). Ignored when
102
- * `routes` is given.
103
- */
104
- toSchema?: string | null;
105
- /** With `toSchema`: limit to these extensions (default: every inventoried one). */
106
- only?: string[];
107
- /**
108
- * With `toSchema`: which source qualifications to rewrite (a `null` entry
109
- * also rewrites bare references). Defaults to `public` + bare.
110
- */
111
- from?: (string | null)[];
112
- /**
113
- * Advanced: explicit per-extension routes (`{ "<ext>": { "to": "<schema>|null",
114
- * "from"?: [...] } }`). Overrides `toSchema`/`only`/`from`.
115
- */
116
- routes?: Record<string, {
117
- to: string | null;
118
- from?: (string | null)[];
119
- }>;
120
- /** Target PostgreSQL major version, for core-graduation awareness. */
121
- serverVersion?: number;
122
- }
86
+ export type ApplyExtensionsSpec = PgpmExtensionsRouting;
123
87
  /** Role-name translation: source role name → target role name. */
124
- export type ApplyRolesSpec = Record<string, string>;
125
- /** The parsed, normalized `pgpm.apply.json` spec. */
126
- export interface PgpmApplySpec {
88
+ export type ApplyRolesSpec = PgpmRolesRouting;
89
+ /**
90
+ * The parsed, normalized `pgpm.apply.json` spec: a source-module reference
91
+ * plus a per-import routing profile ({@link PgpmRoutingProfile}). The routing
92
+ * keys override the workspace `portability` profile per key (inner scope
93
+ * wins).
94
+ */
95
+ export interface PgpmApplySpec extends PgpmRoutingProfile {
127
96
  /**
128
97
  * Module name this instance is deployed as. Defaults to the proxy
129
98
  * directory's name.
@@ -131,33 +100,12 @@ export interface PgpmApplySpec {
131
100
  name?: string;
132
101
  /** Source module reference: a module name, or the expanded object form. */
133
102
  source: string | ApplySpecSource;
134
- /**
135
- * Whole-schema default: source schema → target schema (drives the AST + plan
136
- * rewrite). Optional when `route` fully covers the objects being moved.
137
- */
138
- schemas?: Record<string, string>;
139
- /**
140
- * Object-level routes. Each entry overrides the `schemas` default for its
141
- * specific object, letting one source schema fan out per object (e.g. a
142
- * table into a tenant schema and a function into a shared schema).
143
- */
144
- route?: ApplyRouteEntry[];
145
103
  /**
146
104
  * Reuse declaration: split shared vs per-tenant objects (see
147
105
  * {@link ApplyReuseSpec}). When present, this proxy expands into two modules —
148
106
  * a shared module and a per-tenant module that `requires` it.
149
107
  */
150
108
  reuse?: ApplyReuseSpec;
151
- /**
152
- * Extension routing: where to install extensions and resolve their provided
153
- * symbols in the transpiled instance (see {@link ApplyExtensionsSpec}).
154
- */
155
- extensions?: ApplyExtensionsSpec;
156
- /**
157
- * Role-name translation applied to the transpiled instance's SQL
158
- * (see {@link ApplyRolesSpec}). Renames role identifiers only.
159
- */
160
- roles?: ApplyRolesSpec;
161
109
  /**
162
110
  * Runtime requires of the transpiled output (native extensions and other
163
111
  * modules). Defaults to the source module's own requires.
@@ -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);
@@ -49,16 +49,21 @@ export function parseApplySpec(content, specPath) {
49
49
  throw new Error(`${specPath}: "route" must be a non-empty array of route entries`);
50
50
  }
51
51
  for (const entry of parsed.route) {
52
+ const validToSchema = entry?.toSchema === undefined ||
53
+ entry.toSchema === null ||
54
+ (typeof entry.toSchema === 'string' && !!entry.toSchema);
55
+ const validToName = entry?.toName === undefined || (typeof entry.toName === 'string' && !!entry.toName);
52
56
  if (!entry ||
53
57
  typeof entry !== 'object' ||
54
58
  typeof entry.fromSchema !== 'string' ||
55
59
  !entry.fromSchema ||
56
60
  typeof entry.name !== 'string' ||
57
61
  !entry.name ||
58
- typeof entry.toSchema !== 'string' ||
59
- !entry.toSchema ||
62
+ !validToSchema ||
63
+ !validToName ||
64
+ (entry.toSchema === undefined && entry.toName === undefined) ||
60
65
  !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`);
66
+ throw new Error(`${specPath}: each "route" entry needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name } plus "toSchema" (schema | null) and/or "toName"`);
62
67
  }
63
68
  }
64
69
  }
@@ -137,8 +142,20 @@ export function parseApplySpec(content, specPath) {
137
142
  if (hasRoles && !isSchemaMap(parsed.roles)) {
138
143
  throw new Error(`${specPath}: "roles" must be a non-empty string → string map`);
139
144
  }
140
- if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles) {
141
- throw new Error(`${specPath}: at least one of "schemas", "route", "extensions", or "roles" is required`);
145
+ const hasExclude = parsed.exclude !== undefined;
146
+ if (hasExclude) {
147
+ const ex = parsed.exclude;
148
+ if (typeof ex !== 'object' ||
149
+ ex === null ||
150
+ Array.isArray(ex) ||
151
+ !Array.isArray(ex.schemas) ||
152
+ ex.schemas.length === 0 ||
153
+ ex.schemas.some((s) => typeof s !== 'string' || !s)) {
154
+ throw new Error(`${specPath}: "exclude" must be { schemas: [non-empty schema names] }`);
155
+ }
156
+ }
157
+ if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles && !hasExclude) {
158
+ throw new Error(`${specPath}: at least one of "schemas", "route", "extensions", "roles", or "exclude" is required`);
142
159
  }
143
160
  const name = parsed.name ?? basename(dirname(specPath));
144
161
  if (typeof name !== 'string' || !name) {
@@ -1,4 +1,5 @@
1
1
  export * from './materialize';
2
+ export * from './profile';
2
3
  export * from './apply-spec';
3
4
  export * from './reuse';
4
5
  export * from './types';
@@ -2,8 +2,10 @@ import { mkdtempSync } from 'fs';
2
2
  import { tmpdir } from 'os';
3
3
  import { join, resolve } from 'path';
4
4
  import { bundleFromModule, materializeBundle, transpileBundle, verifyBundle } from '@pgpmjs/bundle';
5
- import { loadModule, makeSchemaTranspiler } from '@pgpmjs/transform';
5
+ import { buildSchemaRouter, loadModule, makeSchemaTranspiler } from '@pgpmjs/transform';
6
+ import { blankScriptSql, excludeSubsystem, stripSubsystemSql } from '@pgpmjs/slice';
6
7
  import { hasApplySpec, readApplySpec } from './apply-spec';
8
+ import { loadWorkspaceRoutingProfile, resolveEffectiveApplySpec } from './profile';
7
9
  import { isReuseSpec, materializeReuseModule, resolveSharedModuleName } from './reuse';
8
10
  // Functions that take a bare schema name as a string-literal first argument.
9
11
  // The AST pass only remaps dotted literals ('schema.object'); these helpers
@@ -17,6 +19,48 @@ const schemaNameLiteralPass = (content, schemaMapping) => {
17
19
  return mapped ? match.replace(`'${schema}'`, `'${mapped}'`) : match;
18
20
  });
19
21
  };
22
+ /**
23
+ * Build the per-script subsystem stripper for an `exclude` spec, verifying
24
+ * cascade safety across the *whole* source bundle first: every deploy-script
25
+ * reference into an excluded schema (FKs, calls, policy predicates) must have
26
+ * a rebind route, or materialization refuses with each unsatisfied reference
27
+ * named. Checking bundle-wide (not per script) is what catches cross-change
28
+ * dependencies on the excluded subsystem.
29
+ */
30
+ function makeSubsystemStripper(source, spec, instanceName) {
31
+ const selector = { schemas: spec.exclude.schemas };
32
+ const rebinds = buildSchemaRouter({ schemaMap: spec.schemas, routes: spec.route });
33
+ const fullDeploySql = source.changes
34
+ .map(c => c.deploy?.sql ?? '')
35
+ .filter(Boolean)
36
+ .join('\n');
37
+ const analysis = excludeSubsystem(fullDeploySql, selector, { rebinds });
38
+ if (analysis.unsatisfied.length > 0) {
39
+ const detail = [
40
+ ...new Set(analysis.unsatisfied.map(u => `${u.object.schema}.${u.object.name}${u.fk ? ' (foreign key target)' : ''}`))
41
+ ].join(', ');
42
+ throw new Error(`Cannot exclude schema(s) ${spec.exclude.schemas.join(', ')} from ` +
43
+ `"${spec.source.module}" as "${instanceName}": surviving statements still reference ` +
44
+ `${detail} with no route/rebind target. Add "route" entries substituting each ` +
45
+ `referenced object, or keep the subsystem.`);
46
+ }
47
+ // A change whose deploy consists entirely of subsystem statements (plus
48
+ // transaction control) is excluded *as a change*: its verify/revert scripts
49
+ // target dropped objects the classifier can't always see (bare DROPs,
50
+ // catalog probes), so all three scripts are blanked together.
51
+ const excludedChanges = new Set();
52
+ for (const change of source.changes) {
53
+ if (!change.deploy)
54
+ continue;
55
+ const stripped = stripSubsystemSql(change.deploy.sql, selector);
56
+ const survivors = stripped.result.kept.filter(i => !stripped.dropped.includes(i) && stripped.result.statements[i].nodeTag !== 'TransactionStmt');
57
+ if (stripped.dropped.length > 0 && survivors.length === 0) {
58
+ excludedChanges.add(change.name);
59
+ }
60
+ }
61
+ const strip = (sql, change) => excludedChanges.has(change) ? blankScriptSql(sql) : stripSubsystemSql(sql, selector).sql;
62
+ return { strip, excludedChanges };
63
+ }
20
64
  /**
21
65
  * Transpile a source module per an apply spec and materialize the result as a
22
66
  * deployable module directory carrying the instance's identity.
@@ -46,9 +90,14 @@ export async function materializeApplyModule(options) {
46
90
  const targetSchemas = [
47
91
  ...new Set([
48
92
  ...Object.values(spec.schemas ?? {}),
49
- ...(spec.route ?? []).map(r => r.toSchema)
93
+ ...(spec.route ?? [])
94
+ .map(r => r.toSchema)
95
+ .filter((s) => typeof s === 'string')
50
96
  ])
51
97
  ];
98
+ const stripSubsystem = spec.exclude
99
+ ? makeSubsystemStripper(source, spec, instanceName)
100
+ : undefined;
52
101
  const { renameChange, transformScript, result } = makeSchemaTranspiler({
53
102
  schemaMap: spec.schemas,
54
103
  routes: spec.route,
@@ -59,9 +108,15 @@ export async function materializeApplyModule(options) {
59
108
  assumeSchemasExist: targetSchemas
60
109
  }
61
110
  });
111
+ // Excluded changes keep their source identity (an emptied tombstone) —
112
+ // renaming them into the replacement provider's namespace would be a lie.
62
113
  const transpiled = transpileBundle(source, {
63
- renameChange,
64
- transformScript,
114
+ renameChange: stripSubsystem
115
+ ? (name) => (stripSubsystem.excludedChanges.has(name) ? name : renameChange(name))
116
+ : renameChange,
117
+ transformScript: stripSubsystem
118
+ ? (sql, ctx) => transformScript(stripSubsystem.strip(sql, ctx.change), ctx)
119
+ : transformScript,
65
120
  renameModule: instanceName,
66
121
  provenance: {
67
122
  appliedFrom: spec.source.module,
@@ -103,7 +158,7 @@ export async function resolveEffectiveModulePath(moduleName, modulePath, moduleM
103
158
  const cached = materializedCache.get(cacheKey);
104
159
  if (cached)
105
160
  return cached;
106
- const spec = readApplySpec(modulePath);
161
+ const spec = resolveEffectiveApplySpec(readApplySpec(modulePath), loadWorkspaceRoutingProfile(workspacePath));
107
162
  const sourceModule = moduleMap[spec.source.module];
108
163
  if (!sourceModule) {
109
164
  throw new Error(`Apply spec in "${moduleName}" references source module "${spec.source.module}", ` +
@@ -0,0 +1,34 @@
1
+ import { loadConfigSyncFromDir } from '@pgpmjs/env';
2
+ import { mergeRoutingProfiles } from '@pgpmjs/types';
3
+ /**
4
+ * Load the workspace-level routing profile (the `portability` field of the
5
+ * workspace `pgpm.json` / `pgpm.config.js`). Returns `undefined` when the
6
+ * workspace has no config file or no profile.
7
+ */
8
+ export function loadWorkspaceRoutingProfile(workspacePath) {
9
+ let config;
10
+ try {
11
+ config = loadConfigSyncFromDir(workspacePath);
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ return config?.portability;
17
+ }
18
+ /**
19
+ * Resolve the routing profile effective for one apply spec: defaults →
20
+ * workspace `portability` → the spec's own routing keys, merged per key
21
+ * (inner scope wins; see {@link mergeRoutingProfiles}).
22
+ */
23
+ export function resolveEffectiveApplySpec(spec, workspaceProfile) {
24
+ if (!workspaceProfile)
25
+ return spec;
26
+ const merged = mergeRoutingProfiles(workspaceProfile, {
27
+ schemas: spec.schemas,
28
+ route: spec.route,
29
+ extensions: spec.extensions,
30
+ roles: spec.roles,
31
+ exclude: spec.exclude
32
+ });
33
+ return { ...spec, ...merged };
34
+ }
@@ -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);