@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.
- package/apply/apply-spec.js +22 -5
- package/apply/index.d.ts +1 -0
- package/apply/index.js +1 -0
- package/apply/materialize.js +70 -4
- package/apply/profile.d.ts +14 -0
- package/apply/profile.js +38 -0
- package/apply/types.d.ts +17 -69
- package/esm/apply/apply-spec.js +22 -5
- package/esm/apply/index.js +1 -0
- package/esm/apply/materialize.js +71 -5
- package/esm/apply/profile.js +34 -0
- package/package.json +13 -13
package/apply/apply-spec.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
!
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
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
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);
|
package/apply/materialize.js
CHANGED
|
@@ -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 ?? [])
|
|
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
|
-
|
|
69
|
-
|
|
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;
|
package/apply/profile.js
ADDED
|
@@ -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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
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 =
|
|
125
|
-
/**
|
|
126
|
-
|
|
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.
|
package/esm/apply/apply-spec.js
CHANGED
|
@@ -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
|
-
|
|
59
|
-
!
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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) {
|
package/esm/apply/index.js
CHANGED
package/esm/apply/materialize.js
CHANGED
|
@@ -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 ?? [])
|
|
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
|
-
|
|
64
|
-
|
|
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.
|
|
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.
|
|
52
|
-
"@pgpmjs/bundle": "^0.12.
|
|
53
|
-
"@pgpmjs/env": "^2.39.
|
|
54
|
-
"@pgpmjs/logger": "^2.22.
|
|
55
|
-
"@pgpmjs/server-utils": "^3.23.
|
|
56
|
-
"@pgpmjs/slice": "^0.
|
|
57
|
-
"@pgpmjs/transform": "^0.
|
|
58
|
-
"@pgpmjs/types": "^2.
|
|
59
|
-
"csv-to-pg": "^4.8.
|
|
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.
|
|
65
|
-
"pg-env": "^1.26.
|
|
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": "
|
|
70
|
+
"gitHead": "a430897efa1488ec24fc636f0d5023207bba7ba7"
|
|
71
71
|
}
|