@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.
- package/apply/apply-spec.js +22 -5
- package/apply/index.d.ts +1 -0
- package/apply/index.js +1 -0
- package/apply/materialize.js +59 -4
- package/apply/profile.d.ts +14 -0
- package/apply/profile.js +38 -0
- package/apply/types.d.ts +17 -69
- package/bundle/artifact.d.ts +2 -2
- package/bundle/artifact.js +2 -2
- package/core/class/pgpm.js +15 -3
- package/esm/apply/apply-spec.js +22 -5
- package/esm/apply/index.js +1 -0
- package/esm/apply/materialize.js +60 -5
- package/esm/apply/profile.js +34 -0
- package/esm/bundle/artifact.js +2 -2
- package/esm/core/class/pgpm.js +15 -3
- package/esm/extensions/compile.js +109 -0
- package/esm/extensions/index.js +4 -0
- package/esm/extensions/manifest.js +139 -0
- package/esm/extensions/resolve-install.js +70 -0
- package/esm/index.js +1 -1
- package/esm/migrate/clean.js +1 -2
- package/esm/projects/deploy.js +19 -4
- package/extensions/compile.d.ts +35 -0
- package/extensions/compile.js +113 -0
- package/extensions/index.d.ts +4 -0
- package/extensions/index.js +20 -0
- package/extensions/manifest.d.ts +78 -0
- package/extensions/manifest.js +144 -0
- package/extensions/resolve-install.d.ts +20 -0
- package/extensions/resolve-install.js +74 -0
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/migrate/clean.js +1 -2
- package/package.json +10 -10
- package/projects/deploy.js +19 -4
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,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 ?? [])
|
|
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
|
-
|
|
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;
|
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/bundle/artifact.d.ts
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
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
|
package/bundle/artifact.js
CHANGED
|
@@ -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
|
|
56
|
-
*
|
|
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
|
package/core/class/pgpm.js
CHANGED
|
@@ -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
|
|
1371
|
-
|
|
1372
|
-
|
|
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);
|
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 { 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 ?? [])
|
|
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
|
-
|
|
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
|
+
}
|
package/esm/bundle/artifact.js
CHANGED
|
@@ -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
|
|
49
|
-
*
|
|
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
|
package/esm/core/class/pgpm.js
CHANGED
|
@@ -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
|
|
1332
|
-
|
|
1333
|
-
|
|
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);
|