@pgpmjs/core 7.13.0 → 7.15.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.d.ts +14 -0
- package/apply/apply-spec.js +165 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.js +20 -0
- package/apply/materialize.d.ts +40 -0
- package/apply/materialize.js +134 -0
- package/apply/reuse.d.ts +52 -0
- package/apply/reuse.js +226 -0
- package/apply/types.d.ts +172 -0
- package/apply/types.js +17 -0
- package/bundle/artifact.d.ts +35 -0
- package/bundle/artifact.js +96 -0
- package/bundle/deploy-bundled.d.ts +71 -0
- package/bundle/deploy-bundled.js +177 -0
- package/bundle/index.d.ts +2 -0
- package/bundle/index.js +4 -0
- package/core/class/pgpm.d.ts +20 -0
- package/core/class/pgpm.js +109 -62
- package/esm/apply/apply-spec.js +160 -0
- package/esm/apply/index.js +4 -0
- package/esm/apply/materialize.js +129 -0
- package/esm/apply/reuse.js +221 -0
- package/esm/apply/types.js +14 -0
- package/esm/bundle/artifact.js +89 -0
- package/esm/bundle/deploy-bundled.js +170 -0
- package/esm/bundle/index.js +4 -0
- package/esm/core/class/pgpm.js +109 -62
- package/esm/index.js +1 -0
- package/esm/packaging/package.js +8 -1
- package/esm/projects/deploy.js +30 -57
- package/esm/projects/revert.js +10 -3
- package/esm/projects/verify.js +10 -3
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +10 -9
- package/packaging/package.d.ts +7 -1
- package/packaging/package.js +8 -1
- package/projects/deploy.js +30 -57
- package/projects/revert.js +10 -3
- package/projects/verify.js +10 -3
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ResolvedApplySpec } from './types';
|
|
2
|
+
/** Whether a module directory is an apply-spec proxy module. */
|
|
3
|
+
export declare function hasApplySpec(moduleDir: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Read, validate, and normalize a proxy module's `pgpm.apply.json`.
|
|
6
|
+
*
|
|
7
|
+
* Normalizations: `source` is expanded to its object form and `name` defaults
|
|
8
|
+
* to the proxy directory's name.
|
|
9
|
+
*
|
|
10
|
+
* @throws when the file is missing, unparsable, or structurally invalid.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readApplySpec(moduleDir: string): ResolvedApplySpec;
|
|
13
|
+
/** In-memory core of {@link readApplySpec}. */
|
|
14
|
+
export declare function parseApplySpec(content: string, specPath: string): ResolvedApplySpec;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hasApplySpec = hasApplySpec;
|
|
4
|
+
exports.readApplySpec = readApplySpec;
|
|
5
|
+
exports.parseApplySpec = parseApplySpec;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const types_1 = require("./types");
|
|
9
|
+
/** Whether a module directory is an apply-spec proxy module. */
|
|
10
|
+
function hasApplySpec(moduleDir) {
|
|
11
|
+
return (0, fs_1.existsSync)((0, path_1.join)(moduleDir, types_1.APPLY_SPEC_FILE));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Read, validate, and normalize a proxy module's `pgpm.apply.json`.
|
|
15
|
+
*
|
|
16
|
+
* Normalizations: `source` is expanded to its object form and `name` defaults
|
|
17
|
+
* to the proxy directory's name.
|
|
18
|
+
*
|
|
19
|
+
* @throws when the file is missing, unparsable, or structurally invalid.
|
|
20
|
+
*/
|
|
21
|
+
function readApplySpec(moduleDir) {
|
|
22
|
+
const specPath = (0, path_1.join)(moduleDir, types_1.APPLY_SPEC_FILE);
|
|
23
|
+
if (!(0, fs_1.existsSync)(specPath)) {
|
|
24
|
+
throw new Error(`No ${types_1.APPLY_SPEC_FILE} found in ${moduleDir}`);
|
|
25
|
+
}
|
|
26
|
+
return parseApplySpec((0, fs_1.readFileSync)(specPath, 'utf-8'), specPath);
|
|
27
|
+
}
|
|
28
|
+
/** In-memory core of {@link readApplySpec}. */
|
|
29
|
+
function parseApplySpec(content, specPath) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(content);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
throw new Error(`Failed to parse ${specPath}: ${err instanceof Error ? err.message : err}`);
|
|
36
|
+
}
|
|
37
|
+
const source = typeof parsed?.source === 'string' ? { module: parsed.source } : parsed?.source;
|
|
38
|
+
if (!source || typeof source.module !== 'string' || !source.module) {
|
|
39
|
+
throw new Error(`${specPath}: "source" (source module name, or { "module": ... }) is required`);
|
|
40
|
+
}
|
|
41
|
+
const isSchemaMap = (value) => typeof value === 'object' &&
|
|
42
|
+
value !== null &&
|
|
43
|
+
!Array.isArray(value) &&
|
|
44
|
+
Object.keys(value).length > 0 &&
|
|
45
|
+
Object.entries(value).every(([from, to]) => typeof from === 'string' && typeof to === 'string' && !!from && !!to);
|
|
46
|
+
const hasSchemas = parsed.schemas !== undefined;
|
|
47
|
+
if (hasSchemas && !isSchemaMap(parsed.schemas)) {
|
|
48
|
+
throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`);
|
|
49
|
+
}
|
|
50
|
+
const ROUTE_KINDS = ['table', 'view', 'function', 'procedure', 'type'];
|
|
51
|
+
const hasRoute = parsed.route !== undefined;
|
|
52
|
+
if (hasRoute) {
|
|
53
|
+
if (!Array.isArray(parsed.route) || parsed.route.length === 0) {
|
|
54
|
+
throw new Error(`${specPath}: "route" must be a non-empty array of route entries`);
|
|
55
|
+
}
|
|
56
|
+
for (const entry of parsed.route) {
|
|
57
|
+
if (!entry ||
|
|
58
|
+
typeof entry !== 'object' ||
|
|
59
|
+
typeof entry.fromSchema !== 'string' ||
|
|
60
|
+
!entry.fromSchema ||
|
|
61
|
+
typeof entry.name !== 'string' ||
|
|
62
|
+
!entry.name ||
|
|
63
|
+
typeof entry.toSchema !== 'string' ||
|
|
64
|
+
!entry.toSchema ||
|
|
65
|
+
!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`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const hasReuse = parsed.reuse !== undefined;
|
|
71
|
+
if (hasReuse) {
|
|
72
|
+
const reuse = parsed.reuse;
|
|
73
|
+
if (typeof reuse !== 'object' || reuse === null || Array.isArray(reuse)) {
|
|
74
|
+
throw new Error(`${specPath}: "reuse" must be an object`);
|
|
75
|
+
}
|
|
76
|
+
if (!isSchemaMap(reuse.sharedSchema)) {
|
|
77
|
+
throw new Error(`${specPath}: "reuse.sharedSchema" must be a non-empty string → string map`);
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(reuse.perTenant) || reuse.perTenant.length === 0) {
|
|
80
|
+
throw new Error(`${specPath}: "reuse.perTenant" must be a non-empty array of seed objects`);
|
|
81
|
+
}
|
|
82
|
+
for (const seed of reuse.perTenant) {
|
|
83
|
+
if (!seed ||
|
|
84
|
+
typeof seed !== 'object' ||
|
|
85
|
+
typeof seed.fromSchema !== 'string' ||
|
|
86
|
+
!seed.fromSchema ||
|
|
87
|
+
typeof seed.name !== 'string' ||
|
|
88
|
+
!seed.name ||
|
|
89
|
+
!ROUTE_KINDS.includes(seed.kind)) {
|
|
90
|
+
throw new Error(`${specPath}: each "reuse.perTenant" seed needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name } as non-empty strings`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (reuse.sharedName !== undefined && (typeof reuse.sharedName !== 'string' || !reuse.sharedName)) {
|
|
94
|
+
throw new Error(`${specPath}: "reuse.sharedName" must be a non-empty string`);
|
|
95
|
+
}
|
|
96
|
+
if (!hasSchemas) {
|
|
97
|
+
throw new Error(`${specPath}: "reuse" requires "schemas" (the per-tenant source → target schema map)`);
|
|
98
|
+
}
|
|
99
|
+
for (const seed of reuse.perTenant) {
|
|
100
|
+
if (!(seed.fromSchema in parsed.schemas)) {
|
|
101
|
+
throw new Error(`${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no per-tenant ` +
|
|
102
|
+
`target in "schemas"`);
|
|
103
|
+
}
|
|
104
|
+
if (!(seed.fromSchema in reuse.sharedSchema)) {
|
|
105
|
+
throw new Error(`${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no shared ` +
|
|
106
|
+
`target in "reuse.sharedSchema"`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const hasExtensions = parsed.extensions !== undefined;
|
|
111
|
+
if (hasExtensions) {
|
|
112
|
+
const ext = parsed.extensions;
|
|
113
|
+
if (typeof ext !== 'object' || ext === null || Array.isArray(ext)) {
|
|
114
|
+
throw new Error(`${specPath}: "extensions" must be an object`);
|
|
115
|
+
}
|
|
116
|
+
const isSchemaOrNull = (v) => v === null || (typeof v === 'string' && !!v);
|
|
117
|
+
const hasRoutes = ext.routes !== undefined;
|
|
118
|
+
if (hasRoutes) {
|
|
119
|
+
if (typeof ext.routes !== 'object' || ext.routes === null || Array.isArray(ext.routes)) {
|
|
120
|
+
throw new Error(`${specPath}: "extensions.routes" must be an object keyed by extension name`);
|
|
121
|
+
}
|
|
122
|
+
for (const [name, route] of Object.entries(ext.routes)) {
|
|
123
|
+
if (!route || typeof route !== 'object' || !isSchemaOrNull(route.to)) {
|
|
124
|
+
throw new Error(`${specPath}: "extensions.routes.${name}" must be { to: schema | null, from?: (string|null)[] }`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (!('toSchema' in ext)) {
|
|
129
|
+
throw new Error(`${specPath}: "extensions" needs "toSchema" (schema | null) or "routes"`);
|
|
130
|
+
}
|
|
131
|
+
else if (!isSchemaOrNull(ext.toSchema)) {
|
|
132
|
+
throw new Error(`${specPath}: "extensions.toSchema" must be a non-empty string or null`);
|
|
133
|
+
}
|
|
134
|
+
if (ext.only !== undefined && (!Array.isArray(ext.only) || ext.only.some((e) => typeof e !== 'string' || !e))) {
|
|
135
|
+
throw new Error(`${specPath}: "extensions.only" must be an array of extension names`);
|
|
136
|
+
}
|
|
137
|
+
if (ext.serverVersion !== undefined && typeof ext.serverVersion !== 'number') {
|
|
138
|
+
throw new Error(`${specPath}: "extensions.serverVersion" must be a number`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const hasRoles = parsed.roles !== undefined;
|
|
142
|
+
if (hasRoles && !isSchemaMap(parsed.roles)) {
|
|
143
|
+
throw new Error(`${specPath}: "roles" must be a non-empty string → string map`);
|
|
144
|
+
}
|
|
145
|
+
if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles) {
|
|
146
|
+
throw new Error(`${specPath}: at least one of "schemas", "route", "extensions", or "roles" is required`);
|
|
147
|
+
}
|
|
148
|
+
const name = parsed.name ?? (0, path_1.basename)((0, path_1.dirname)(specPath));
|
|
149
|
+
if (typeof name !== 'string' || !name) {
|
|
150
|
+
throw new Error(`${specPath}: "name" must be a non-empty string`);
|
|
151
|
+
}
|
|
152
|
+
if (name === source.module) {
|
|
153
|
+
throw new Error(`${specPath}: apply module "${name}" cannot apply itself`);
|
|
154
|
+
}
|
|
155
|
+
const requires = parsed.requires;
|
|
156
|
+
if (requires !== undefined &&
|
|
157
|
+
(!Array.isArray(requires) || requires.some((r) => typeof r !== 'string' || !r))) {
|
|
158
|
+
throw new Error(`${specPath}: "requires" must be an array of module names`);
|
|
159
|
+
}
|
|
160
|
+
if (requires?.includes(source.module)) {
|
|
161
|
+
throw new Error(`${specPath}: "requires" must not include the source module "${source.module}" — ` +
|
|
162
|
+
`it is source material, not a runtime dependency`);
|
|
163
|
+
}
|
|
164
|
+
return { ...parsed, name, source, requires };
|
|
165
|
+
}
|
package/apply/index.d.ts
ADDED
package/apply/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./materialize"), exports);
|
|
18
|
+
__exportStar(require("./apply-spec"), exports);
|
|
19
|
+
__exportStar(require("./reuse"), exports);
|
|
20
|
+
__exportStar(require("./types"), exports);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { MigrationBundle } from '@pgpmjs/bundle';
|
|
2
|
+
import { ModuleMap } from '../modules/modules';
|
|
3
|
+
import { ResolvedApplySpec } from './types';
|
|
4
|
+
export interface MaterializeApplyOptions {
|
|
5
|
+
/** Directory of the source module being applied. */
|
|
6
|
+
sourceDir: string;
|
|
7
|
+
/** The apply spec (typically read from the proxy via {@link readApplySpec}). */
|
|
8
|
+
spec: ResolvedApplySpec;
|
|
9
|
+
/** Directory to materialize into (default: a fresh temp dir). */
|
|
10
|
+
outDir?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface MaterializeApplyResult {
|
|
13
|
+
/** The transpiled, content-addressed bundle that was materialized. */
|
|
14
|
+
bundle: MigrationBundle;
|
|
15
|
+
/** The deployable module directory the bundle was written to. */
|
|
16
|
+
outDir: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Transpile a source module per an apply spec and materialize the result as a
|
|
20
|
+
* deployable module directory carrying the instance's identity.
|
|
21
|
+
*
|
|
22
|
+
* Pure pipeline over existing primitives: `bundleFromModule` →
|
|
23
|
+
* `transpileBundle(makeSchemaTranspiler)` → `verifyBundle` →
|
|
24
|
+
* `materializeBundle`. Deterministic — identical source + spec always produce
|
|
25
|
+
* an identical bundle (and digest), which is what makes verify/revert able to
|
|
26
|
+
* re-derive the exact deployed artifact.
|
|
27
|
+
*/
|
|
28
|
+
export declare function materializeApplyModule(options: MaterializeApplyOptions): Promise<MaterializeApplyResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Deploy-path dispatch seam: given a resolved module's directory, return the
|
|
31
|
+
* directory the migration engine should actually run against.
|
|
32
|
+
*
|
|
33
|
+
* Non-proxy modules pass through unchanged. For proxy modules (directories
|
|
34
|
+
* carrying a `pgpm.apply.json`), the source module is located in the workspace
|
|
35
|
+
* module map, transpiled per the spec, and materialized to a cached temp dir
|
|
36
|
+
* carrying the instance's identity.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveEffectiveModulePath(moduleName: string, modulePath: string, moduleMap: ModuleMap, workspacePath: string): Promise<string>;
|
|
39
|
+
/** Test seam: clear the per-process materialization cache. */
|
|
40
|
+
export declare function clearApplyMaterializationCache(): void;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.materializeApplyModule = materializeApplyModule;
|
|
4
|
+
exports.resolveEffectiveModulePath = resolveEffectiveModulePath;
|
|
5
|
+
exports.clearApplyMaterializationCache = clearApplyMaterializationCache;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const os_1 = require("os");
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
const bundle_1 = require("@pgpmjs/bundle");
|
|
10
|
+
const transform_1 = require("@pgpmjs/transform");
|
|
11
|
+
const apply_spec_1 = require("./apply-spec");
|
|
12
|
+
const reuse_1 = require("./reuse");
|
|
13
|
+
// Functions that take a bare schema name as a string-literal first argument.
|
|
14
|
+
// The AST pass only remaps dotted literals ('schema.object'); these helpers
|
|
15
|
+
// (ubiquitous in verify scripts) name the schema alone, so a string-level
|
|
16
|
+
// pre-pass remaps them.
|
|
17
|
+
const SCHEMA_NAME_LITERAL_FUNCS = ['verify_schema', 'has_schema_privilege'];
|
|
18
|
+
const schemaNameLiteralPass = (content, schemaMapping) => {
|
|
19
|
+
const pattern = new RegExp(`\\b(${SCHEMA_NAME_LITERAL_FUNCS.join('|')})\\s*\\(\\s*'([^']+)'`, 'gi');
|
|
20
|
+
return content.replace(pattern, (match, _fn, schema) => {
|
|
21
|
+
const mapped = schemaMapping.get(schema);
|
|
22
|
+
return mapped ? match.replace(`'${schema}'`, `'${mapped}'`) : match;
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Transpile a source module per an apply spec and materialize the result as a
|
|
27
|
+
* deployable module directory carrying the instance's identity.
|
|
28
|
+
*
|
|
29
|
+
* Pure pipeline over existing primitives: `bundleFromModule` →
|
|
30
|
+
* `transpileBundle(makeSchemaTranspiler)` → `verifyBundle` →
|
|
31
|
+
* `materializeBundle`. Deterministic — identical source + spec always produce
|
|
32
|
+
* an identical bundle (and digest), which is what makes verify/revert able to
|
|
33
|
+
* re-derive the exact deployed artifact.
|
|
34
|
+
*/
|
|
35
|
+
async function materializeApplyModule(options) {
|
|
36
|
+
const { sourceDir, spec } = options;
|
|
37
|
+
const instanceName = spec.name;
|
|
38
|
+
// the schema transpiler's SQL/PLpgSQL parser is WASM-backed and needs a
|
|
39
|
+
// one-time async init (idempotent)
|
|
40
|
+
await (0, transform_1.loadModule)();
|
|
41
|
+
const source = (0, bundle_1.bundleFromModule)(sourceDir);
|
|
42
|
+
if (spec.source.bundleDigest && spec.source.bundleDigest !== source.manifest.digest) {
|
|
43
|
+
throw new Error(`Apply spec for "${instanceName}" pins source bundle digest ${spec.source.bundleDigest}, ` +
|
|
44
|
+
`but the installed source "${spec.source.module}" hashes to ${source.manifest.digest}. ` +
|
|
45
|
+
`Reinstall the pinned version or update the spec.`);
|
|
46
|
+
}
|
|
47
|
+
// Every distinct target schema is treated as "may already exist": apply
|
|
48
|
+
// deploys into consumer-chosen namespaces that can pre-exist (a shared schema,
|
|
49
|
+
// another tenant), so `CREATE SCHEMA` is emitted idempotently. This is what
|
|
50
|
+
// lets the same source apply cleanly whether or not the destination exists.
|
|
51
|
+
const targetSchemas = [
|
|
52
|
+
...new Set([
|
|
53
|
+
...Object.values(spec.schemas ?? {}),
|
|
54
|
+
...(spec.route ?? []).map(r => r.toSchema)
|
|
55
|
+
])
|
|
56
|
+
];
|
|
57
|
+
const { renameChange, transformScript, result } = (0, transform_1.makeSchemaTranspiler)({
|
|
58
|
+
schemaMap: spec.schemas,
|
|
59
|
+
routes: spec.route,
|
|
60
|
+
extensions: spec.extensions,
|
|
61
|
+
roles: spec.roles,
|
|
62
|
+
transform: {
|
|
63
|
+
prePasses: [schemaNameLiteralPass],
|
|
64
|
+
assumeSchemasExist: targetSchemas
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const transpiled = (0, bundle_1.transpileBundle)(source, {
|
|
68
|
+
renameChange,
|
|
69
|
+
transformScript,
|
|
70
|
+
renameModule: instanceName,
|
|
71
|
+
provenance: {
|
|
72
|
+
appliedFrom: spec.source.module,
|
|
73
|
+
...(spec.source.package ? { sourcePackage: spec.source.package } : {}),
|
|
74
|
+
...(spec.source.version ? { sourceVersion: spec.source.version } : {})
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
if (result.errors.length > 0) {
|
|
78
|
+
const detail = result.errors.map(e => `${e.file}: ${e.error}`).join('; ');
|
|
79
|
+
throw new Error(`Apply transpile of "${spec.source.module}" as "${instanceName}" failed: ${detail}`);
|
|
80
|
+
}
|
|
81
|
+
const issues = (0, bundle_1.verifyBundle)(transpiled);
|
|
82
|
+
if (issues.length > 0) {
|
|
83
|
+
throw new Error(`Transpiled bundle for "${instanceName}" failed integrity verification: ` +
|
|
84
|
+
issues.map(i => i.kind).join(', '));
|
|
85
|
+
}
|
|
86
|
+
const outDir = options.outDir ?? (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), `pgpm-apply-${instanceName}-`));
|
|
87
|
+
(0, bundle_1.materializeBundle)(transpiled, outDir);
|
|
88
|
+
return { bundle: transpiled, outDir };
|
|
89
|
+
}
|
|
90
|
+
// One materialization per proxy path per process: the pipeline is
|
|
91
|
+
// deterministic, so re-deriving within a process is pure waste.
|
|
92
|
+
const materializedCache = new Map();
|
|
93
|
+
/**
|
|
94
|
+
* Deploy-path dispatch seam: given a resolved module's directory, return the
|
|
95
|
+
* directory the migration engine should actually run against.
|
|
96
|
+
*
|
|
97
|
+
* Non-proxy modules pass through unchanged. For proxy modules (directories
|
|
98
|
+
* carrying a `pgpm.apply.json`), the source module is located in the workspace
|
|
99
|
+
* module map, transpiled per the spec, and materialized to a cached temp dir
|
|
100
|
+
* carrying the instance's identity.
|
|
101
|
+
*/
|
|
102
|
+
async function resolveEffectiveModulePath(moduleName, modulePath, moduleMap, workspacePath) {
|
|
103
|
+
if (!(0, apply_spec_1.hasApplySpec)(modulePath))
|
|
104
|
+
return modulePath;
|
|
105
|
+
// A reuse proxy resolves under two names (shared + per-tenant) from one
|
|
106
|
+
// directory, so the cache is keyed by the requested module name too.
|
|
107
|
+
const cacheKey = `${modulePath}::${moduleName}`;
|
|
108
|
+
const cached = materializedCache.get(cacheKey);
|
|
109
|
+
if (cached)
|
|
110
|
+
return cached;
|
|
111
|
+
const spec = (0, apply_spec_1.readApplySpec)(modulePath);
|
|
112
|
+
const sourceModule = moduleMap[spec.source.module];
|
|
113
|
+
if (!sourceModule) {
|
|
114
|
+
throw new Error(`Apply spec in "${moduleName}" references source module "${spec.source.module}", ` +
|
|
115
|
+
`which was not found in the workspace. Run \`pgpm install\` to install it` +
|
|
116
|
+
(spec.source.package ? ` (${spec.source.package})` : '') +
|
|
117
|
+
`.`);
|
|
118
|
+
}
|
|
119
|
+
const sourceDir = (0, path_1.resolve)(workspacePath, sourceModule.path);
|
|
120
|
+
if ((0, reuse_1.isReuseSpec)(spec)) {
|
|
121
|
+
const result = await (0, reuse_1.materializeReuseModule)({ sourceDir, spec });
|
|
122
|
+
materializedCache.set(`${modulePath}::${result.sharedName}`, result.shared.outDir);
|
|
123
|
+
materializedCache.set(`${modulePath}::${result.perTenantName}`, result.perTenant.outDir);
|
|
124
|
+
const outDir = moduleName === (0, reuse_1.resolveSharedModuleName)(spec) ? result.shared.outDir : result.perTenant.outDir;
|
|
125
|
+
return outDir;
|
|
126
|
+
}
|
|
127
|
+
const { outDir } = await materializeApplyModule({ sourceDir, spec });
|
|
128
|
+
materializedCache.set(cacheKey, outDir);
|
|
129
|
+
return outDir;
|
|
130
|
+
}
|
|
131
|
+
/** Test seam: clear the per-process materialization cache. */
|
|
132
|
+
function clearApplyMaterializationCache() {
|
|
133
|
+
materializedCache.clear();
|
|
134
|
+
}
|
package/apply/reuse.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { MigrationBundle } from '@pgpmjs/bundle';
|
|
2
|
+
import { ResolvedApplySpec } from './types';
|
|
3
|
+
/** A resolved apply spec that carries a reuse declaration. */
|
|
4
|
+
export type ResolvedReuseSpec = ResolvedApplySpec & {
|
|
5
|
+
reuse: NonNullable<ResolvedApplySpec['reuse']>;
|
|
6
|
+
};
|
|
7
|
+
/** Whether an apply spec is a reuse (shared/per-tenant split) spec. */
|
|
8
|
+
export declare function isReuseSpec(spec: ResolvedApplySpec): spec is ResolvedReuseSpec;
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic name of the shared module a reuse proxy expands into. Derived
|
|
11
|
+
* from the source module, the shared-schema mapping, and the per-tenant seed
|
|
12
|
+
* set — the exact inputs that determine the shared partition — so two tenant
|
|
13
|
+
* proxies over the same source/shared/seed configuration resolve to *one*
|
|
14
|
+
* shared module (deployed once), while any difference isolates them.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveSharedModuleName(spec: ResolvedReuseSpec): string;
|
|
17
|
+
export interface MaterializeReuseOptions {
|
|
18
|
+
/** Directory of the source module being applied. */
|
|
19
|
+
sourceDir: string;
|
|
20
|
+
/** The resolved reuse spec. */
|
|
21
|
+
spec: ResolvedReuseSpec;
|
|
22
|
+
/** Deployable directory for the shared module (default: a fresh temp dir). */
|
|
23
|
+
sharedOutDir?: string;
|
|
24
|
+
/** Deployable directory for the per-tenant module (default: a fresh temp dir). */
|
|
25
|
+
perTenantOutDir?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface MaterializeReuseResult {
|
|
28
|
+
sharedName: string;
|
|
29
|
+
perTenantName: string;
|
|
30
|
+
shared: {
|
|
31
|
+
bundle: MigrationBundle;
|
|
32
|
+
outDir: string;
|
|
33
|
+
};
|
|
34
|
+
perTenant: {
|
|
35
|
+
bundle: MigrationBundle;
|
|
36
|
+
outDir: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Expand a reuse proxy into two deployable modules: a shared module (objects the
|
|
41
|
+
* classifier proves tenant-independent, deployed once) and a per-tenant module
|
|
42
|
+
* (objects reachable from the per-tenant seeds, materialized per instance) that
|
|
43
|
+
* `requires` the shared one.
|
|
44
|
+
*
|
|
45
|
+
* Pipeline over existing primitives:
|
|
46
|
+
* `bundleFromModule` → `partitionModule` (classifier) →
|
|
47
|
+
* `transpileBundle(makeSchemaTranspiler)` routing shared objects to the shared
|
|
48
|
+
* schema and per-tenant objects to the per-tenant schema in *one* pass (so
|
|
49
|
+
* cross-boundary references resolve correctly) → `splitBundle` with a per-tenant
|
|
50
|
+
* schema bootstrap → `verifyBundle` → `materializeBundle`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function materializeReuseModule(options: MaterializeReuseOptions): Promise<MaterializeReuseResult>;
|
package/apply/reuse.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isReuseSpec = isReuseSpec;
|
|
4
|
+
exports.resolveSharedModuleName = resolveSharedModuleName;
|
|
5
|
+
exports.materializeReuseModule = materializeReuseModule;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const os_1 = require("os");
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
const ast_1 = require("@pgpmjs/ast");
|
|
10
|
+
const bundle_1 = require("@pgpmjs/bundle");
|
|
11
|
+
const slice_1 = require("@pgpmjs/slice");
|
|
12
|
+
const transform_1 = require("@pgpmjs/transform");
|
|
13
|
+
/** Whether an apply spec is a reuse (shared/per-tenant split) spec. */
|
|
14
|
+
function isReuseSpec(spec) {
|
|
15
|
+
return spec.reuse !== undefined;
|
|
16
|
+
}
|
|
17
|
+
/** pgpm change-path folder → object route kind. */
|
|
18
|
+
const FOLDER_KIND = {
|
|
19
|
+
tables: 'table',
|
|
20
|
+
views: 'view',
|
|
21
|
+
functions: 'function',
|
|
22
|
+
procedures: 'procedure',
|
|
23
|
+
types: 'type'
|
|
24
|
+
};
|
|
25
|
+
// Mirrors materialize.ts: bare-schema-name literal helpers the AST pass can't
|
|
26
|
+
// reach are remapped by a narrow string pre-pass.
|
|
27
|
+
const SCHEMA_NAME_LITERAL_FUNCS = ['verify_schema', 'has_schema_privilege'];
|
|
28
|
+
const schemaNameLiteralPass = (content, schemaMapping) => {
|
|
29
|
+
const pattern = new RegExp(`\\b(${SCHEMA_NAME_LITERAL_FUNCS.join('|')})\\s*\\(\\s*'([^']+)'`, 'gi');
|
|
30
|
+
return content.replace(pattern, (match, _fn, schema) => {
|
|
31
|
+
const mapped = schemaMapping.get(schema);
|
|
32
|
+
return mapped ? match.replace(`'${schema}'`, `'${mapped}'`) : match;
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
/** Parse a `schemas/<schema>/<folder>/<name>…` change path into its object identity. */
|
|
36
|
+
function parseObjectChange(change) {
|
|
37
|
+
const parts = change.split('/');
|
|
38
|
+
if (parts[0] !== 'schemas' || parts.length < 2)
|
|
39
|
+
return null;
|
|
40
|
+
const schema = parts[1];
|
|
41
|
+
const folder = parts[2];
|
|
42
|
+
if (folder === undefined || folder === 'schema')
|
|
43
|
+
return { schema, folder: 'schema' };
|
|
44
|
+
return { schema, folder, name: parts[3] };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Deterministic name of the shared module a reuse proxy expands into. Derived
|
|
48
|
+
* from the source module, the shared-schema mapping, and the per-tenant seed
|
|
49
|
+
* set — the exact inputs that determine the shared partition — so two tenant
|
|
50
|
+
* proxies over the same source/shared/seed configuration resolve to *one*
|
|
51
|
+
* shared module (deployed once), while any difference isolates them.
|
|
52
|
+
*/
|
|
53
|
+
function resolveSharedModuleName(spec) {
|
|
54
|
+
if (spec.reuse.sharedName)
|
|
55
|
+
return spec.reuse.sharedName;
|
|
56
|
+
const canonical = JSON.stringify({
|
|
57
|
+
source: spec.source.module,
|
|
58
|
+
sharedSchema: Object.entries(spec.reuse.sharedSchema).sort(([a], [b]) => a.localeCompare(b)),
|
|
59
|
+
seeds: spec.reuse.perTenant
|
|
60
|
+
.map(s => `${s.fromSchema}.${s.kind}.${s.name}`)
|
|
61
|
+
.sort((a, b) => a.localeCompare(b))
|
|
62
|
+
});
|
|
63
|
+
return `${spec.source.module}-shared-${(0, ast_1.hashString)(canonical).slice(0, 8)}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Expand a reuse proxy into two deployable modules: a shared module (objects the
|
|
67
|
+
* classifier proves tenant-independent, deployed once) and a per-tenant module
|
|
68
|
+
* (objects reachable from the per-tenant seeds, materialized per instance) that
|
|
69
|
+
* `requires` the shared one.
|
|
70
|
+
*
|
|
71
|
+
* Pipeline over existing primitives:
|
|
72
|
+
* `bundleFromModule` → `partitionModule` (classifier) →
|
|
73
|
+
* `transpileBundle(makeSchemaTranspiler)` routing shared objects to the shared
|
|
74
|
+
* schema and per-tenant objects to the per-tenant schema in *one* pass (so
|
|
75
|
+
* cross-boundary references resolve correctly) → `splitBundle` with a per-tenant
|
|
76
|
+
* schema bootstrap → `verifyBundle` → `materializeBundle`.
|
|
77
|
+
*/
|
|
78
|
+
async function materializeReuseModule(options) {
|
|
79
|
+
const { sourceDir, spec } = options;
|
|
80
|
+
const perTenantName = spec.name;
|
|
81
|
+
const sharedName = resolveSharedModuleName(spec);
|
|
82
|
+
const reuse = spec.reuse;
|
|
83
|
+
await (0, transform_1.loadModule)();
|
|
84
|
+
const source = (0, bundle_1.bundleFromModule)(sourceDir);
|
|
85
|
+
if (spec.source.bundleDigest && spec.source.bundleDigest !== source.manifest.digest) {
|
|
86
|
+
throw new Error(`Apply spec for "${perTenantName}" pins source bundle digest ${spec.source.bundleDigest}, ` +
|
|
87
|
+
`but the installed source "${spec.source.module}" hashes to ${source.manifest.digest}. ` +
|
|
88
|
+
`Reinstall the pinned version or update the spec.`);
|
|
89
|
+
}
|
|
90
|
+
// 1. Classify: which source changes must be per-tenant (a seed or a dependent).
|
|
91
|
+
const partition = (0, slice_1.partitionModule)({
|
|
92
|
+
moduleDir: sourceDir,
|
|
93
|
+
seedObjects: reuse.perTenant.map(s => ({ schema: s.fromSchema, name: s.name }))
|
|
94
|
+
});
|
|
95
|
+
const unknownSeeds = partition.warnings.filter(w => w.kind === 'unknown-seed');
|
|
96
|
+
if (unknownSeeds.length > 0) {
|
|
97
|
+
throw new Error(`Reuse spec for "${perTenantName}": seed object(s) not found in source "${spec.source.module}": ` +
|
|
98
|
+
unknownSeeds.map(w => w.change).join(', '));
|
|
99
|
+
}
|
|
100
|
+
// 2. Object routes: every per-tenant change's object goes to the per-tenant
|
|
101
|
+
// target; everything else defaults (schemaMap) to the shared target. A
|
|
102
|
+
// single router keeps cross-boundary references correct (a per-tenant
|
|
103
|
+
// function landing in the tenant schema still references the shared helper
|
|
104
|
+
// in the shared schema).
|
|
105
|
+
const routes = [];
|
|
106
|
+
for (const change of partition.perTenant) {
|
|
107
|
+
const obj = parseObjectChange(change);
|
|
108
|
+
if (!obj || obj.folder === 'schema' || !obj.name) {
|
|
109
|
+
throw new Error(`Reuse spec for "${perTenantName}": per-tenant change "${change}" is not an object change ` +
|
|
110
|
+
`and cannot be routed to a per-tenant schema; refine the seeds so it stays shared, or ` +
|
|
111
|
+
`it names an unsupported object kind`);
|
|
112
|
+
}
|
|
113
|
+
const kind = FOLDER_KIND[obj.folder];
|
|
114
|
+
if (!kind) {
|
|
115
|
+
throw new Error(`Reuse spec for "${perTenantName}": per-tenant change "${change}" has unsupported object ` +
|
|
116
|
+
`folder "${obj.folder}"`);
|
|
117
|
+
}
|
|
118
|
+
const toSchema = spec.schemas[obj.schema];
|
|
119
|
+
if (!toSchema) {
|
|
120
|
+
throw new Error(`Reuse spec for "${perTenantName}": per-tenant object in schema "${obj.schema}" has no ` +
|
|
121
|
+
`per-tenant target in "schemas"`);
|
|
122
|
+
}
|
|
123
|
+
routes.push({ fromSchema: obj.schema, kind, name: obj.name, toSchema });
|
|
124
|
+
}
|
|
125
|
+
const assumeSchemasExist = [
|
|
126
|
+
...new Set([...Object.values(reuse.sharedSchema), ...Object.values(spec.schemas)])
|
|
127
|
+
];
|
|
128
|
+
const provenance = {
|
|
129
|
+
appliedFrom: spec.source.module,
|
|
130
|
+
...(spec.source.package ? { sourcePackage: spec.source.package } : {}),
|
|
131
|
+
...(spec.source.version ? { sourceVersion: spec.source.version } : {})
|
|
132
|
+
};
|
|
133
|
+
// 3. Transpile once: schema default = shared target, per-object overrides =
|
|
134
|
+
// per-tenant target.
|
|
135
|
+
const main = (0, transform_1.makeSchemaTranspiler)({
|
|
136
|
+
schemaMap: reuse.sharedSchema,
|
|
137
|
+
routes,
|
|
138
|
+
extensions: spec.extensions,
|
|
139
|
+
roles: spec.roles,
|
|
140
|
+
transform: { prePasses: [schemaNameLiteralPass], assumeSchemasExist }
|
|
141
|
+
});
|
|
142
|
+
const transpiled = (0, bundle_1.transpileBundle)(source, {
|
|
143
|
+
renameChange: main.renameChange,
|
|
144
|
+
transformScript: main.transformScript,
|
|
145
|
+
provenance
|
|
146
|
+
});
|
|
147
|
+
if (main.result.errors.length > 0) {
|
|
148
|
+
const detail = main.result.errors.map(e => `${e.file}: ${e.error}`).join('; ');
|
|
149
|
+
throw new Error(`Reuse transpile of "${spec.source.module}" for "${perTenantName}" failed: ${detail}`);
|
|
150
|
+
}
|
|
151
|
+
// The partition is in *source* change names; splitBundle works on the
|
|
152
|
+
// transpiled bundle, so map the per-tenant set through the same rename.
|
|
153
|
+
const perTenantTranspiled = [...partition.perTenant].map(main.renameChange);
|
|
154
|
+
// 4. Per-tenant schema bootstrap: the source's `CREATE SCHEMA` lands in the
|
|
155
|
+
// shared module (shared target), but each tenant also needs its own
|
|
156
|
+
// schema. Duplicate it — transpiled to the per-tenant target — into the
|
|
157
|
+
// per-tenant module, re-pointing per-tenant dependencies off the shared
|
|
158
|
+
// schema onto this local one.
|
|
159
|
+
const bootstrap = buildSchemaBootstraps(source, spec, main.renameChange, assumeSchemasExist);
|
|
160
|
+
const { shared, perTenant } = (0, bundle_1.splitBundle)(transpiled, {
|
|
161
|
+
perTenantChanges: perTenantTranspiled,
|
|
162
|
+
sharedName,
|
|
163
|
+
perTenantName,
|
|
164
|
+
perTenantBootstrap: bootstrap
|
|
165
|
+
});
|
|
166
|
+
for (const [label, bundle] of [
|
|
167
|
+
[sharedName, shared],
|
|
168
|
+
[perTenantName, perTenant]
|
|
169
|
+
]) {
|
|
170
|
+
const issues = (0, bundle_1.verifyBundle)(bundle);
|
|
171
|
+
if (issues.length > 0) {
|
|
172
|
+
throw new Error(`Reuse bundle "${label}" failed integrity verification: ${issues.map(i => i.kind).join(', ')}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const sharedOutDir = options.sharedOutDir ?? (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), `pgpm-apply-${sharedName}-`));
|
|
176
|
+
const perTenantOutDir = options.perTenantOutDir ?? (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), `pgpm-apply-${perTenantName}-`));
|
|
177
|
+
(0, bundle_1.materializeBundle)(shared, sharedOutDir);
|
|
178
|
+
(0, bundle_1.materializeBundle)(perTenant, perTenantOutDir);
|
|
179
|
+
return {
|
|
180
|
+
sharedName,
|
|
181
|
+
perTenantName,
|
|
182
|
+
shared: { bundle: shared, outDir: sharedOutDir },
|
|
183
|
+
perTenant: { bundle: perTenant, outDir: perTenantOutDir }
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Build a per-tenant `CREATE SCHEMA` bootstrap for each source schema that both
|
|
188
|
+
* creates a schema and has per-tenant objects. The scripts are the source
|
|
189
|
+
* schema-creation change transpiled to the per-tenant target; `replacesShared`
|
|
190
|
+
* is the shared-schema change the per-tenant objects would otherwise depend on.
|
|
191
|
+
*/
|
|
192
|
+
function buildSchemaBootstraps(source, spec, renameSharedChange, assumeSchemasExist) {
|
|
193
|
+
const bootstraps = [];
|
|
194
|
+
for (const [fromSchema, perTenantTarget] of Object.entries(spec.schemas)) {
|
|
195
|
+
const sharedTarget = spec.reuse.sharedSchema[fromSchema];
|
|
196
|
+
if (!sharedTarget || sharedTarget === perTenantTarget)
|
|
197
|
+
continue;
|
|
198
|
+
const sourceSchemaChange = `schemas/${fromSchema}/schema`;
|
|
199
|
+
const schemaChange = source.changes.find(c => c.name === sourceSchemaChange);
|
|
200
|
+
if (!schemaChange)
|
|
201
|
+
continue; // source doesn't create this schema; nothing to duplicate
|
|
202
|
+
// Transpile just the schema-creation change to the per-tenant target.
|
|
203
|
+
const single = {
|
|
204
|
+
...source,
|
|
205
|
+
manifest: { ...source.manifest, changeCount: 1, deployOrder: [sourceSchemaChange] },
|
|
206
|
+
changes: [schemaChange]
|
|
207
|
+
};
|
|
208
|
+
const t = (0, transform_1.makeSchemaTranspiler)({
|
|
209
|
+
schemaMap: { [fromSchema]: perTenantTarget },
|
|
210
|
+
transform: { prePasses: [schemaNameLiteralPass], assumeSchemasExist }
|
|
211
|
+
});
|
|
212
|
+
const perTenantSchema = (0, bundle_1.transpileBundle)(single, {
|
|
213
|
+
renameChange: t.renameChange,
|
|
214
|
+
transformScript: t.transformScript
|
|
215
|
+
}).changes[0];
|
|
216
|
+
bootstraps.push({
|
|
217
|
+
name: `schemas/${perTenantTarget}/schema`,
|
|
218
|
+
dependencies: [],
|
|
219
|
+
deploy: perTenantSchema.deploy?.sql ?? null,
|
|
220
|
+
revert: perTenantSchema.revert?.sql ?? null,
|
|
221
|
+
verify: perTenantSchema.verify?.sql ?? null,
|
|
222
|
+
replacesShared: renameSharedChange(sourceSchemaChange)
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return bootstraps;
|
|
226
|
+
}
|