@pgpmjs/core 7.13.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.d.ts +14 -0
- package/apply/apply-spec.js +165 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.js +20 -0
- package/apply/materialize.d.ts +40 -0
- package/apply/materialize.js +134 -0
- package/apply/reuse.d.ts +52 -0
- package/apply/reuse.js +226 -0
- package/apply/types.d.ts +172 -0
- package/apply/types.js +17 -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.d.ts +20 -0
- package/core/class/pgpm.js +109 -62
- package/esm/apply/apply-spec.js +160 -0
- package/esm/apply/index.js +4 -0
- package/esm/apply/materialize.js +129 -0
- package/esm/apply/reuse.js +221 -0
- package/esm/apply/types.js +14 -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 +109 -62
- package/esm/index.js +1 -0
- package/esm/packaging/package.js +8 -1
- package/esm/projects/deploy.js +30 -57
- package/esm/projects/revert.js +10 -3
- package/esm/projects/verify.js +10 -3
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +10 -9
- package/packaging/package.d.ts +7 -1
- package/packaging/package.js +8 -1
- package/projects/deploy.js +30 -57
- package/projects/revert.js +10 -3
- package/projects/verify.js +10 -3
package/apply/types.d.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The apply-spec ("proxy module") types.
|
|
3
|
+
*
|
|
4
|
+
* A proxy module is a directory containing a single `pgpm.apply.json` file:
|
|
5
|
+
* "apply <source module> transpiled with this schema map, under my name". It
|
|
6
|
+
* needs no `.control`, no `pgpm.plan`, and no deploy/revert/verify scripts —
|
|
7
|
+
* workspace discovery synthesizes a module entry from the spec, so the proxy
|
|
8
|
+
* participates in dependency resolution by name exactly like a regular
|
|
9
|
+
* module. At deploy/verify/revert time the engine transpiles the source
|
|
10
|
+
* module in memory and runs the normal migration path against the result;
|
|
11
|
+
* transpiled code is never committed.
|
|
12
|
+
*/
|
|
13
|
+
/** File name of the apply spec inside a proxy module directory. */
|
|
14
|
+
export declare const APPLY_SPEC_FILE = "pgpm.apply.json";
|
|
15
|
+
/** Expanded reference to the source module a proxy module applies. */
|
|
16
|
+
export interface ApplySpecSource {
|
|
17
|
+
/**
|
|
18
|
+
* Control/module name of the source module as it appears in the workspace
|
|
19
|
+
* module map (e.g. `pgpm-totp`). The source is typically installed into the
|
|
20
|
+
* workspace extensions directory via `pgpm install`; it is source material,
|
|
21
|
+
* not a runtime dependency, so it must not appear in `requires`.
|
|
22
|
+
*/
|
|
23
|
+
module: string;
|
|
24
|
+
/** Optional npm package name the source is installed from (e.g. `@pgpm/totp`). */
|
|
25
|
+
package?: string;
|
|
26
|
+
/** Optional exact version pin for the source package. */
|
|
27
|
+
version?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Optional content-addressed pin: the expected `manifest.digest` of the
|
|
30
|
+
* source bundle. When present, apply refuses to proceed if the installed
|
|
31
|
+
* source does not hash to exactly this digest.
|
|
32
|
+
*/
|
|
33
|
+
bundleDigest?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An object-level route in a `pgpm.apply.json` spec: send one named object out
|
|
37
|
+
* of a source schema to a target schema, overriding the whole-schema default
|
|
38
|
+
* for that object only. Kinds are expressed as separate properties (no dotted
|
|
39
|
+
* identity strings), so a table and a function of the same name route
|
|
40
|
+
* independently.
|
|
41
|
+
*/
|
|
42
|
+
export interface ApplyRouteEntry {
|
|
43
|
+
/** Source schema the object is defined in (e.g. `users`). */
|
|
44
|
+
fromSchema: string;
|
|
45
|
+
/** Object namespace: `table`/`view` → relation, `procedure` → function. */
|
|
46
|
+
kind: 'table' | 'view' | 'function' | 'procedure' | 'type';
|
|
47
|
+
/** Unqualified object name (e.g. `accounts`). */
|
|
48
|
+
name: string;
|
|
49
|
+
/** Target schema the object is routed to (e.g. `reporting`). */
|
|
50
|
+
toSchema: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A per-tenant seed object in a reuse spec: the objects that must be
|
|
54
|
+
* materialized once *per tenant*. `@pgpmjs/slice`'s `partitionModule` starts
|
|
55
|
+
* from these seeds and marks every change that transitively reaches one as
|
|
56
|
+
* per-tenant; everything else is proven shared and emitted once. Expressed as
|
|
57
|
+
* separate properties (no dotted identity strings), matching {@link ApplyRouteEntry}.
|
|
58
|
+
*/
|
|
59
|
+
export interface ApplyReuseSeed {
|
|
60
|
+
/** Source schema the seed object is defined in (e.g. `catalog`). */
|
|
61
|
+
fromSchema: string;
|
|
62
|
+
/** Object namespace: `table`/`view` → relation, `procedure` → function. */
|
|
63
|
+
kind: 'table' | 'view' | 'function' | 'procedure' | 'type';
|
|
64
|
+
/** Unqualified object name (e.g. `products`). */
|
|
65
|
+
name: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Reuse declaration: split the source into a *shared* module (deployed once,
|
|
69
|
+
* reused by every tenant via ordinary `requires`) and a *per-tenant* module
|
|
70
|
+
* (materialized per instance). The classifier proves the split from the
|
|
71
|
+
* `perTenant` seeds; a shared object that would depend on a per-tenant one is
|
|
72
|
+
* rejected as unsound rather than silently deployed.
|
|
73
|
+
*/
|
|
74
|
+
export interface ApplyReuseSpec {
|
|
75
|
+
/**
|
|
76
|
+
* Source schema → *shared* target schema. Shared objects land here; this
|
|
77
|
+
* mapping must be stable across tenant instances so the shared module has a
|
|
78
|
+
* deterministic identity and deploys exactly once.
|
|
79
|
+
*/
|
|
80
|
+
sharedSchema: Record<string, string>;
|
|
81
|
+
/** Per-tenant seed objects (non-empty). */
|
|
82
|
+
perTenant: ApplyReuseSeed[];
|
|
83
|
+
/**
|
|
84
|
+
* Optional explicit shared-module name. Defaults to a deterministic name
|
|
85
|
+
* derived from the source module and the shared-schema mapping, so two tenant
|
|
86
|
+
* proxies over the same source + shared mapping resolve to one shared module.
|
|
87
|
+
*/
|
|
88
|
+
sharedName?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Extension-routing declaration in a `pgpm.apply.json`: where the transpiled
|
|
92
|
+
* instance should install extensions and resolve the symbols they provide
|
|
93
|
+
* (e.g. isolate `pgcrypto` in a dedicated `extensions` schema, qualifying bare
|
|
94
|
+
* `crypt(...)` calls, or the reverse). A distinct dimension from schema
|
|
95
|
+
* routing — driven by a version-aware symbol inventory, not by the objects the
|
|
96
|
+
* SQL itself creates. Forwarded to `@pgpmjs/transform`'s extension router.
|
|
97
|
+
*/
|
|
98
|
+
export interface ApplyExtensionsSpec {
|
|
99
|
+
/**
|
|
100
|
+
* Install the matched extensions and route their provided symbols to this
|
|
101
|
+
* schema. `null` strips qualification (rely on `search_path`). Ignored when
|
|
102
|
+
* `routes` is given.
|
|
103
|
+
*/
|
|
104
|
+
toSchema?: string | null;
|
|
105
|
+
/** With `toSchema`: limit to these extensions (default: every inventoried one). */
|
|
106
|
+
only?: string[];
|
|
107
|
+
/**
|
|
108
|
+
* With `toSchema`: which source qualifications to rewrite (a `null` entry
|
|
109
|
+
* also rewrites bare references). Defaults to `public` + bare.
|
|
110
|
+
*/
|
|
111
|
+
from?: (string | null)[];
|
|
112
|
+
/**
|
|
113
|
+
* Advanced: explicit per-extension routes (`{ "<ext>": { "to": "<schema>|null",
|
|
114
|
+
* "from"?: [...] } }`). Overrides `toSchema`/`only`/`from`.
|
|
115
|
+
*/
|
|
116
|
+
routes?: Record<string, {
|
|
117
|
+
to: string | null;
|
|
118
|
+
from?: (string | null)[];
|
|
119
|
+
}>;
|
|
120
|
+
/** Target PostgreSQL major version, for core-graduation awareness. */
|
|
121
|
+
serverVersion?: number;
|
|
122
|
+
}
|
|
123
|
+
/** Role-name translation: source role name → target role name. */
|
|
124
|
+
export type ApplyRolesSpec = Record<string, string>;
|
|
125
|
+
/** The parsed, normalized `pgpm.apply.json` spec. */
|
|
126
|
+
export interface PgpmApplySpec {
|
|
127
|
+
/**
|
|
128
|
+
* Module name this instance is deployed as. Defaults to the proxy
|
|
129
|
+
* directory's name.
|
|
130
|
+
*/
|
|
131
|
+
name?: string;
|
|
132
|
+
/** Source module reference: a module name, or the expanded object form. */
|
|
133
|
+
source: string | ApplySpecSource;
|
|
134
|
+
/**
|
|
135
|
+
* Whole-schema default: source schema → target schema (drives the AST + plan
|
|
136
|
+
* rewrite). Optional when `route` fully covers the objects being moved.
|
|
137
|
+
*/
|
|
138
|
+
schemas?: Record<string, string>;
|
|
139
|
+
/**
|
|
140
|
+
* Object-level routes. Each entry overrides the `schemas` default for its
|
|
141
|
+
* specific object, letting one source schema fan out per object (e.g. a
|
|
142
|
+
* table into a tenant schema and a function into a shared schema).
|
|
143
|
+
*/
|
|
144
|
+
route?: ApplyRouteEntry[];
|
|
145
|
+
/**
|
|
146
|
+
* Reuse declaration: split shared vs per-tenant objects (see
|
|
147
|
+
* {@link ApplyReuseSpec}). When present, this proxy expands into two modules —
|
|
148
|
+
* a shared module and a per-tenant module that `requires` it.
|
|
149
|
+
*/
|
|
150
|
+
reuse?: ApplyReuseSpec;
|
|
151
|
+
/**
|
|
152
|
+
* Extension routing: where to install extensions and resolve their provided
|
|
153
|
+
* symbols in the transpiled instance (see {@link ApplyExtensionsSpec}).
|
|
154
|
+
*/
|
|
155
|
+
extensions?: ApplyExtensionsSpec;
|
|
156
|
+
/**
|
|
157
|
+
* Role-name translation applied to the transpiled instance's SQL
|
|
158
|
+
* (see {@link ApplyRolesSpec}). Renames role identifiers only.
|
|
159
|
+
*/
|
|
160
|
+
roles?: ApplyRolesSpec;
|
|
161
|
+
/**
|
|
162
|
+
* Runtime requires of the transpiled output (native extensions and other
|
|
163
|
+
* modules). Defaults to the source module's own requires.
|
|
164
|
+
*/
|
|
165
|
+
requires?: string[];
|
|
166
|
+
/** Version reported for this instance (default: the source's version). */
|
|
167
|
+
version?: string;
|
|
168
|
+
}
|
|
169
|
+
/** {@link PgpmApplySpec} with `source` normalized to the object form. */
|
|
170
|
+
export interface ResolvedApplySpec extends Omit<PgpmApplySpec, 'source'> {
|
|
171
|
+
source: ApplySpecSource;
|
|
172
|
+
}
|
package/apply/types.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The apply-spec ("proxy module") types.
|
|
4
|
+
*
|
|
5
|
+
* A proxy module is a directory containing a single `pgpm.apply.json` file:
|
|
6
|
+
* "apply <source module> transpiled with this schema map, under my name". It
|
|
7
|
+
* needs no `.control`, no `pgpm.plan`, and no deploy/revert/verify scripts —
|
|
8
|
+
* workspace discovery synthesizes a module entry from the spec, so the proxy
|
|
9
|
+
* participates in dependency resolution by name exactly like a regular
|
|
10
|
+
* module. At deploy/verify/revert time the engine transpiles the source
|
|
11
|
+
* module in memory and runs the normal migration path against the result;
|
|
12
|
+
* transpiled code is never committed.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.APPLY_SPEC_FILE = void 0;
|
|
16
|
+
/** File name of the apply spec inside a proxy module directory. */
|
|
17
|
+
exports.APPLY_SPEC_FILE = 'pgpm.apply.json';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CreateBundleOptions, MigrationBundle } from '@pgpmjs/bundle';
|
|
2
|
+
/**
|
|
3
|
+
* Artifact file name for a module's stored bundle: it lives beside the packaged
|
|
4
|
+
* `sql/<name>--<version>.sql` as `sql/<name>--<version>.bundle.tar.gz`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function bundleArtifactFileName(name: string, version: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Locate a module's stored bundle artifact, if it has one.
|
|
9
|
+
*
|
|
10
|
+
* Prefers the version declared in the module's `.control` file, then falls back
|
|
11
|
+
* to any single `sql/*.bundle.tar.gz` present — so a module packaged at a
|
|
12
|
+
* different version still gets the fast path instead of silently degrading.
|
|
13
|
+
* Returns `null` when the module ships no artifact.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveBundleArtifactPath(moduleDir: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Build the executable form of a module's bundle: the content-addressed AST
|
|
18
|
+
* snapshot plus, per change, the deploy SQL with transaction / `CREATE
|
|
19
|
+
* EXTENSION` statements stripped (`cleanSql`).
|
|
20
|
+
*
|
|
21
|
+
* That pre-computation is the whole point of the artifact — the parse/deparse
|
|
22
|
+
* work moves from every deploy to the one-time package step, while the digests
|
|
23
|
+
* still cover the exact bytes that will be executed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildExecutableBundle(moduleDir: string, options?: CreateBundleOptions): Promise<MigrationBundle>;
|
|
26
|
+
/**
|
|
27
|
+
* Emit a module's stored bundle artifact into `sql/`, returning its path.
|
|
28
|
+
*/
|
|
29
|
+
export declare function writeBundleArtifact(moduleDir: string, version: string, options?: CreateBundleOptions): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Read a module's stored bundle artifact, or `null` when it has none or the
|
|
32
|
+
* archive cannot be read. Never throws — a broken artifact must degrade to the
|
|
33
|
+
* normal packaging path, not fail the deploy.
|
|
34
|
+
*/
|
|
35
|
+
export declare function readBundleArtifact(moduleDir: string): MigrationBundle | null;
|
|
@@ -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.d.ts
CHANGED
|
@@ -97,6 +97,14 @@ export declare class PgpmPackage {
|
|
|
97
97
|
* Handles naming collisions by preferring the shortest path.
|
|
98
98
|
*/
|
|
99
99
|
listModules(): ModuleMap;
|
|
100
|
+
/**
|
|
101
|
+
* Discover apply-spec proxy modules (`pgpm.apply.json`) and synthesize
|
|
102
|
+
* module-map entries for them so they participate in dependency resolution
|
|
103
|
+
* by name like any other module. A proxy needs no `.control` or `pgpm.plan`;
|
|
104
|
+
* its `requires` default to the source module's requires. Control-file
|
|
105
|
+
* modules win name collisions.
|
|
106
|
+
*/
|
|
107
|
+
private addApplyModules;
|
|
100
108
|
getModuleMap(): ModuleMap;
|
|
101
109
|
getAvailableModules(): Promise<string[]>;
|
|
102
110
|
getModuleProject(name: string): PgpmPackage;
|
|
@@ -271,6 +279,18 @@ export declare class PgpmPackage {
|
|
|
271
279
|
resolved: string[];
|
|
272
280
|
external: string[];
|
|
273
281
|
}>;
|
|
282
|
+
/**
|
|
283
|
+
* Dependency closure for a named module. Proxy (apply-spec) modules have no
|
|
284
|
+
* pgpm.plan, so they resolve straight off the workspace module map instead
|
|
285
|
+
* of instantiating a module-rooted package.
|
|
286
|
+
*/
|
|
287
|
+
private getModuleExtensionsFor;
|
|
288
|
+
/**
|
|
289
|
+
* Directory the migration engine should run against for a named module:
|
|
290
|
+
* the module's own directory, or — for proxy (apply-spec) modules — the
|
|
291
|
+
* materialized transpiled instance.
|
|
292
|
+
*/
|
|
293
|
+
private getEffectiveModulePathFor;
|
|
274
294
|
private parsePackageTarget;
|
|
275
295
|
deploy(opts: PgpmOptions, target?: string, recursive?: boolean): Promise<void>;
|
|
276
296
|
/**
|