@pgpmjs/core 7.17.0 → 7.18.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.
@@ -35,11 +35,13 @@ const schemaNameLiteralPass = (content, schemaMapping) => {
35
35
  function makeSubsystemStripper(source, spec, instanceName) {
36
36
  const selector = { schemas: spec.exclude.schemas };
37
37
  const rebinds = (0, transform_1.buildSchemaRouter)({ schemaMap: spec.schemas, routes: spec.route });
38
- const fullDeploySql = source.changes
39
- .map(c => c.deploy?.sql ?? '')
40
- .filter(Boolean)
41
- .join('\n');
42
- const analysis = (0, slice_1.excludeSubsystem)(fullDeploySql, selector, { rebinds });
38
+ // One parse per deploy script; the analysis runs over all programs together
39
+ // (cross-change references into the subsystem are caught by construction)
40
+ // and returns per-change drop/strip/prune decisions from the same pass.
41
+ const programs = source.changes
42
+ .filter(c => c.deploy)
43
+ .map(c => [c.name, (0, transform_1.parseSqlProgram)(c.deploy.sql)]);
44
+ const analysis = (0, slice_1.excludeSubsystemPrograms)(programs, selector, { rebinds });
43
45
  if (analysis.unsatisfied.length > 0) {
44
46
  const detail = [
45
47
  ...new Set(analysis.unsatisfied.map(u => `${u.object.schema}.${u.object.name}${u.fk ? ' (foreign key target)' : ''}`))
@@ -49,21 +51,26 @@ function makeSubsystemStripper(source, spec, instanceName) {
49
51
  `${detail} with no route/rebind target. Add "route" entries substituting each ` +
50
52
  `referenced object, or keep the subsystem.`);
51
53
  }
52
- // A change whose deploy consists entirely of subsystem statements (plus
53
- // transaction control) is excluded *as a change*: its verify/revert scripts
54
- // target dropped objects the classifier can't always see (bare DROPs,
55
- // catalog probes), so all three scripts are blanked together.
54
+ // A fully-excluded change is dropped *as a change* — removed from the plan
55
+ // and deploy order rather than emitted as an empty script (empty scripts
56
+ // collide on the deploy ledger's script-hash uniqueness). Its verify/revert
57
+ // scripts target dropped objects the classifier can't always see (bare
58
+ // DROPs, catalog probes), which is exactly why the whole change is dropped,
59
+ // not just blanked.
56
60
  const excludedChanges = new Set();
57
- for (const change of source.changes) {
58
- if (!change.deploy)
59
- continue;
60
- const stripped = (0, slice_1.stripSubsystemSql)(change.deploy.sql, selector);
61
- const survivors = stripped.result.kept.filter(i => !stripped.dropped.includes(i) && stripped.result.statements[i].nodeTag !== 'TransactionStmt');
62
- if (stripped.dropped.length > 0 && survivors.length === 0) {
63
- excludedChanges.add(change.name);
64
- }
61
+ const strippedDeploys = new Map();
62
+ for (const [name, program] of programs) {
63
+ const exclusion = analysis.programs.get(name);
64
+ if (exclusion.fullyExcluded)
65
+ excludedChanges.add(name);
66
+ else
67
+ strippedDeploys.set(program.source, exclusion.sql);
65
68
  }
66
- const strip = (sql, change) => excludedChanges.has(change) ? (0, slice_1.blankScriptSql)(sql) : (0, slice_1.stripSubsystemSql)(sql, selector).sql;
69
+ // Surviving changes may still contain stray subsystem statements (a change
70
+ // that both creates a survivor and touches the excluded subsystem); strip
71
+ // those in place. Verify/revert scripts are separate sources, stripped on
72
+ // demand with the same selector.
73
+ const strip = (sql) => strippedDeploys.get(sql) ?? (0, slice_1.stripSubsystemSql)(sql, selector).sql;
67
74
  return { strip, excludedChanges };
68
75
  }
69
76
  /**
@@ -113,14 +120,20 @@ async function materializeApplyModule(options) {
113
120
  assumeSchemasExist: targetSchemas
114
121
  }
115
122
  });
116
- // Excluded changes keep their source identity (an emptied tombstone) —
117
- // renaming them into the replacement provider's namespace would be a lie.
123
+ // Excluded changes are dropped from the artifact entirely (plan + deploy
124
+ // order + dependency refs); surviving changes get their stray subsystem
125
+ // statements stripped before the namespace transform.
118
126
  const transpiled = (0, bundle_1.transpileBundle)(source, {
127
+ excludeChange: stripSubsystem
128
+ ? (name) => stripSubsystem.excludedChanges.has(name)
129
+ : undefined,
130
+ // Excluded changes keep their source identity so the plan-prune (which
131
+ // matches source names) can find them; survivors route normally.
119
132
  renameChange: stripSubsystem
120
133
  ? (name) => (stripSubsystem.excludedChanges.has(name) ? name : renameChange(name))
121
134
  : renameChange,
122
135
  transformScript: stripSubsystem
123
- ? (sql, ctx) => transformScript(stripSubsystem.strip(sql, ctx.change), ctx)
136
+ ? (sql, ctx) => transformScript(stripSubsystem.strip(sql), ctx)
124
137
  : transformScript,
125
138
  renameModule: instanceName,
126
139
  provenance: {
@@ -27,6 +27,18 @@ export declare function buildExecutableBundle(moduleDir: string, options?: Creat
27
27
  * Emit a module's stored bundle artifact into `sql/`, returning its path.
28
28
  */
29
29
  export declare function writeBundleArtifact(moduleDir: string, version: string, options?: CreateBundleOptions): Promise<string>;
30
+ /**
31
+ * Whether a stored bundle still describes what is on disk.
32
+ *
33
+ * `verifyBundle` only proves an artifact is internally consistent — it cannot
34
+ * tell that `deploy/` moved on since the artifact was written. A committed
35
+ * artifact plus a rebased branch is exactly that case, and using it would
36
+ * deploy the stale SQL while recording hashes for it, silently.
37
+ *
38
+ * The check is deliberately I/O-only (read + sha256, no parse), so it costs a
39
+ * fraction of the parse/deparse work the artifact exists to avoid.
40
+ */
41
+ export declare function bundleMatchesModule(moduleDir: string, bundle: MigrationBundle): boolean;
30
42
  /**
31
43
  * Read a module's stored bundle artifact, or `null` when it has none or the
32
44
  * archive cannot be read. Never throws — a broken artifact must degrade to the
@@ -4,9 +4,11 @@ exports.bundleArtifactFileName = bundleArtifactFileName;
4
4
  exports.resolveBundleArtifactPath = resolveBundleArtifactPath;
5
5
  exports.buildExecutableBundle = buildExecutableBundle;
6
6
  exports.writeBundleArtifact = writeBundleArtifact;
7
+ exports.bundleMatchesModule = bundleMatchesModule;
7
8
  exports.readBundleArtifact = readBundleArtifact;
8
9
  const fs_1 = require("fs");
9
10
  const path_1 = require("path");
11
+ const ast_1 = require("@pgpmjs/ast");
10
12
  const files_1 = require("@pgpmjs/ast/files");
11
13
  const bundle_1 = require("@pgpmjs/bundle");
12
14
  const clean_1 = require("../migrate/clean");
@@ -78,6 +80,34 @@ async function writeBundleArtifact(moduleDir, version, options) {
78
80
  (0, bundle_1.writeBundleArchiveFile)(bundle, outPath);
79
81
  return outPath;
80
82
  }
83
+ /**
84
+ * Whether a stored bundle still describes what is on disk.
85
+ *
86
+ * `verifyBundle` only proves an artifact is internally consistent — it cannot
87
+ * tell that `deploy/` moved on since the artifact was written. A committed
88
+ * artifact plus a rebased branch is exactly that case, and using it would
89
+ * deploy the stale SQL while recording hashes for it, silently.
90
+ *
91
+ * The check is deliberately I/O-only (read + sha256, no parse), so it costs a
92
+ * fraction of the parse/deparse work the artifact exists to avoid.
93
+ */
94
+ function bundleMatchesModule(moduleDir, bundle) {
95
+ const planPath = (0, path_1.join)(moduleDir, 'pgpm.plan');
96
+ if (!(0, fs_1.existsSync)(planPath))
97
+ return false;
98
+ if ((0, fs_1.readFileSync)(planPath, 'utf-8') !== bundle.plan)
99
+ return false;
100
+ for (const change of bundle.changes) {
101
+ if (!change.deploy)
102
+ continue;
103
+ const deployPath = (0, path_1.join)(moduleDir, 'deploy', `${change.name}.sql`);
104
+ if (!(0, fs_1.existsSync)(deployPath))
105
+ return false;
106
+ if ((0, ast_1.hashString)((0, fs_1.readFileSync)(deployPath, 'utf-8')) !== change.deploy.digest)
107
+ return false;
108
+ }
109
+ return true;
110
+ }
81
111
  /**
82
112
  * Read a module's stored bundle artifact, or `null` when it has none or the
83
113
  * archive cannot be read. Never throws — a broken artifact must degrade to the
@@ -47,11 +47,17 @@ async function loadExecutableBundle(moduleDir) {
47
47
  const stored = (0, artifact_1.readBundleArtifact)(moduleDir);
48
48
  if (stored) {
49
49
  const issues = (0, bundle_1.verifyBundle)(stored);
50
- if (issues.length === 0) {
50
+ if (issues.length > 0) {
51
+ log.warn(`⚠️ Bundle artifact for ${stored.manifest.name} failed verification ` +
52
+ `(${issues.map(i => i.kind).join(', ')}); rebuilding from deploy/.`);
53
+ }
54
+ else if (!(0, artifact_1.bundleMatchesModule)(moduleDir, stored)) {
55
+ log.warn(`⚠️ Bundle artifact for ${stored.manifest.name} is stale (deploy/ has ` +
56
+ `changed since it was packaged); rebuilding from deploy/.`);
57
+ }
58
+ else {
51
59
  return { bundle: stored, source: 'artifact' };
52
60
  }
53
- log.warn(`⚠️ Bundle artifact for ${stored.manifest.name} failed verification ` +
54
- `(${issues.map(i => i.kind).join(', ')}); rebuilding from deploy/.`);
55
61
  }
56
62
  return { bundle: await (0, artifact_1.buildExecutableBundle)(moduleDir), source: 'packaged' };
57
63
  }
@@ -2,8 +2,8 @@ import { mkdtempSync } from 'fs';
2
2
  import { tmpdir } from 'os';
3
3
  import { join, resolve } from 'path';
4
4
  import { bundleFromModule, materializeBundle, transpileBundle, verifyBundle } from '@pgpmjs/bundle';
5
- import { buildSchemaRouter, loadModule, makeSchemaTranspiler } from '@pgpmjs/transform';
6
- import { blankScriptSql, excludeSubsystem, stripSubsystemSql } from '@pgpmjs/slice';
5
+ import { buildSchemaRouter, loadModule, makeSchemaTranspiler, parseSqlProgram } from '@pgpmjs/transform';
6
+ import { excludeSubsystemPrograms, stripSubsystemSql } from '@pgpmjs/slice';
7
7
  import { hasApplySpec, readApplySpec } from './apply-spec';
8
8
  import { loadWorkspaceRoutingProfile, resolveEffectiveApplySpec } from './profile';
9
9
  import { isReuseSpec, materializeReuseModule, resolveSharedModuleName } from './reuse';
@@ -30,11 +30,13 @@ const schemaNameLiteralPass = (content, schemaMapping) => {
30
30
  function makeSubsystemStripper(source, spec, instanceName) {
31
31
  const selector = { schemas: spec.exclude.schemas };
32
32
  const rebinds = buildSchemaRouter({ schemaMap: spec.schemas, routes: spec.route });
33
- const fullDeploySql = source.changes
34
- .map(c => c.deploy?.sql ?? '')
35
- .filter(Boolean)
36
- .join('\n');
37
- const analysis = excludeSubsystem(fullDeploySql, selector, { rebinds });
33
+ // One parse per deploy script; the analysis runs over all programs together
34
+ // (cross-change references into the subsystem are caught by construction)
35
+ // and returns per-change drop/strip/prune decisions from the same pass.
36
+ const programs = source.changes
37
+ .filter(c => c.deploy)
38
+ .map(c => [c.name, parseSqlProgram(c.deploy.sql)]);
39
+ const analysis = excludeSubsystemPrograms(programs, selector, { rebinds });
38
40
  if (analysis.unsatisfied.length > 0) {
39
41
  const detail = [
40
42
  ...new Set(analysis.unsatisfied.map(u => `${u.object.schema}.${u.object.name}${u.fk ? ' (foreign key target)' : ''}`))
@@ -44,21 +46,26 @@ function makeSubsystemStripper(source, spec, instanceName) {
44
46
  `${detail} with no route/rebind target. Add "route" entries substituting each ` +
45
47
  `referenced object, or keep the subsystem.`);
46
48
  }
47
- // A change whose deploy consists entirely of subsystem statements (plus
48
- // transaction control) is excluded *as a change*: its verify/revert scripts
49
- // target dropped objects the classifier can't always see (bare DROPs,
50
- // catalog probes), so all three scripts are blanked together.
49
+ // A fully-excluded change is dropped *as a change* — removed from the plan
50
+ // and deploy order rather than emitted as an empty script (empty scripts
51
+ // collide on the deploy ledger's script-hash uniqueness). Its verify/revert
52
+ // scripts target dropped objects the classifier can't always see (bare
53
+ // DROPs, catalog probes), which is exactly why the whole change is dropped,
54
+ // not just blanked.
51
55
  const excludedChanges = new Set();
52
- for (const change of source.changes) {
53
- if (!change.deploy)
54
- continue;
55
- const stripped = stripSubsystemSql(change.deploy.sql, selector);
56
- const survivors = stripped.result.kept.filter(i => !stripped.dropped.includes(i) && stripped.result.statements[i].nodeTag !== 'TransactionStmt');
57
- if (stripped.dropped.length > 0 && survivors.length === 0) {
58
- excludedChanges.add(change.name);
59
- }
56
+ const strippedDeploys = new Map();
57
+ for (const [name, program] of programs) {
58
+ const exclusion = analysis.programs.get(name);
59
+ if (exclusion.fullyExcluded)
60
+ excludedChanges.add(name);
61
+ else
62
+ strippedDeploys.set(program.source, exclusion.sql);
60
63
  }
61
- const strip = (sql, change) => excludedChanges.has(change) ? blankScriptSql(sql) : stripSubsystemSql(sql, selector).sql;
64
+ // Surviving changes may still contain stray subsystem statements (a change
65
+ // that both creates a survivor and touches the excluded subsystem); strip
66
+ // those in place. Verify/revert scripts are separate sources, stripped on
67
+ // demand with the same selector.
68
+ const strip = (sql) => strippedDeploys.get(sql) ?? stripSubsystemSql(sql, selector).sql;
62
69
  return { strip, excludedChanges };
63
70
  }
64
71
  /**
@@ -108,14 +115,20 @@ export async function materializeApplyModule(options) {
108
115
  assumeSchemasExist: targetSchemas
109
116
  }
110
117
  });
111
- // Excluded changes keep their source identity (an emptied tombstone) —
112
- // renaming them into the replacement provider's namespace would be a lie.
118
+ // Excluded changes are dropped from the artifact entirely (plan + deploy
119
+ // order + dependency refs); surviving changes get their stray subsystem
120
+ // statements stripped before the namespace transform.
113
121
  const transpiled = transpileBundle(source, {
122
+ excludeChange: stripSubsystem
123
+ ? (name) => stripSubsystem.excludedChanges.has(name)
124
+ : undefined,
125
+ // Excluded changes keep their source identity so the plan-prune (which
126
+ // matches source names) can find them; survivors route normally.
114
127
  renameChange: stripSubsystem
115
128
  ? (name) => (stripSubsystem.excludedChanges.has(name) ? name : renameChange(name))
116
129
  : renameChange,
117
130
  transformScript: stripSubsystem
118
- ? (sql, ctx) => transformScript(stripSubsystem.strip(sql, ctx.change), ctx)
131
+ ? (sql, ctx) => transformScript(stripSubsystem.strip(sql), ctx)
119
132
  : transformScript,
120
133
  renameModule: instanceName,
121
134
  provenance: {
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { hashString } from '@pgpmjs/ast';
3
4
  import { getExtensionName, parseControlContent } from '@pgpmjs/ast/files';
4
5
  import { BUNDLE_ARCHIVE_EXTENSION, bundleFromModule, readBundleArchiveFile, withExecutableSql, writeBundleArchiveFile } from '@pgpmjs/bundle';
5
6
  import { cleanSql } from '../migrate/clean';
@@ -71,6 +72,34 @@ export async function writeBundleArtifact(moduleDir, version, options) {
71
72
  writeBundleArchiveFile(bundle, outPath);
72
73
  return outPath;
73
74
  }
75
+ /**
76
+ * Whether a stored bundle still describes what is on disk.
77
+ *
78
+ * `verifyBundle` only proves an artifact is internally consistent — it cannot
79
+ * tell that `deploy/` moved on since the artifact was written. A committed
80
+ * artifact plus a rebased branch is exactly that case, and using it would
81
+ * deploy the stale SQL while recording hashes for it, silently.
82
+ *
83
+ * The check is deliberately I/O-only (read + sha256, no parse), so it costs a
84
+ * fraction of the parse/deparse work the artifact exists to avoid.
85
+ */
86
+ export function bundleMatchesModule(moduleDir, bundle) {
87
+ const planPath = join(moduleDir, 'pgpm.plan');
88
+ if (!existsSync(planPath))
89
+ return false;
90
+ if (readFileSync(planPath, 'utf-8') !== bundle.plan)
91
+ return false;
92
+ for (const change of bundle.changes) {
93
+ if (!change.deploy)
94
+ continue;
95
+ const deployPath = join(moduleDir, 'deploy', `${change.name}.sql`);
96
+ if (!existsSync(deployPath))
97
+ return false;
98
+ if (hashString(readFileSync(deployPath, 'utf-8')) !== change.deploy.digest)
99
+ return false;
100
+ }
101
+ return true;
102
+ }
74
103
  /**
75
104
  * Read a module's stored bundle artifact, or `null` when it has none or the
76
105
  * archive cannot be read. Never throws — a broken artifact must degrade to the
@@ -2,7 +2,7 @@ import { Logger } from '@pgpmjs/logger';
2
2
  import { verifyBundle } from '@pgpmjs/bundle';
3
3
  import { getPgPool } from 'pg-cache';
4
4
  import { PgpmMigrate } from '../migrate/client';
5
- import { buildExecutableBundle, readBundleArtifact } from './artifact';
5
+ import { bundleMatchesModule, buildExecutableBundle, readBundleArtifact } from './artifact';
6
6
  const log = new Logger('deploy-fast');
7
7
  /** Thrown when the ledger already holds a change with different content. */
8
8
  export class BundledDeployConflictError extends Error {
@@ -40,11 +40,17 @@ async function loadExecutableBundle(moduleDir) {
40
40
  const stored = readBundleArtifact(moduleDir);
41
41
  if (stored) {
42
42
  const issues = verifyBundle(stored);
43
- if (issues.length === 0) {
43
+ if (issues.length > 0) {
44
+ log.warn(`⚠️ Bundle artifact for ${stored.manifest.name} failed verification ` +
45
+ `(${issues.map(i => i.kind).join(', ')}); rebuilding from deploy/.`);
46
+ }
47
+ else if (!bundleMatchesModule(moduleDir, stored)) {
48
+ log.warn(`⚠️ Bundle artifact for ${stored.manifest.name} is stale (deploy/ has ` +
49
+ `changed since it was packaged); rebuilding from deploy/.`);
50
+ }
51
+ else {
44
52
  return { bundle: stored, source: 'artifact' };
45
53
  }
46
- log.warn(`⚠️ Bundle artifact for ${stored.manifest.name} failed verification ` +
47
- `(${issues.map(i => i.kind).join(', ')}); rebuilding from deploy/.`);
48
54
  }
49
55
  return { bundle: await buildExecutableBundle(moduleDir), source: 'packaged' };
50
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgpmjs/core",
3
- "version": "7.17.0",
3
+ "version": "7.18.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "PGPM Package and Migration Tools",
6
6
  "main": "index.js",
@@ -48,24 +48,24 @@
48
48
  "makage": "^0.3.0"
49
49
  },
50
50
  "dependencies": {
51
- "@pgpmjs/ast": "^0.9.4",
52
- "@pgpmjs/bundle": "^0.12.2",
53
- "@pgpmjs/env": "^2.39.1",
54
- "@pgpmjs/logger": "^2.22.0",
55
- "@pgpmjs/server-utils": "^3.23.3",
56
- "@pgpmjs/slice": "^0.11.0",
57
- "@pgpmjs/transform": "^0.12.0",
58
- "@pgpmjs/types": "^2.48.0",
59
- "csv-to-pg": "^4.8.0",
51
+ "@pgpmjs/ast": "^0.9.5",
52
+ "@pgpmjs/bundle": "^0.12.4",
53
+ "@pgpmjs/env": "^2.39.2",
54
+ "@pgpmjs/logger": "^2.22.1",
55
+ "@pgpmjs/server-utils": "^3.23.4",
56
+ "@pgpmjs/slice": "^0.12.0",
57
+ "@pgpmjs/transform": "^0.13.0",
58
+ "@pgpmjs/types": "^2.48.1",
59
+ "csv-to-pg": "^4.8.1",
60
60
  "genomic": "^5.6.2",
61
61
  "glob": "^13.0.6",
62
62
  "parse-package-name": "^1.0.0",
63
63
  "pg": "^8.21.0",
64
- "pg-cache": "^3.23.3",
65
- "pg-env": "^1.26.0",
64
+ "pg-cache": "^3.23.4",
65
+ "pg-env": "^1.26.1",
66
66
  "pgsql-deparser": "^18.1.1",
67
67
  "pgsql-parser": "^18.1.1",
68
68
  "yanse": "^0.2.1"
69
69
  },
70
- "gitHead": "2832d5632d11eb22e1437df773706ac6a99175fe"
70
+ "gitHead": "04b34ce4b5efcebfd8e49d64d196e651288d97f3"
71
71
  }