@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.
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.bundleArtifactFileName = bundleArtifactFileName;
4
+ exports.resolveBundleArtifactPath = resolveBundleArtifactPath;
5
+ exports.buildExecutableBundle = buildExecutableBundle;
6
+ exports.writeBundleArtifact = writeBundleArtifact;
7
+ exports.readBundleArtifact = readBundleArtifact;
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
10
+ const files_1 = require("@pgpmjs/ast/files");
11
+ const bundle_1 = require("@pgpmjs/bundle");
12
+ const clean_1 = require("../migrate/clean");
13
+ /**
14
+ * Artifact file name for a module's stored bundle: it lives beside the packaged
15
+ * `sql/<name>--<version>.sql` as `sql/<name>--<version>.bundle.tar.gz`.
16
+ */
17
+ function bundleArtifactFileName(name, version) {
18
+ return `${name}--${version}${bundle_1.BUNDLE_ARCHIVE_EXTENSION}`;
19
+ }
20
+ /**
21
+ * Locate a module's stored bundle artifact, if it has one.
22
+ *
23
+ * Prefers the version declared in the module's `.control` file, then falls back
24
+ * to any single `sql/*.bundle.tar.gz` present — so a module packaged at a
25
+ * different version still gets the fast path instead of silently degrading.
26
+ * Returns `null` when the module ships no artifact.
27
+ */
28
+ function resolveBundleArtifactPath(moduleDir) {
29
+ const sqlDir = (0, path_1.join)(moduleDir, 'sql');
30
+ if (!(0, fs_1.existsSync)(sqlDir))
31
+ return null;
32
+ let name;
33
+ try {
34
+ name = (0, files_1.getExtensionName)(moduleDir);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ const controlPath = (0, path_1.join)(moduleDir, `${name}.control`);
40
+ if ((0, fs_1.existsSync)(controlPath)) {
41
+ const { version } = (0, files_1.parseControlContent)((0, fs_1.readFileSync)(controlPath, 'utf-8'));
42
+ if (version) {
43
+ const versioned = (0, path_1.join)(sqlDir, bundleArtifactFileName(name, version));
44
+ if ((0, fs_1.existsSync)(versioned))
45
+ return versioned;
46
+ }
47
+ }
48
+ const candidates = (0, fs_1.readdirSync)(sqlDir).filter(file => file.startsWith(`${name}--`) && file.endsWith(bundle_1.BUNDLE_ARCHIVE_EXTENSION));
49
+ if (candidates.length !== 1)
50
+ return null;
51
+ return (0, path_1.join)(sqlDir, candidates[0]);
52
+ }
53
+ /**
54
+ * Build the executable form of a module's bundle: the content-addressed AST
55
+ * snapshot plus, per change, the deploy SQL with transaction / `CREATE
56
+ * EXTENSION` statements stripped (`cleanSql`).
57
+ *
58
+ * That pre-computation is the whole point of the artifact — the parse/deparse
59
+ * work moves from every deploy to the one-time package step, while the digests
60
+ * still cover the exact bytes that will be executed.
61
+ */
62
+ async function buildExecutableBundle(moduleDir, options) {
63
+ const bundle = (0, bundle_1.bundleFromModule)(moduleDir, options);
64
+ const execSql = {};
65
+ for (const change of bundle.changes) {
66
+ if (!change.deploy)
67
+ continue;
68
+ execSql[change.name] = await (0, clean_1.cleanSql)(change.deploy.sql, false, '$EOFCODE$');
69
+ }
70
+ return (0, bundle_1.withExecutableSql)(bundle, execSql);
71
+ }
72
+ /**
73
+ * Emit a module's stored bundle artifact into `sql/`, returning its path.
74
+ */
75
+ async function writeBundleArtifact(moduleDir, version, options) {
76
+ const bundle = await buildExecutableBundle(moduleDir, options);
77
+ const outPath = (0, path_1.join)(moduleDir, 'sql', bundleArtifactFileName(bundle.manifest.name, version));
78
+ (0, bundle_1.writeBundleArchiveFile)(bundle, outPath);
79
+ return outPath;
80
+ }
81
+ /**
82
+ * Read a module's stored bundle artifact, or `null` when it has none or the
83
+ * archive cannot be read. Never throws — a broken artifact must degrade to the
84
+ * normal packaging path, not fail the deploy.
85
+ */
86
+ function readBundleArtifact(moduleDir) {
87
+ const artifactPath = resolveBundleArtifactPath(moduleDir);
88
+ if (!artifactPath)
89
+ return null;
90
+ try {
91
+ return (0, bundle_1.readBundleArchiveFile)(artifactPath);
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
@@ -0,0 +1,71 @@
1
+ import { BundleChange } from '@pgpmjs/bundle';
2
+ import { PgConfig } from 'pg-env';
3
+ /**
4
+ * Why the one-shot path could not be used, so the caller falls back to the
5
+ * per-change migration path.
6
+ */
7
+ export type BundledDeploySkipReason = 'no-executable-sql' | 'unsupported-hash-method';
8
+ /** How the executable bundle for a module was obtained. */
9
+ export type BundleSource = 'artifact' | 'packaged';
10
+ export interface BundledDeployResult {
11
+ /** Module (package) name from the bundle manifest. */
12
+ name: string;
13
+ /** Where the executable bundle came from. */
14
+ source: BundleSource;
15
+ /** Changes executed and recorded on this run, in deploy order. */
16
+ deployed: string[];
17
+ /** Changes already present in the ledger with a matching hash. */
18
+ skipped: string[];
19
+ }
20
+ export interface BundledDeployOptions {
21
+ config: PgConfig;
22
+ /** Target database (defaults to `config.database`). */
23
+ database?: string;
24
+ /** Record ledger rows without executing the DDL. */
25
+ logOnly?: boolean;
26
+ /** Wrap execution + ledger writes in one transaction. Default true. */
27
+ useTransaction?: boolean;
28
+ /**
29
+ * Ledger hash scheme in force. The bundle's per-change digests are sha256 of
30
+ * the deploy script's bytes, i.e. identical to `PgpmMigrate`'s `content`
31
+ * scheme; the `ast` scheme is not reproducible here, so it is reported as a
32
+ * skip and the caller falls back.
33
+ */
34
+ hashMethod?: 'content' | 'ast';
35
+ }
36
+ /** Thrown when the ledger already holds a change with different content. */
37
+ export declare class BundledDeployConflictError extends Error {
38
+ readonly packageName: string;
39
+ readonly changeName: string;
40
+ constructor(packageName: string, changeName: string);
41
+ }
42
+ /**
43
+ * The ledger hash for a change: the sha256 of the deploy script bytes.
44
+ *
45
+ * This is deliberately the same value `PgpmMigrate.calculateScriptHash` derives
46
+ * from `deploy/<change>.sql` under the default `content` hash method, so a
47
+ * change hashed at package time and the hash recorded/compared at deploy time
48
+ * agree — the one-shot and per-change paths are interchangeable and idempotent
49
+ * against each other.
50
+ */
51
+ export declare function ledgerHashForChange(change: BundleChange): string | null;
52
+ /**
53
+ * Deploy a module in one shot **and** record the migration ledger — the `fast`
54
+ * deploy strategy.
55
+ *
56
+ * Pending changes' deploy SQL is concatenated and executed as a single
57
+ * statement (the speed of the old fast path), then one `pgpm_migrate.changes`
58
+ * row per change is bulk-inserted with the deploy script's own sha256 as
59
+ * `script_hash` — one round-trip, instead of an `isDeployed` + `readScript` +
60
+ * `CALL pgpm_migrate.deploy` per change. Because the ledger is written, the
61
+ * path is idempotent and resumable: re-deploys skip changes whose stored hash
62
+ * matches, and a same-name/different-content change raises
63
+ * {@link BundledDeployConflictError} exactly as the per-change path does.
64
+ *
65
+ * Returns a {@link BundledDeploySkipReason} (rather than throwing) when it
66
+ * cannot honour the requested semantics, so the caller falls back to the
67
+ * per-change migration path. Genuine SQL failures still throw.
68
+ */
69
+ export declare function deployModuleFast(moduleDir: string, options: BundledDeployOptions): Promise<BundledDeployResult | BundledDeploySkipReason>;
70
+ /** True when the value is a real deploy result rather than a skip reason. */
71
+ export declare function isBundledDeployResult(value: BundledDeployResult | BundledDeploySkipReason): value is BundledDeployResult;
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BundledDeployConflictError = void 0;
4
+ exports.ledgerHashForChange = ledgerHashForChange;
5
+ exports.deployModuleFast = deployModuleFast;
6
+ exports.isBundledDeployResult = isBundledDeployResult;
7
+ const logger_1 = require("@pgpmjs/logger");
8
+ const bundle_1 = require("@pgpmjs/bundle");
9
+ const pg_cache_1 = require("pg-cache");
10
+ const client_1 = require("../migrate/client");
11
+ const artifact_1 = require("./artifact");
12
+ const log = new logger_1.Logger('deploy-fast');
13
+ /** Thrown when the ledger already holds a change with different content. */
14
+ class BundledDeployConflictError extends Error {
15
+ packageName;
16
+ changeName;
17
+ constructor(packageName, changeName) {
18
+ super(`Change ${changeName} already deployed in package ${packageName} with different content`);
19
+ this.packageName = packageName;
20
+ this.changeName = changeName;
21
+ this.name = 'BundledDeployConflictError';
22
+ }
23
+ }
24
+ exports.BundledDeployConflictError = BundledDeployConflictError;
25
+ /**
26
+ * The ledger hash for a change: the sha256 of the deploy script bytes.
27
+ *
28
+ * This is deliberately the same value `PgpmMigrate.calculateScriptHash` derives
29
+ * from `deploy/<change>.sql` under the default `content` hash method, so a
30
+ * change hashed at package time and the hash recorded/compared at deploy time
31
+ * agree — the one-shot and per-change paths are interchangeable and idempotent
32
+ * against each other.
33
+ */
34
+ function ledgerHashForChange(change) {
35
+ return change.deploy?.digest ?? null;
36
+ }
37
+ /**
38
+ * Load a module's executable bundle: its stored artifact when one verifies,
39
+ * otherwise built from `deploy/` on the fly.
40
+ *
41
+ * The artifact is purely an optimization — it removes the read+parse work, not
42
+ * a capability. Whatever the source, the resulting bundle is byte-identical in
43
+ * what it executes and in the hashes it records, so a module without an
44
+ * artifact deploys exactly the same way, just slower.
45
+ */
46
+ async function loadExecutableBundle(moduleDir) {
47
+ const stored = (0, artifact_1.readBundleArtifact)(moduleDir);
48
+ if (stored) {
49
+ const issues = (0, bundle_1.verifyBundle)(stored);
50
+ if (issues.length === 0) {
51
+ return { bundle: stored, source: 'artifact' };
52
+ }
53
+ log.warn(`⚠️ Bundle artifact for ${stored.manifest.name} failed verification ` +
54
+ `(${issues.map(i => i.kind).join(', ')}); rebuilding from deploy/.`);
55
+ }
56
+ return { bundle: await (0, artifact_1.buildExecutableBundle)(moduleDir), source: 'packaged' };
57
+ }
58
+ /**
59
+ * Deploy a module in one shot **and** record the migration ledger — the `fast`
60
+ * deploy strategy.
61
+ *
62
+ * Pending changes' deploy SQL is concatenated and executed as a single
63
+ * statement (the speed of the old fast path), then one `pgpm_migrate.changes`
64
+ * row per change is bulk-inserted with the deploy script's own sha256 as
65
+ * `script_hash` — one round-trip, instead of an `isDeployed` + `readScript` +
66
+ * `CALL pgpm_migrate.deploy` per change. Because the ledger is written, the
67
+ * path is idempotent and resumable: re-deploys skip changes whose stored hash
68
+ * matches, and a same-name/different-content change raises
69
+ * {@link BundledDeployConflictError} exactly as the per-change path does.
70
+ *
71
+ * Returns a {@link BundledDeploySkipReason} (rather than throwing) when it
72
+ * cannot honour the requested semantics, so the caller falls back to the
73
+ * per-change migration path. Genuine SQL failures still throw.
74
+ */
75
+ async function deployModuleFast(moduleDir, options) {
76
+ if ((options.hashMethod ?? 'content') !== 'content') {
77
+ return 'unsupported-hash-method';
78
+ }
79
+ const { bundle, source } = await loadExecutableBundle(moduleDir);
80
+ const executable = bundle.changes.filter(change => change.deploy);
81
+ if (executable.length === 0 || executable.some(change => !change.exec)) {
82
+ return 'no-executable-sql';
83
+ }
84
+ const packageName = bundle.manifest.name;
85
+ const config = options.database
86
+ ? { ...options.config, database: options.database }
87
+ : options.config;
88
+ // Ensures the pgpm_migrate schema/procedures exist, exactly as the
89
+ // per-change deploy path does.
90
+ await new client_1.PgpmMigrate(config, { hashMethod: 'content' }).initialize();
91
+ const pool = (0, pg_cache_1.getPgPool)(config);
92
+ const existing = await pool.query('SELECT change_name, script_hash FROM pgpm_migrate.changes WHERE package = $1', [packageName]);
93
+ const deployedHashes = new Map(existing.rows.map(row => [row.change_name, row.script_hash]));
94
+ const pending = [];
95
+ const skipped = [];
96
+ for (const change of executable) {
97
+ const hash = ledgerHashForChange(change);
98
+ const recorded = deployedHashes.get(change.name);
99
+ if (recorded === undefined) {
100
+ pending.push(change);
101
+ continue;
102
+ }
103
+ if (recorded !== hash) {
104
+ throw new BundledDeployConflictError(packageName, change.name);
105
+ }
106
+ skipped.push(change.name);
107
+ }
108
+ if (pending.length === 0) {
109
+ log.info(`⚡ ${packageName}: all ${skipped.length} change(s) already deployed.`);
110
+ return { name: packageName, source, deployed: [], skipped };
111
+ }
112
+ const changeNames = pending.map(change => change.name);
113
+ const hashes = pending.map(change => ledgerHashForChange(change));
114
+ const depChanges = [];
115
+ const depRequires = [];
116
+ for (const change of pending) {
117
+ for (const dep of new Set(change.dependencies)) {
118
+ depChanges.push(change.name);
119
+ depRequires.push(dep.includes(':') ? dep : `${packageName}:${dep}`);
120
+ }
121
+ }
122
+ const deploySql = pending.map(change => change.exec.sql).join('\n');
123
+ const client = await pool.connect();
124
+ const useTransaction = options.useTransaction ?? true;
125
+ try {
126
+ if (useTransaction)
127
+ await client.query('BEGIN');
128
+ if (!options.logOnly) {
129
+ await client.query(deploySql);
130
+ }
131
+ await client.query('INSERT INTO pgpm_migrate.packages (package) VALUES ($1) ON CONFLICT (package) DO NOTHING', [packageName]);
132
+ await client.query(`WITH input AS (
133
+ SELECT change_name, script_hash
134
+ FROM unnest($2::text[], $3::text[]) AS t(change_name, script_hash)
135
+ ), inserted AS (
136
+ INSERT INTO pgpm_migrate.changes (change_id, change_name, package, script_hash)
137
+ SELECT encode(sha256(($1 || change_name || script_hash)::bytea), 'hex'),
138
+ change_name, $1, script_hash
139
+ FROM input
140
+ ON CONFLICT (package, change_name) DO NOTHING
141
+ RETURNING change_id, change_name
142
+ ), deps AS (
143
+ INSERT INTO pgpm_migrate.dependencies (change_id, requires)
144
+ SELECT inserted.change_id, d.requires
145
+ FROM unnest($4::text[], $5::text[]) AS d(change_name, requires)
146
+ JOIN inserted ON inserted.change_name = d.change_name
147
+ ON CONFLICT DO NOTHING
148
+ RETURNING change_id
149
+ )
150
+ INSERT INTO pgpm_migrate.events (event_type, change_name, package)
151
+ SELECT 'deploy', change_name, $1 FROM inserted`, [packageName, changeNames, hashes, depChanges, depRequires]);
152
+ if (useTransaction)
153
+ await client.query('COMMIT');
154
+ }
155
+ catch (error) {
156
+ if (useTransaction) {
157
+ try {
158
+ await client.query('ROLLBACK');
159
+ }
160
+ catch {
161
+ // the connection is already unusable; surface the original error
162
+ }
163
+ }
164
+ throw error;
165
+ }
166
+ finally {
167
+ client.release();
168
+ }
169
+ log.success(`⚡ ${packageName}: ${changeNames.length} change(s) ${options.logOnly ? 'logged' : 'deployed'} ` +
170
+ `from ${source === 'artifact' ? 'bundle artifact' : 'packaged deploy/'} ` +
171
+ `(${skipped.length} already deployed).`);
172
+ return { name: packageName, source, deployed: changeNames, skipped };
173
+ }
174
+ /** True when the value is a real deploy result rather than a skip reason. */
175
+ function isBundledDeployResult(value) {
176
+ return typeof value !== 'string';
177
+ }
package/bundle/index.d.ts CHANGED
@@ -11,3 +11,5 @@
11
11
  export * from '@pgpmjs/bundle';
12
12
  export * from './apply';
13
13
  export * from './apply-envelope';
14
+ export * from './artifact';
15
+ export * from './deploy-bundled';
package/bundle/index.js CHANGED
@@ -31,3 +31,7 @@ __exportStar(require("@pgpmjs/bundle"), exports);
31
31
  __exportStar(require("./apply"), exports);
32
32
  // applyEnvelope: schema apply + data/fixtures part replay for a BundleEnvelope.
33
33
  __exportStar(require("./apply-envelope"), exports);
34
+ // The stored `sql/<name>--<version>.bundle.tar.gz` artifact: emit + resolve.
35
+ __exportStar(require("./artifact"), exports);
36
+ // The bundle-backed deploy fast path (one-shot execute + ledger).
37
+ __exportStar(require("./deploy-bundled"), exports);
@@ -50,6 +50,7 @@ const pg_cache_1 = require("pg-cache");
50
50
  const yanse_1 = __importDefault(require("yanse"));
51
51
  const materialize_1 = require("../../apply/materialize");
52
52
  const apply_spec_1 = require("../../apply/apply-spec");
53
+ const reuse_1 = require("../../apply/reuse");
53
54
  const types_2 = require("../../apply/types");
54
55
  const extensions_1 = require("../../extensions/extensions");
55
56
  const files_1 = require("@pgpmjs/ast/files");
@@ -60,7 +61,7 @@ const parser_1 = require("@pgpmjs/ast/files/plan/parser");
60
61
  const validators_1 = require("@pgpmjs/ast/files/plan/validators");
61
62
  const client_1 = require("../../migrate/client");
62
63
  const modules_1 = require("../../modules/modules");
63
- const package_1 = require("../../packaging/package");
64
+ const deploy_bundled_1 = require("../../bundle/deploy-bundled");
64
65
  const sync_versions_1 = require("../../packaging/sync-versions");
65
66
  const deps_1 = require("../../resolution/deps");
66
67
  const fs_2 = require("../../utils/fs");
@@ -313,13 +314,30 @@ class PgpmPackage {
313
314
  if (moduleMap[name])
314
315
  continue;
315
316
  const source = moduleMap[spec.source.module];
316
- moduleMap[name] = {
317
- path: path_1.default
318
- .dirname(path_1.default.relative(this.workspacePath, file))
319
- .replace(/\\/g, '/'),
320
- requires: spec.requires ?? source?.requires ?? [],
321
- version: spec.version ?? source?.version ?? '0.0.1'
322
- };
317
+ const relPath = path_1.default
318
+ .dirname(path_1.default.relative(this.workspacePath, file))
319
+ .replace(/\\/g, '/');
320
+ const baseRequires = spec.requires ?? source?.requires ?? [];
321
+ const version = spec.version ?? source?.version ?? '0.0.1';
322
+ if ((0, reuse_1.isReuseSpec)(spec)) {
323
+ // A reuse proxy expands into two modules that both materialize from
324
+ // this one directory: a shared module (deployed once, reused across
325
+ // tenants) and the per-tenant module that `requires` it. Multiple
326
+ // tenant proxies over the same source/shared/seed config resolve to
327
+ // the same deterministic shared name, so it is added at most once.
328
+ const sharedName = (0, reuse_1.resolveSharedModuleName)(spec);
329
+ if (!moduleMap[sharedName]) {
330
+ moduleMap[sharedName] = { path: relPath, requires: baseRequires, version };
331
+ }
332
+ moduleMap[name] = {
333
+ path: relPath,
334
+ requires: [...baseRequires, sharedName],
335
+ version
336
+ };
337
+ }
338
+ else {
339
+ moduleMap[name] = { path: relPath, requires: baseRequires, version };
340
+ }
323
341
  }
324
342
  return moduleMap;
325
343
  }
@@ -1334,12 +1352,6 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1334
1352
  const log = new logger_1.Logger('deploy');
1335
1353
  const { name, toChange } = this.parsePackageTarget(target);
1336
1354
  if (recursive) {
1337
- // Cache for fast deployment
1338
- const deployFastCache = {};
1339
- const getCacheKey = (pg, name, database) => {
1340
- const { host, port, user } = pg ?? {};
1341
- return `${host}:${port}:${user}:${database}:${name}`;
1342
- };
1343
1355
  const modules = this.getModuleMap();
1344
1356
  let extensions;
1345
1357
  if (name === null) {
@@ -1362,49 +1374,22 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1362
1374
  else {
1363
1375
  const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
1364
1376
  log.info(`📂 Deploying local module: ${extension}`);
1365
- if (opts.deployment.fast) {
1366
- const cacheKey = getCacheKey(opts.pg, extension, opts.pg.database);
1367
- if (opts.deployment.cache && deployFastCache[cacheKey]) {
1368
- log.warn(`⚡ Using cached pkg for ${extension}.`);
1369
- await pgPool.query(deployFastCache[cacheKey].sql);
1377
+ if (opts.deployment.fast || opts.deployment.bundled) {
1378
+ // Fast strategy: one-shot execution plus a bulk-written
1379
+ // pgpm_migrate ledger, from the module's verified bundle artifact
1380
+ // when it has one and from `deploy/` when it does not.
1381
+ const outcome = await (0, deploy_bundled_1.deployModuleFast)(modulePath, {
1382
+ config: opts.pg,
1383
+ logOnly: opts.deployment.logOnly,
1384
+ useTransaction: opts.deployment.useTx,
1385
+ hashMethod: opts.deployment.hashMethod
1386
+ });
1387
+ if ((0, deploy_bundled_1.isBundledDeployResult)(outcome)) {
1370
1388
  continue;
1371
1389
  }
1372
- let pkg;
1373
- try {
1374
- pkg = await (0, package_1.packageModule)(modulePath, {
1375
- usePlan: opts.deployment.usePlan,
1376
- extension: false
1377
- });
1378
- }
1379
- catch (err) {
1380
- const errorLines = [];
1381
- errorLines.push(`❌ Failed to package module "${extension}" at path: ${modulePath}`);
1382
- errorLines.push(` Module Path: ${modulePath}`);
1383
- errorLines.push(` Workspace Path: ${this.workspacePath}`);
1384
- errorLines.push(` Error Code: ${err.code || 'N/A'}`);
1385
- errorLines.push(` Error Message: ${err.message || 'Unknown error'}`);
1386
- if (err.code === 'ENOENT') {
1387
- errorLines.push('💡 Hint: File or directory not found. Check if the module path is correct.');
1388
- }
1389
- else if (err.code === 'EACCES') {
1390
- errorLines.push('💡 Hint: Permission denied. Check file permissions.');
1391
- }
1392
- else if (err.message && err.message.includes('pgpm.plan')) {
1393
- errorLines.push('💡 Hint: pgpm.plan file issue. Check if the plan file exists and is valid.');
1394
- }
1395
- log.error(errorLines.join('\n'));
1396
- console.error(err);
1397
- throw types_1.errors.DEPLOYMENT_FAILED({
1398
- type: 'Deployment',
1399
- module: extension
1400
- });
1401
- }
1402
- await pgPool.query(pkg.sql);
1403
- if (opts.deployment.cache) {
1404
- deployFastCache[cacheKey] = pkg;
1405
- }
1390
+ log.info(`↩️ Fast deploy unavailable for ${extension} (${outcome}); using per-change path.`);
1406
1391
  }
1407
- else {
1392
+ {
1408
1393
  try {
1409
1394
  const client = new client_1.PgpmMigrate(opts.pg);
1410
1395
  // Only apply toChange to the target module, not its dependencies
@@ -33,14 +33,14 @@ export function parseApplySpec(content, specPath) {
33
33
  if (!source || typeof source.module !== 'string' || !source.module) {
34
34
  throw new Error(`${specPath}: "source" (source module name, or { "module": ... }) is required`);
35
35
  }
36
+ const isSchemaMap = (value) => typeof value === 'object' &&
37
+ value !== null &&
38
+ !Array.isArray(value) &&
39
+ Object.keys(value).length > 0 &&
40
+ Object.entries(value).every(([from, to]) => typeof from === 'string' && typeof to === 'string' && !!from && !!to);
36
41
  const hasSchemas = parsed.schemas !== undefined;
37
- if (hasSchemas) {
38
- if (typeof parsed.schemas !== 'object' ||
39
- Array.isArray(parsed.schemas) ||
40
- Object.keys(parsed.schemas).length === 0 ||
41
- Object.entries(parsed.schemas).some(([from, to]) => typeof from !== 'string' || typeof to !== 'string' || !from || !to)) {
42
- throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`);
43
- }
42
+ if (hasSchemas && !isSchemaMap(parsed.schemas)) {
43
+ throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`);
44
44
  }
45
45
  const ROUTE_KINDS = ['table', 'view', 'function', 'procedure', 'type'];
46
46
  const hasRoute = parsed.route !== undefined;
@@ -62,8 +62,83 @@ export function parseApplySpec(content, specPath) {
62
62
  }
63
63
  }
64
64
  }
65
- if (!hasSchemas && !hasRoute) {
66
- throw new Error(`${specPath}: at least one of "schemas" (string → string map) or "route" (object routes) is required`);
65
+ const hasReuse = parsed.reuse !== undefined;
66
+ if (hasReuse) {
67
+ const reuse = parsed.reuse;
68
+ if (typeof reuse !== 'object' || reuse === null || Array.isArray(reuse)) {
69
+ throw new Error(`${specPath}: "reuse" must be an object`);
70
+ }
71
+ if (!isSchemaMap(reuse.sharedSchema)) {
72
+ throw new Error(`${specPath}: "reuse.sharedSchema" must be a non-empty string → string map`);
73
+ }
74
+ if (!Array.isArray(reuse.perTenant) || reuse.perTenant.length === 0) {
75
+ throw new Error(`${specPath}: "reuse.perTenant" must be a non-empty array of seed objects`);
76
+ }
77
+ for (const seed of reuse.perTenant) {
78
+ if (!seed ||
79
+ typeof seed !== 'object' ||
80
+ typeof seed.fromSchema !== 'string' ||
81
+ !seed.fromSchema ||
82
+ typeof seed.name !== 'string' ||
83
+ !seed.name ||
84
+ !ROUTE_KINDS.includes(seed.kind)) {
85
+ throw new Error(`${specPath}: each "reuse.perTenant" seed needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name } as non-empty strings`);
86
+ }
87
+ }
88
+ if (reuse.sharedName !== undefined && (typeof reuse.sharedName !== 'string' || !reuse.sharedName)) {
89
+ throw new Error(`${specPath}: "reuse.sharedName" must be a non-empty string`);
90
+ }
91
+ if (!hasSchemas) {
92
+ throw new Error(`${specPath}: "reuse" requires "schemas" (the per-tenant source → target schema map)`);
93
+ }
94
+ for (const seed of reuse.perTenant) {
95
+ if (!(seed.fromSchema in parsed.schemas)) {
96
+ throw new Error(`${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no per-tenant ` +
97
+ `target in "schemas"`);
98
+ }
99
+ if (!(seed.fromSchema in reuse.sharedSchema)) {
100
+ throw new Error(`${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no shared ` +
101
+ `target in "reuse.sharedSchema"`);
102
+ }
103
+ }
104
+ }
105
+ const hasExtensions = parsed.extensions !== undefined;
106
+ if (hasExtensions) {
107
+ const ext = parsed.extensions;
108
+ if (typeof ext !== 'object' || ext === null || Array.isArray(ext)) {
109
+ throw new Error(`${specPath}: "extensions" must be an object`);
110
+ }
111
+ const isSchemaOrNull = (v) => v === null || (typeof v === 'string' && !!v);
112
+ const hasRoutes = ext.routes !== undefined;
113
+ if (hasRoutes) {
114
+ if (typeof ext.routes !== 'object' || ext.routes === null || Array.isArray(ext.routes)) {
115
+ throw new Error(`${specPath}: "extensions.routes" must be an object keyed by extension name`);
116
+ }
117
+ for (const [name, route] of Object.entries(ext.routes)) {
118
+ if (!route || typeof route !== 'object' || !isSchemaOrNull(route.to)) {
119
+ throw new Error(`${specPath}: "extensions.routes.${name}" must be { to: schema | null, from?: (string|null)[] }`);
120
+ }
121
+ }
122
+ }
123
+ else if (!('toSchema' in ext)) {
124
+ throw new Error(`${specPath}: "extensions" needs "toSchema" (schema | null) or "routes"`);
125
+ }
126
+ else if (!isSchemaOrNull(ext.toSchema)) {
127
+ throw new Error(`${specPath}: "extensions.toSchema" must be a non-empty string or null`);
128
+ }
129
+ if (ext.only !== undefined && (!Array.isArray(ext.only) || ext.only.some((e) => typeof e !== 'string' || !e))) {
130
+ throw new Error(`${specPath}: "extensions.only" must be an array of extension names`);
131
+ }
132
+ if (ext.serverVersion !== undefined && typeof ext.serverVersion !== 'number') {
133
+ throw new Error(`${specPath}: "extensions.serverVersion" must be a number`);
134
+ }
135
+ }
136
+ const hasRoles = parsed.roles !== undefined;
137
+ if (hasRoles && !isSchemaMap(parsed.roles)) {
138
+ throw new Error(`${specPath}: "roles" must be a non-empty string → string map`);
139
+ }
140
+ if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles) {
141
+ throw new Error(`${specPath}: at least one of "schemas", "route", "extensions", or "roles" is required`);
67
142
  }
68
143
  const name = parsed.name ?? basename(dirname(specPath));
69
144
  if (typeof name !== 'string' || !name) {
@@ -1,3 +1,4 @@
1
1
  export * from './materialize';
2
2
  export * from './apply-spec';
3
+ export * from './reuse';
3
4
  export * from './types';
@@ -4,6 +4,7 @@ import { join, resolve } from 'path';
4
4
  import { bundleFromModule, materializeBundle, transpileBundle, verifyBundle } from '@pgpmjs/bundle';
5
5
  import { loadModule, makeSchemaTranspiler } from '@pgpmjs/transform';
6
6
  import { hasApplySpec, readApplySpec } from './apply-spec';
7
+ import { isReuseSpec, materializeReuseModule, resolveSharedModuleName } from './reuse';
7
8
  // Functions that take a bare schema name as a string-literal first argument.
8
9
  // The AST pass only remaps dotted literals ('schema.object'); these helpers
9
10
  // (ubiquitous in verify scripts) name the schema alone, so a string-level
@@ -51,6 +52,8 @@ export async function materializeApplyModule(options) {
51
52
  const { renameChange, transformScript, result } = makeSchemaTranspiler({
52
53
  schemaMap: spec.schemas,
53
54
  routes: spec.route,
55
+ extensions: spec.extensions,
56
+ roles: spec.roles,
54
57
  transform: {
55
58
  prePasses: [schemaNameLiteralPass],
56
59
  assumeSchemasExist: targetSchemas
@@ -94,7 +97,10 @@ const materializedCache = new Map();
94
97
  export async function resolveEffectiveModulePath(moduleName, modulePath, moduleMap, workspacePath) {
95
98
  if (!hasApplySpec(modulePath))
96
99
  return modulePath;
97
- const cached = materializedCache.get(modulePath);
100
+ // A reuse proxy resolves under two names (shared + per-tenant) from one
101
+ // directory, so the cache is keyed by the requested module name too.
102
+ const cacheKey = `${modulePath}::${moduleName}`;
103
+ const cached = materializedCache.get(cacheKey);
98
104
  if (cached)
99
105
  return cached;
100
106
  const spec = readApplySpec(modulePath);
@@ -106,8 +112,15 @@ export async function resolveEffectiveModulePath(moduleName, modulePath, moduleM
106
112
  `.`);
107
113
  }
108
114
  const sourceDir = resolve(workspacePath, sourceModule.path);
115
+ if (isReuseSpec(spec)) {
116
+ const result = await materializeReuseModule({ sourceDir, spec });
117
+ materializedCache.set(`${modulePath}::${result.sharedName}`, result.shared.outDir);
118
+ materializedCache.set(`${modulePath}::${result.perTenantName}`, result.perTenant.outDir);
119
+ const outDir = moduleName === resolveSharedModuleName(spec) ? result.shared.outDir : result.perTenant.outDir;
120
+ return outDir;
121
+ }
109
122
  const { outDir } = await materializeApplyModule({ sourceDir, spec });
110
- materializedCache.set(modulePath, outDir);
123
+ materializedCache.set(cacheKey, outDir);
111
124
  return outDir;
112
125
  }
113
126
  /** Test seam: clear the per-process materialization cache. */