@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.
@@ -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
- if (typeof parsed.schemas !== 'object' ||
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
- if (!hasSchemas && !hasRoute) {
71
- throw new Error(`${specPath}: at least one of "schemas" (string → string map) or "route" (object routes) is required`);
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
@@ -1,3 +1,4 @@
1
1
  export * from './materialize';
2
2
  export * from './apply-spec';
3
+ export * from './reuse';
3
4
  export * from './types';
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);
@@ -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
- const cached = materializedCache.get(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);
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(modulePath, outDir);
128
+ materializedCache.set(cacheKey, outDir);
116
129
  return outDir;
117
130
  }
118
131
  /** Test seam: clear the per-process materialization cache. */
@@ -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;