@pgpmjs/core 7.14.0 → 7.16.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.
Files changed (45) hide show
  1. package/apply/apply-spec.js +84 -9
  2. package/apply/index.d.ts +1 -0
  3. package/apply/index.js +1 -0
  4. package/apply/materialize.js +15 -2
  5. package/apply/reuse.d.ts +52 -0
  6. package/apply/reuse.js +226 -0
  7. package/apply/types.d.ts +89 -0
  8. package/bundle/artifact.d.ts +35 -0
  9. package/bundle/artifact.js +96 -0
  10. package/bundle/deploy-bundled.d.ts +71 -0
  11. package/bundle/deploy-bundled.js +177 -0
  12. package/bundle/index.d.ts +2 -0
  13. package/bundle/index.js +4 -0
  14. package/core/class/pgpm.js +54 -57
  15. package/esm/apply/apply-spec.js +84 -9
  16. package/esm/apply/index.js +1 -0
  17. package/esm/apply/materialize.js +15 -2
  18. package/esm/apply/reuse.js +221 -0
  19. package/esm/bundle/artifact.js +89 -0
  20. package/esm/bundle/deploy-bundled.js +170 -0
  21. package/esm/bundle/index.js +4 -0
  22. package/esm/core/class/pgpm.js +54 -57
  23. package/esm/extensions/compile.js +109 -0
  24. package/esm/extensions/index.js +4 -0
  25. package/esm/extensions/manifest.js +139 -0
  26. package/esm/extensions/resolve-install.js +70 -0
  27. package/esm/index.js +1 -1
  28. package/esm/migrate/clean.js +1 -2
  29. package/esm/packaging/package.js +8 -1
  30. package/esm/projects/deploy.js +36 -58
  31. package/extensions/compile.d.ts +35 -0
  32. package/extensions/compile.js +113 -0
  33. package/extensions/index.d.ts +4 -0
  34. package/extensions/index.js +20 -0
  35. package/extensions/manifest.d.ts +78 -0
  36. package/extensions/manifest.js +144 -0
  37. package/extensions/resolve-install.d.ts +20 -0
  38. package/extensions/resolve-install.js +74 -0
  39. package/index.d.ts +1 -1
  40. package/index.js +1 -1
  41. package/migrate/clean.js +1 -2
  42. package/package.json +10 -10
  43. package/packaging/package.d.ts +7 -1
  44. package/packaging/package.js +8 -1
  45. package/projects/deploy.js +36 -58
@@ -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-control
56
+ * 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,8 +61,9 @@ 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");
66
+ const resolve_install_1 = require("../../extensions/resolve-install");
65
67
  const deps_1 = require("../../resolution/deps");
66
68
  const fs_2 = require("../../utils/fs");
67
69
  const glob_1 = require("../../utils/glob");
@@ -313,13 +315,30 @@ class PgpmPackage {
313
315
  if (moduleMap[name])
314
316
  continue;
315
317
  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
- };
318
+ const relPath = path_1.default
319
+ .dirname(path_1.default.relative(this.workspacePath, file))
320
+ .replace(/\\/g, '/');
321
+ const baseRequires = spec.requires ?? source?.requires ?? [];
322
+ const version = spec.version ?? source?.version ?? '0.0.1';
323
+ if ((0, reuse_1.isReuseSpec)(spec)) {
324
+ // A reuse proxy expands into two modules that both materialize from
325
+ // this one directory: a shared module (deployed once, reused across
326
+ // tenants) and the per-tenant module that `requires` it. Multiple
327
+ // tenant proxies over the same source/shared/seed config resolve to
328
+ // the same deterministic shared name, so it is added at most once.
329
+ const sharedName = (0, reuse_1.resolveSharedModuleName)(spec);
330
+ if (!moduleMap[sharedName]) {
331
+ moduleMap[sharedName] = { path: relPath, requires: baseRequires, version };
332
+ }
333
+ moduleMap[name] = {
334
+ path: relPath,
335
+ requires: [...baseRequires, sharedName],
336
+ version
337
+ };
338
+ }
339
+ else {
340
+ moduleMap[name] = { path: relPath, requires: baseRequires, version };
341
+ }
323
342
  }
324
343
  return moduleMap;
325
344
  }
@@ -1334,12 +1353,6 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1334
1353
  const log = new logger_1.Logger('deploy');
1335
1354
  const { name, toChange } = this.parsePackageTarget(target);
1336
1355
  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
1356
  const modules = this.getModuleMap();
1344
1357
  let extensions;
1345
1358
  if (name === null) {
@@ -1350,61 +1363,45 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1350
1363
  extensions = this.getModuleExtensionsFor(name);
1351
1364
  }
1352
1365
  const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
1366
+ const localModules = extensions.resolved.filter((m) => !extensions.external.includes(m));
1367
+ const roleMap = (0, resolve_install_1.roleMapFromRoles)(opts.db?.roles);
1353
1368
  const targetDescription = name === null ? 'all modules' : name;
1354
1369
  log.success(`🚀 Starting deployment to database ${opts.pg.database}...`);
1355
1370
  for (const extension of extensions.resolved) {
1356
1371
  try {
1357
1372
  if (extensions.external.includes(extension)) {
1358
- const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
1359
- log.info(`📥 Installing external extension: ${extension}`);
1360
- await pgPool.query(msg);
1373
+ const install = (0, resolve_install_1.findExtensionInstall)(extension, localModules, modules, this.workspacePath, { roleMap });
1374
+ if (install) {
1375
+ log.info(`📥 Installing external extension (declarative): ${extension}`);
1376
+ for (const stmt of install.deploy) {
1377
+ await pgPool.query(stmt);
1378
+ }
1379
+ }
1380
+ else {
1381
+ const msg = `CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;`;
1382
+ log.info(`📥 Installing external extension: ${extension}`);
1383
+ await pgPool.query(msg);
1384
+ }
1361
1385
  }
1362
1386
  else {
1363
1387
  const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
1364
1388
  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);
1389
+ if (opts.deployment.fast || opts.deployment.bundled) {
1390
+ // Fast strategy: one-shot execution plus a bulk-written
1391
+ // pgpm_migrate ledger, from the module's verified bundle artifact
1392
+ // when it has one and from `deploy/` when it does not.
1393
+ const outcome = await (0, deploy_bundled_1.deployModuleFast)(modulePath, {
1394
+ config: opts.pg,
1395
+ logOnly: opts.deployment.logOnly,
1396
+ useTransaction: opts.deployment.useTx,
1397
+ hashMethod: opts.deployment.hashMethod
1398
+ });
1399
+ if ((0, deploy_bundled_1.isBundledDeployResult)(outcome)) {
1370
1400
  continue;
1371
1401
  }
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
- }
1402
+ log.info(`↩️ Fast deploy unavailable for ${extension} (${outcome}); using per-change path.`);
1406
1403
  }
1407
- else {
1404
+ {
1408
1405
  try {
1409
1406
  const client = new client_1.PgpmMigrate(opts.pg);
1410
1407
  // 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';