@pgpmjs/core 7.14.0 → 7.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apply/apply-spec.js +84 -9
- package/apply/index.d.ts +1 -0
- package/apply/index.js +1 -0
- package/apply/materialize.js +15 -2
- package/apply/reuse.d.ts +52 -0
- package/apply/reuse.js +226 -0
- package/apply/types.d.ts +89 -0
- package/bundle/artifact.d.ts +35 -0
- package/bundle/artifact.js +96 -0
- package/bundle/deploy-bundled.d.ts +71 -0
- package/bundle/deploy-bundled.js +177 -0
- package/bundle/index.d.ts +2 -0
- package/bundle/index.js +4 -0
- package/core/class/pgpm.js +39 -54
- package/esm/apply/apply-spec.js +84 -9
- package/esm/apply/index.js +1 -0
- package/esm/apply/materialize.js +15 -2
- package/esm/apply/reuse.js +221 -0
- package/esm/bundle/artifact.js +89 -0
- package/esm/bundle/deploy-bundled.js +170 -0
- package/esm/bundle/index.js +4 -0
- package/esm/core/class/pgpm.js +39 -54
- package/esm/packaging/package.js +8 -1
- package/esm/projects/deploy.js +17 -54
- package/package.json +10 -10
- package/packaging/package.d.ts +7 -1
- package/packaging/package.js +8 -1
- package/projects/deploy.js +17 -54
|
@@ -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
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);
|
package/core/class/pgpm.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/esm/apply/apply-spec.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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) {
|
package/esm/apply/index.js
CHANGED
package/esm/apply/materialize.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
123
|
+
materializedCache.set(cacheKey, outDir);
|
|
111
124
|
return outDir;
|
|
112
125
|
}
|
|
113
126
|
/** Test seam: clear the per-process materialization cache. */
|