@pgpmjs/core 7.14.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.js +84 -9
- package/apply/index.d.ts +1 -0
- package/apply/index.js +1 -0
- package/apply/materialize.js +15 -2
- package/apply/reuse.d.ts +52 -0
- package/apply/reuse.js +226 -0
- package/apply/types.d.ts +89 -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.js +39 -54
- package/esm/apply/apply-spec.js +84 -9
- package/esm/apply/index.js +1 -0
- package/esm/apply/materialize.js +15 -2
- package/esm/apply/reuse.js +221 -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 +39 -54
- package/esm/packaging/package.js +8 -1
- package/esm/projects/deploy.js +17 -54
- package/package.json +10 -10
- package/packaging/package.d.ts +7 -1
- package/packaging/package.js +8 -1
- package/projects/deploy.js +17 -54
package/apply/apply-spec.js
CHANGED
|
@@ -38,14 +38,14 @@ function parseApplySpec(content, specPath) {
|
|
|
38
38
|
if (!source || typeof source.module !== 'string' || !source.module) {
|
|
39
39
|
throw new Error(`${specPath}: "source" (source module name, or { "module": ... }) is required`);
|
|
40
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);
|
|
41
46
|
const hasSchemas = parsed.schemas !== undefined;
|
|
42
|
-
if (hasSchemas) {
|
|
43
|
-
|
|
44
|
-
Array.isArray(parsed.schemas) ||
|
|
45
|
-
Object.keys(parsed.schemas).length === 0 ||
|
|
46
|
-
Object.entries(parsed.schemas).some(([from, to]) => typeof from !== 'string' || typeof to !== 'string' || !from || !to)) {
|
|
47
|
-
throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`);
|
|
48
|
-
}
|
|
47
|
+
if (hasSchemas && !isSchemaMap(parsed.schemas)) {
|
|
48
|
+
throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`);
|
|
49
49
|
}
|
|
50
50
|
const ROUTE_KINDS = ['table', 'view', 'function', 'procedure', 'type'];
|
|
51
51
|
const hasRoute = parsed.route !== undefined;
|
|
@@ -67,8 +67,83 @@ function parseApplySpec(content, specPath) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
|
|
71
|
-
|
|
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`);
|
|
72
147
|
}
|
|
73
148
|
const name = parsed.name ?? (0, path_1.basename)((0, path_1.dirname)(specPath));
|
|
74
149
|
if (typeof name !== 'string' || !name) {
|
package/apply/index.d.ts
CHANGED
package/apply/index.js
CHANGED
|
@@ -16,4 +16,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./materialize"), exports);
|
|
18
18
|
__exportStar(require("./apply-spec"), exports);
|
|
19
|
+
__exportStar(require("./reuse"), exports);
|
|
19
20
|
__exportStar(require("./types"), exports);
|
package/apply/materialize.js
CHANGED
|
@@ -9,6 +9,7 @@ const path_1 = require("path");
|
|
|
9
9
|
const bundle_1 = require("@pgpmjs/bundle");
|
|
10
10
|
const transform_1 = require("@pgpmjs/transform");
|
|
11
11
|
const apply_spec_1 = require("./apply-spec");
|
|
12
|
+
const reuse_1 = require("./reuse");
|
|
12
13
|
// Functions that take a bare schema name as a string-literal first argument.
|
|
13
14
|
// The AST pass only remaps dotted literals ('schema.object'); these helpers
|
|
14
15
|
// (ubiquitous in verify scripts) name the schema alone, so a string-level
|
|
@@ -56,6 +57,8 @@ async function materializeApplyModule(options) {
|
|
|
56
57
|
const { renameChange, transformScript, result } = (0, transform_1.makeSchemaTranspiler)({
|
|
57
58
|
schemaMap: spec.schemas,
|
|
58
59
|
routes: spec.route,
|
|
60
|
+
extensions: spec.extensions,
|
|
61
|
+
roles: spec.roles,
|
|
59
62
|
transform: {
|
|
60
63
|
prePasses: [schemaNameLiteralPass],
|
|
61
64
|
assumeSchemasExist: targetSchemas
|
|
@@ -99,7 +102,10 @@ const materializedCache = new Map();
|
|
|
99
102
|
async function resolveEffectiveModulePath(moduleName, modulePath, moduleMap, workspacePath) {
|
|
100
103
|
if (!(0, apply_spec_1.hasApplySpec)(modulePath))
|
|
101
104
|
return modulePath;
|
|
102
|
-
|
|
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);
|
|
103
109
|
if (cached)
|
|
104
110
|
return cached;
|
|
105
111
|
const spec = (0, apply_spec_1.readApplySpec)(modulePath);
|
|
@@ -111,8 +117,15 @@ async function resolveEffectiveModulePath(moduleName, modulePath, moduleMap, wor
|
|
|
111
117
|
`.`);
|
|
112
118
|
}
|
|
113
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
|
+
}
|
|
114
127
|
const { outDir } = await materializeApplyModule({ sourceDir, spec });
|
|
115
|
-
materializedCache.set(
|
|
128
|
+
materializedCache.set(cacheKey, outDir);
|
|
116
129
|
return outDir;
|
|
117
130
|
}
|
|
118
131
|
/** Test seam: clear the per-process materialization cache. */
|
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
|
+
}
|
package/apply/types.d.ts
CHANGED
|
@@ -49,6 +49,79 @@ export interface ApplyRouteEntry {
|
|
|
49
49
|
/** Target schema the object is routed to (e.g. `reporting`). */
|
|
50
50
|
toSchema: string;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* A per-tenant seed object in a reuse spec: the objects that must be
|
|
54
|
+
* materialized once *per tenant*. `@pgpmjs/slice`'s `partitionModule` starts
|
|
55
|
+
* from these seeds and marks every change that transitively reaches one as
|
|
56
|
+
* per-tenant; everything else is proven shared and emitted once. Expressed as
|
|
57
|
+
* separate properties (no dotted identity strings), matching {@link ApplyRouteEntry}.
|
|
58
|
+
*/
|
|
59
|
+
export interface ApplyReuseSeed {
|
|
60
|
+
/** Source schema the seed object is defined in (e.g. `catalog`). */
|
|
61
|
+
fromSchema: string;
|
|
62
|
+
/** Object namespace: `table`/`view` → relation, `procedure` → function. */
|
|
63
|
+
kind: 'table' | 'view' | 'function' | 'procedure' | 'type';
|
|
64
|
+
/** Unqualified object name (e.g. `products`). */
|
|
65
|
+
name: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Reuse declaration: split the source into a *shared* module (deployed once,
|
|
69
|
+
* reused by every tenant via ordinary `requires`) and a *per-tenant* module
|
|
70
|
+
* (materialized per instance). The classifier proves the split from the
|
|
71
|
+
* `perTenant` seeds; a shared object that would depend on a per-tenant one is
|
|
72
|
+
* rejected as unsound rather than silently deployed.
|
|
73
|
+
*/
|
|
74
|
+
export interface ApplyReuseSpec {
|
|
75
|
+
/**
|
|
76
|
+
* Source schema → *shared* target schema. Shared objects land here; this
|
|
77
|
+
* mapping must be stable across tenant instances so the shared module has a
|
|
78
|
+
* deterministic identity and deploys exactly once.
|
|
79
|
+
*/
|
|
80
|
+
sharedSchema: Record<string, string>;
|
|
81
|
+
/** Per-tenant seed objects (non-empty). */
|
|
82
|
+
perTenant: ApplyReuseSeed[];
|
|
83
|
+
/**
|
|
84
|
+
* Optional explicit shared-module name. Defaults to a deterministic name
|
|
85
|
+
* derived from the source module and the shared-schema mapping, so two tenant
|
|
86
|
+
* proxies over the same source + shared mapping resolve to one shared module.
|
|
87
|
+
*/
|
|
88
|
+
sharedName?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Extension-routing declaration in a `pgpm.apply.json`: where the transpiled
|
|
92
|
+
* instance should install extensions and resolve the symbols they provide
|
|
93
|
+
* (e.g. isolate `pgcrypto` in a dedicated `extensions` schema, qualifying bare
|
|
94
|
+
* `crypt(...)` calls, or the reverse). A distinct dimension from schema
|
|
95
|
+
* routing — driven by a version-aware symbol inventory, not by the objects the
|
|
96
|
+
* SQL itself creates. Forwarded to `@pgpmjs/transform`'s extension router.
|
|
97
|
+
*/
|
|
98
|
+
export interface ApplyExtensionsSpec {
|
|
99
|
+
/**
|
|
100
|
+
* Install the matched extensions and route their provided symbols to this
|
|
101
|
+
* schema. `null` strips qualification (rely on `search_path`). Ignored when
|
|
102
|
+
* `routes` is given.
|
|
103
|
+
*/
|
|
104
|
+
toSchema?: string | null;
|
|
105
|
+
/** With `toSchema`: limit to these extensions (default: every inventoried one). */
|
|
106
|
+
only?: string[];
|
|
107
|
+
/**
|
|
108
|
+
* With `toSchema`: which source qualifications to rewrite (a `null` entry
|
|
109
|
+
* also rewrites bare references). Defaults to `public` + bare.
|
|
110
|
+
*/
|
|
111
|
+
from?: (string | null)[];
|
|
112
|
+
/**
|
|
113
|
+
* Advanced: explicit per-extension routes (`{ "<ext>": { "to": "<schema>|null",
|
|
114
|
+
* "from"?: [...] } }`). Overrides `toSchema`/`only`/`from`.
|
|
115
|
+
*/
|
|
116
|
+
routes?: Record<string, {
|
|
117
|
+
to: string | null;
|
|
118
|
+
from?: (string | null)[];
|
|
119
|
+
}>;
|
|
120
|
+
/** Target PostgreSQL major version, for core-graduation awareness. */
|
|
121
|
+
serverVersion?: number;
|
|
122
|
+
}
|
|
123
|
+
/** Role-name translation: source role name → target role name. */
|
|
124
|
+
export type ApplyRolesSpec = Record<string, string>;
|
|
52
125
|
/** The parsed, normalized `pgpm.apply.json` spec. */
|
|
53
126
|
export interface PgpmApplySpec {
|
|
54
127
|
/**
|
|
@@ -69,6 +142,22 @@ export interface PgpmApplySpec {
|
|
|
69
142
|
* table into a tenant schema and a function into a shared schema).
|
|
70
143
|
*/
|
|
71
144
|
route?: ApplyRouteEntry[];
|
|
145
|
+
/**
|
|
146
|
+
* Reuse declaration: split shared vs per-tenant objects (see
|
|
147
|
+
* {@link ApplyReuseSpec}). When present, this proxy expands into two modules —
|
|
148
|
+
* a shared module and a per-tenant module that `requires` it.
|
|
149
|
+
*/
|
|
150
|
+
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;
|
|
72
161
|
/**
|
|
73
162
|
* Runtime requires of the transpiled output (native extensions and other
|
|
74
163
|
* modules). Defaults to the source module's own requires.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CreateBundleOptions, MigrationBundle } from '@pgpmjs/bundle';
|
|
2
|
+
/**
|
|
3
|
+
* Artifact file name for a module's stored bundle: it lives beside the packaged
|
|
4
|
+
* `sql/<name>--<version>.sql` as `sql/<name>--<version>.bundle.tar.gz`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function bundleArtifactFileName(name: string, version: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Locate a module's stored bundle artifact, if it has one.
|
|
9
|
+
*
|
|
10
|
+
* Prefers the version declared in the module's `.control` file, then falls back
|
|
11
|
+
* to any single `sql/*.bundle.tar.gz` present — so a module packaged at a
|
|
12
|
+
* different version still gets the fast path instead of silently degrading.
|
|
13
|
+
* Returns `null` when the module ships no artifact.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveBundleArtifactPath(moduleDir: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Build the executable form of a module's bundle: the content-addressed AST
|
|
18
|
+
* snapshot plus, per change, the deploy SQL with transaction / `CREATE
|
|
19
|
+
* EXTENSION` statements stripped (`cleanSql`).
|
|
20
|
+
*
|
|
21
|
+
* That pre-computation is the whole point of the artifact — the parse/deparse
|
|
22
|
+
* work moves from every deploy to the one-time package step, while the digests
|
|
23
|
+
* still cover the exact bytes that will be executed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildExecutableBundle(moduleDir: string, options?: CreateBundleOptions): Promise<MigrationBundle>;
|
|
26
|
+
/**
|
|
27
|
+
* Emit a module's stored bundle artifact into `sql/`, returning its path.
|
|
28
|
+
*/
|
|
29
|
+
export declare function writeBundleArtifact(moduleDir: string, version: string, options?: CreateBundleOptions): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Read a module's stored bundle artifact, or `null` when it has none or the
|
|
32
|
+
* archive cannot be read. Never throws — a broken artifact must degrade to the
|
|
33
|
+
* normal packaging path, not fail the deploy.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readBundleArtifact(moduleDir: string): MigrationBundle | null;
|