@pgpmjs/core 7.16.0 → 7.17.2

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,53 @@ 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 dropped *as a change* — removed from the plan and
54
+ // deploy order rather than emitted as an empty script (empty scripts collide
55
+ // on the deploy ledger's script-hash uniqueness). Its verify/revert scripts
56
+ // target dropped objects the classifier can't always see (bare DROPs, catalog
57
+ // probes), which is exactly why the whole change is dropped, not just blanked.
58
+ const excludedChanges = new Set();
59
+ for (const change of source.changes) {
60
+ if (!change.deploy)
61
+ continue;
62
+ const stripped = (0, slice_1.stripSubsystemSql)(change.deploy.sql, selector);
63
+ const survivors = stripped.result.kept.filter(i => !stripped.dropped.includes(i) && stripped.result.statements[i].nodeTag !== 'TransactionStmt');
64
+ if (stripped.dropped.length > 0 && survivors.length === 0) {
65
+ excludedChanges.add(change.name);
66
+ }
67
+ }
68
+ // Surviving changes may still contain stray subsystem statements (a change
69
+ // that both creates a survivor and touches the excluded subsystem); strip
70
+ // those in place. Fully-excluded changes are dropped via `excludedChanges`.
71
+ const strip = (sql) => (0, slice_1.stripSubsystemSql)(sql, selector).sql;
72
+ return { strip, excludedChanges };
73
+ }
25
74
  /**
26
75
  * Transpile a source module per an apply spec and materialize the result as a
27
76
  * deployable module directory carrying the instance's identity.
@@ -51,9 +100,14 @@ async function materializeApplyModule(options) {
51
100
  const targetSchemas = [
52
101
  ...new Set([
53
102
  ...Object.values(spec.schemas ?? {}),
54
- ...(spec.route ?? []).map(r => r.toSchema)
103
+ ...(spec.route ?? [])
104
+ .map(r => r.toSchema)
105
+ .filter((s) => typeof s === 'string')
55
106
  ])
56
107
  ];
108
+ const stripSubsystem = spec.exclude
109
+ ? makeSubsystemStripper(source, spec, instanceName)
110
+ : undefined;
57
111
  const { renameChange, transformScript, result } = (0, transform_1.makeSchemaTranspiler)({
58
112
  schemaMap: spec.schemas,
59
113
  routes: spec.route,
@@ -64,9 +118,21 @@ async function materializeApplyModule(options) {
64
118
  assumeSchemasExist: targetSchemas
65
119
  }
66
120
  });
121
+ // Excluded changes are dropped from the artifact entirely (plan + deploy
122
+ // order + dependency refs); surviving changes get their stray subsystem
123
+ // statements stripped before the namespace transform.
67
124
  const transpiled = (0, bundle_1.transpileBundle)(source, {
68
- renameChange,
69
- transformScript,
125
+ excludeChange: stripSubsystem
126
+ ? (name) => stripSubsystem.excludedChanges.has(name)
127
+ : undefined,
128
+ // Excluded changes keep their source identity so the plan-prune (which
129
+ // matches source names) can find them; survivors route normally.
130
+ renameChange: stripSubsystem
131
+ ? (name) => (stripSubsystem.excludedChanges.has(name) ? name : renameChange(name))
132
+ : renameChange,
133
+ transformScript: stripSubsystem
134
+ ? (sql, ctx) => transformScript(stripSubsystem.strip(sql), ctx)
135
+ : transformScript,
70
136
  renameModule: instanceName,
71
137
  provenance: {
72
138
  appliedFrom: spec.source.module,
@@ -108,7 +174,7 @@ async function resolveEffectiveModulePath(moduleName, modulePath, moduleMap, wor
108
174
  const cached = materializedCache.get(cacheKey);
109
175
  if (cached)
110
176
  return cached;
111
- const spec = (0, apply_spec_1.readApplySpec)(modulePath);
177
+ const spec = (0, profile_1.resolveEffectiveApplySpec)((0, apply_spec_1.readApplySpec)(modulePath), (0, profile_1.loadWorkspaceRoutingProfile)(workspacePath));
112
178
  const sourceModule = moduleMap[spec.source.module];
113
179
  if (!sourceModule) {
114
180
  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.
@@ -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 { 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,53 @@ 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 dropped *as a change* — removed from the plan and
49
+ // deploy order rather than emitted as an empty script (empty scripts collide
50
+ // on the deploy ledger's script-hash uniqueness). Its verify/revert scripts
51
+ // target dropped objects the classifier can't always see (bare DROPs, catalog
52
+ // probes), which is exactly why the whole change is dropped, not just blanked.
53
+ const excludedChanges = new Set();
54
+ for (const change of source.changes) {
55
+ if (!change.deploy)
56
+ continue;
57
+ const stripped = stripSubsystemSql(change.deploy.sql, selector);
58
+ const survivors = stripped.result.kept.filter(i => !stripped.dropped.includes(i) && stripped.result.statements[i].nodeTag !== 'TransactionStmt');
59
+ if (stripped.dropped.length > 0 && survivors.length === 0) {
60
+ excludedChanges.add(change.name);
61
+ }
62
+ }
63
+ // Surviving changes may still contain stray subsystem statements (a change
64
+ // that both creates a survivor and touches the excluded subsystem); strip
65
+ // those in place. Fully-excluded changes are dropped via `excludedChanges`.
66
+ const strip = (sql) => stripSubsystemSql(sql, selector).sql;
67
+ return { strip, excludedChanges };
68
+ }
20
69
  /**
21
70
  * Transpile a source module per an apply spec and materialize the result as a
22
71
  * deployable module directory carrying the instance's identity.
@@ -46,9 +95,14 @@ export async function materializeApplyModule(options) {
46
95
  const targetSchemas = [
47
96
  ...new Set([
48
97
  ...Object.values(spec.schemas ?? {}),
49
- ...(spec.route ?? []).map(r => r.toSchema)
98
+ ...(spec.route ?? [])
99
+ .map(r => r.toSchema)
100
+ .filter((s) => typeof s === 'string')
50
101
  ])
51
102
  ];
103
+ const stripSubsystem = spec.exclude
104
+ ? makeSubsystemStripper(source, spec, instanceName)
105
+ : undefined;
52
106
  const { renameChange, transformScript, result } = makeSchemaTranspiler({
53
107
  schemaMap: spec.schemas,
54
108
  routes: spec.route,
@@ -59,9 +113,21 @@ export async function materializeApplyModule(options) {
59
113
  assumeSchemasExist: targetSchemas
60
114
  }
61
115
  });
116
+ // Excluded changes are dropped from the artifact entirely (plan + deploy
117
+ // order + dependency refs); surviving changes get their stray subsystem
118
+ // statements stripped before the namespace transform.
62
119
  const transpiled = transpileBundle(source, {
63
- renameChange,
64
- transformScript,
120
+ excludeChange: stripSubsystem
121
+ ? (name) => stripSubsystem.excludedChanges.has(name)
122
+ : undefined,
123
+ // Excluded changes keep their source identity so the plan-prune (which
124
+ // matches source names) can find them; survivors route normally.
125
+ renameChange: stripSubsystem
126
+ ? (name) => (stripSubsystem.excludedChanges.has(name) ? name : renameChange(name))
127
+ : renameChange,
128
+ transformScript: stripSubsystem
129
+ ? (sql, ctx) => transformScript(stripSubsystem.strip(sql), ctx)
130
+ : transformScript,
65
131
  renameModule: instanceName,
66
132
  provenance: {
67
133
  appliedFrom: spec.source.module,
@@ -103,7 +169,7 @@ export async function resolveEffectiveModulePath(moduleName, modulePath, moduleM
103
169
  const cached = materializedCache.get(cacheKey);
104
170
  if (cached)
105
171
  return cached;
106
- const spec = readApplySpec(modulePath);
172
+ const spec = resolveEffectiveApplySpec(readApplySpec(modulePath), loadWorkspaceRoutingProfile(workspacePath));
107
173
  const sourceModule = moduleMap[spec.source.module];
108
174
  if (!sourceModule) {
109
175
  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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgpmjs/core",
3
- "version": "7.16.0",
3
+ "version": "7.17.2",
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.3",
52
- "@pgpmjs/bundle": "^0.12.1",
53
- "@pgpmjs/env": "^2.39.0",
54
- "@pgpmjs/logger": "^2.22.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
- "csv-to-pg": "^4.8.0",
51
+ "@pgpmjs/ast": "^0.9.5",
52
+ "@pgpmjs/bundle": "^0.12.4",
53
+ "@pgpmjs/env": "^2.39.2",
54
+ "@pgpmjs/logger": "^2.22.1",
55
+ "@pgpmjs/server-utils": "^3.23.4",
56
+ "@pgpmjs/slice": "^0.11.2",
57
+ "@pgpmjs/transform": "^0.12.2",
58
+ "@pgpmjs/types": "^2.48.1",
59
+ "csv-to-pg": "^4.8.1",
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.2",
65
- "pg-env": "^1.26.0",
64
+ "pg-cache": "^3.23.4",
65
+ "pg-env": "^1.26.1",
66
66
  "pgsql-deparser": "^18.1.1",
67
67
  "pgsql-parser": "^18.1.1",
68
68
  "yanse": "^0.2.1"
69
69
  },
70
- "gitHead": "0286d994d98717cb9b28842233d2db6d1e37c48e"
70
+ "gitHead": "a430897efa1488ec24fc636f0d5023207bba7ba7"
71
71
  }