@pgpmjs/diff 0.1.2 → 0.3.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/diff-source.d.ts +16 -5
- package/diff-source.js +27 -5
- package/esm/diff-source.js +25 -4
- package/esm/index.js +2 -1
- package/esm/ledger.js +135 -0
- package/index.d.ts +3 -1
- package/index.js +6 -1
- package/ledger.d.ts +105 -0
- package/ledger.js +140 -0
- package/package.json +5 -5
package/diff-source.d.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Input ingestion + migration emission for `pgpm diff`.
|
|
3
3
|
*
|
|
4
|
-
* A diff side can be a pgpm module directory, a
|
|
4
|
+
* A diff side can be a pgpm module directory, a pgpm workspace (flattened by
|
|
5
|
+
* the CLI, which owns module-map resolution), a raw .sql file, or a live
|
|
5
6
|
* database (handled by the CLI via pg_dump; the dump text lands here through
|
|
6
7
|
* {@link sqlToDiffChanges}). Every side normalizes to the same
|
|
7
8
|
* `DiffInputChange[]` seam the semantic diff driver consumes, so the
|
|
8
9
|
* comparison is source- and dial-invariant.
|
|
9
10
|
*/
|
|
10
11
|
import { PgpmRow } from '@pgpmjs/ast';
|
|
11
|
-
import type { DiffInputChange, SemanticDeltaChange } from '@pgpmjs/transform';
|
|
12
|
+
import type { DiffInputChange, ModuleSource, SemanticDeltaChange } from '@pgpmjs/transform';
|
|
12
13
|
/** What a diff side spec resolves to. */
|
|
13
|
-
export type DiffSideKind = 'module' | 'sql' | 'database';
|
|
14
|
+
export type DiffSideKind = 'module' | 'sql' | 'database' | 'workspace';
|
|
14
15
|
/** A diff side normalized to the semantic diff driver's input seam. */
|
|
15
16
|
export interface DiffSide {
|
|
16
17
|
kind: DiffSideKind;
|
|
@@ -22,7 +23,8 @@ export interface DiffSide {
|
|
|
22
23
|
/**
|
|
23
24
|
* Classify a side spec: `db:<name>` or a postgres:// / postgresql://
|
|
24
25
|
* connection string is a live database; a directory containing pgpm.plan is
|
|
25
|
-
* a module;
|
|
26
|
+
* a module; a directory containing a workspace config (pgpm.json /
|
|
27
|
+
* pgpm.config.js) is a workspace; an existing file is raw SQL.
|
|
26
28
|
*/
|
|
27
29
|
export declare const resolveDiffSideKind: (spec: string) => DiffSideKind;
|
|
28
30
|
/**
|
|
@@ -33,9 +35,18 @@ export declare const resolveDiffSideKind: (spec: string) => DiffSideKind;
|
|
|
33
35
|
export declare const stripDumpPreamble: (sql: string) => string;
|
|
34
36
|
/** Wrap raw SQL text (a .sql file or a schema dump) as a single diff change. */
|
|
35
37
|
export declare const sqlToDiffChanges: (sql: string, name: string) => DiffInputChange[];
|
|
38
|
+
/**
|
|
39
|
+
* Flatten loaded workspace modules (in cross-module topological order) into
|
|
40
|
+
* diff input changes. Names are package-qualified (`pkg:change`) and local
|
|
41
|
+
* dependencies are qualified with their own package, matching how the ledger
|
|
42
|
+
* records cross-package requires.
|
|
43
|
+
*/
|
|
44
|
+
export declare const workspaceModulesToDiffChanges: (modules: ModuleSource[]) => DiffInputChange[];
|
|
36
45
|
/**
|
|
37
46
|
* Load an on-disk diff side (module directory or .sql file). Live databases
|
|
38
|
-
* are dumped by the caller and fed through {@link sqlToDiffChanges}
|
|
47
|
+
* are dumped by the caller and fed through {@link sqlToDiffChanges};
|
|
48
|
+
* workspaces are flattened by the caller (module-map resolution needs
|
|
49
|
+
* `@pgpmjs/core`) and fed through {@link workspaceModulesToDiffChanges}.
|
|
39
50
|
*/
|
|
40
51
|
export declare const loadDiffSideFromDisk: (spec: string) => DiffSide;
|
|
41
52
|
/**
|
package/diff-source.js
CHANGED
|
@@ -33,15 +33,18 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.deltaChangesToRows = exports.loadDiffSideFromDisk = exports.sqlToDiffChanges = exports.stripDumpPreamble = exports.resolveDiffSideKind = void 0;
|
|
36
|
+
exports.deltaChangesToRows = exports.loadDiffSideFromDisk = exports.workspaceModulesToDiffChanges = exports.sqlToDiffChanges = exports.stripDumpPreamble = exports.resolveDiffSideKind = void 0;
|
|
37
37
|
const naming_spec_1 = require("@pgpmjs/naming-spec");
|
|
38
38
|
const transform_1 = require("@pgpmjs/transform");
|
|
39
39
|
const fs = __importStar(require("fs"));
|
|
40
40
|
const path = __importStar(require("path"));
|
|
41
|
+
/** Files whose presence marks a directory as a pgpm workspace root. */
|
|
42
|
+
const WORKSPACE_CONFIG_FILES = ['pgpm.json', 'pgpm.config.js'];
|
|
41
43
|
/**
|
|
42
44
|
* Classify a side spec: `db:<name>` or a postgres:// / postgresql://
|
|
43
45
|
* connection string is a live database; a directory containing pgpm.plan is
|
|
44
|
-
* a module;
|
|
46
|
+
* a module; a directory containing a workspace config (pgpm.json /
|
|
47
|
+
* pgpm.config.js) is a workspace; an existing file is raw SQL.
|
|
45
48
|
*/
|
|
46
49
|
const resolveDiffSideKind = (spec) => {
|
|
47
50
|
if (/^postgres(ql)?:\/\//.test(spec) || spec.startsWith('db:'))
|
|
@@ -51,11 +54,13 @@ const resolveDiffSideKind = (spec) => {
|
|
|
51
54
|
if (fs.statSync(resolved).isDirectory()) {
|
|
52
55
|
if (fs.existsSync(path.join(resolved, 'pgpm.plan')))
|
|
53
56
|
return 'module';
|
|
54
|
-
|
|
57
|
+
if (WORKSPACE_CONFIG_FILES.some(f => fs.existsSync(path.join(resolved, f))))
|
|
58
|
+
return 'workspace';
|
|
59
|
+
throw new Error(`${spec}: directory has no pgpm.plan or workspace config (not a pgpm module or workspace)`);
|
|
55
60
|
}
|
|
56
61
|
return 'sql';
|
|
57
62
|
}
|
|
58
|
-
throw new Error(`${spec}: not a module directory, .sql file, db:<name>, or connection string`);
|
|
63
|
+
throw new Error(`${spec}: not a module directory, workspace, .sql file, db:<name>, or connection string`);
|
|
59
64
|
};
|
|
60
65
|
exports.resolveDiffSideKind = resolveDiffSideKind;
|
|
61
66
|
/** Lines pg_dump emits that carry no schema content. */
|
|
@@ -84,15 +89,32 @@ const sqlToDiffChanges = (sql, name) => [
|
|
|
84
89
|
}
|
|
85
90
|
];
|
|
86
91
|
exports.sqlToDiffChanges = sqlToDiffChanges;
|
|
92
|
+
/**
|
|
93
|
+
* Flatten loaded workspace modules (in cross-module topological order) into
|
|
94
|
+
* diff input changes. Names are package-qualified (`pkg:change`) and local
|
|
95
|
+
* dependencies are qualified with their own package, matching how the ledger
|
|
96
|
+
* records cross-package requires.
|
|
97
|
+
*/
|
|
98
|
+
const workspaceModulesToDiffChanges = (modules) => modules.flatMap(mod => mod.changes.map(change => ({
|
|
99
|
+
name: `${mod.name}:${change.name}`,
|
|
100
|
+
dependencies: change.dependencies.map(dep => dep.includes(':') ? dep : `${mod.name}:${dep}`),
|
|
101
|
+
deploy: change.deploy
|
|
102
|
+
})));
|
|
103
|
+
exports.workspaceModulesToDiffChanges = workspaceModulesToDiffChanges;
|
|
87
104
|
/**
|
|
88
105
|
* Load an on-disk diff side (module directory or .sql file). Live databases
|
|
89
|
-
* are dumped by the caller and fed through {@link sqlToDiffChanges}
|
|
106
|
+
* are dumped by the caller and fed through {@link sqlToDiffChanges};
|
|
107
|
+
* workspaces are flattened by the caller (module-map resolution needs
|
|
108
|
+
* `@pgpmjs/core`) and fed through {@link workspaceModulesToDiffChanges}.
|
|
90
109
|
*/
|
|
91
110
|
const loadDiffSideFromDisk = (spec) => {
|
|
92
111
|
const kind = (0, exports.resolveDiffSideKind)(spec);
|
|
93
112
|
if (kind === 'database') {
|
|
94
113
|
throw new Error(`${spec}: live database sides must be dumped by the caller`);
|
|
95
114
|
}
|
|
115
|
+
if (kind === 'workspace') {
|
|
116
|
+
throw new Error(`${spec}: workspace sides must be flattened by the caller`);
|
|
117
|
+
}
|
|
96
118
|
const resolved = path.resolve(spec);
|
|
97
119
|
if (kind === 'module') {
|
|
98
120
|
const source = (0, transform_1.loadModuleSource)(resolved);
|
package/esm/diff-source.js
CHANGED
|
@@ -2,10 +2,13 @@ import { alterationPathFor } from '@pgpmjs/naming-spec';
|
|
|
2
2
|
import { loadModuleSource, stripTransactionWrapper } from '@pgpmjs/transform';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
import * as path from 'path';
|
|
5
|
+
/** Files whose presence marks a directory as a pgpm workspace root. */
|
|
6
|
+
const WORKSPACE_CONFIG_FILES = ['pgpm.json', 'pgpm.config.js'];
|
|
5
7
|
/**
|
|
6
8
|
* Classify a side spec: `db:<name>` or a postgres:// / postgresql://
|
|
7
9
|
* connection string is a live database; a directory containing pgpm.plan is
|
|
8
|
-
* a module;
|
|
10
|
+
* a module; a directory containing a workspace config (pgpm.json /
|
|
11
|
+
* pgpm.config.js) is a workspace; an existing file is raw SQL.
|
|
9
12
|
*/
|
|
10
13
|
export const resolveDiffSideKind = (spec) => {
|
|
11
14
|
if (/^postgres(ql)?:\/\//.test(spec) || spec.startsWith('db:'))
|
|
@@ -15,11 +18,13 @@ export const resolveDiffSideKind = (spec) => {
|
|
|
15
18
|
if (fs.statSync(resolved).isDirectory()) {
|
|
16
19
|
if (fs.existsSync(path.join(resolved, 'pgpm.plan')))
|
|
17
20
|
return 'module';
|
|
18
|
-
|
|
21
|
+
if (WORKSPACE_CONFIG_FILES.some(f => fs.existsSync(path.join(resolved, f))))
|
|
22
|
+
return 'workspace';
|
|
23
|
+
throw new Error(`${spec}: directory has no pgpm.plan or workspace config (not a pgpm module or workspace)`);
|
|
19
24
|
}
|
|
20
25
|
return 'sql';
|
|
21
26
|
}
|
|
22
|
-
throw new Error(`${spec}: not a module directory, .sql file, db:<name>, or connection string`);
|
|
27
|
+
throw new Error(`${spec}: not a module directory, workspace, .sql file, db:<name>, or connection string`);
|
|
23
28
|
};
|
|
24
29
|
/** Lines pg_dump emits that carry no schema content. */
|
|
25
30
|
const DUMP_NOISE = [
|
|
@@ -45,15 +50,31 @@ export const sqlToDiffChanges = (sql, name) => [
|
|
|
45
50
|
deploy: stripDumpPreamble(stripTransactionWrapper(sql))
|
|
46
51
|
}
|
|
47
52
|
];
|
|
53
|
+
/**
|
|
54
|
+
* Flatten loaded workspace modules (in cross-module topological order) into
|
|
55
|
+
* diff input changes. Names are package-qualified (`pkg:change`) and local
|
|
56
|
+
* dependencies are qualified with their own package, matching how the ledger
|
|
57
|
+
* records cross-package requires.
|
|
58
|
+
*/
|
|
59
|
+
export const workspaceModulesToDiffChanges = (modules) => modules.flatMap(mod => mod.changes.map(change => ({
|
|
60
|
+
name: `${mod.name}:${change.name}`,
|
|
61
|
+
dependencies: change.dependencies.map(dep => dep.includes(':') ? dep : `${mod.name}:${dep}`),
|
|
62
|
+
deploy: change.deploy
|
|
63
|
+
})));
|
|
48
64
|
/**
|
|
49
65
|
* Load an on-disk diff side (module directory or .sql file). Live databases
|
|
50
|
-
* are dumped by the caller and fed through {@link sqlToDiffChanges}
|
|
66
|
+
* are dumped by the caller and fed through {@link sqlToDiffChanges};
|
|
67
|
+
* workspaces are flattened by the caller (module-map resolution needs
|
|
68
|
+
* `@pgpmjs/core`) and fed through {@link workspaceModulesToDiffChanges}.
|
|
51
69
|
*/
|
|
52
70
|
export const loadDiffSideFromDisk = (spec) => {
|
|
53
71
|
const kind = resolveDiffSideKind(spec);
|
|
54
72
|
if (kind === 'database') {
|
|
55
73
|
throw new Error(`${spec}: live database sides must be dumped by the caller`);
|
|
56
74
|
}
|
|
75
|
+
if (kind === 'workspace') {
|
|
76
|
+
throw new Error(`${spec}: workspace sides must be flattened by the caller`);
|
|
77
|
+
}
|
|
57
78
|
const resolved = path.resolve(spec);
|
|
58
79
|
if (kind === 'module') {
|
|
59
80
|
const source = loadModuleSource(resolved);
|
package/esm/index.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export { deltaChangesToRows, loadDiffSideFromDisk, resolveDiffSideKind, sqlToDiffChanges, stripDumpPreamble } from './diff-source';
|
|
1
|
+
export { deltaChangesToRows, loadDiffSideFromDisk, resolveDiffSideKind, sqlToDiffChanges, stripDumpPreamble, workspaceModulesToDiffChanges } from './diff-source';
|
|
2
|
+
export { classifyAgainstLedger, emitLedgerBackfill, selectBackfillEntries } from './ledger';
|
package/esm/ledger.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger-aware classification and backfill for `pgpm diff`.
|
|
3
|
+
*
|
|
4
|
+
* The `pgpm_migrate` ledger is a cursor into a workspace's deployment
|
|
5
|
+
* history: which plan changes a database has executed, with what script
|
|
6
|
+
* hashes, in what order. These pure helpers relate a workspace's plan to a
|
|
7
|
+
* ledger snapshot (classification) and project a set of already-satisfied
|
|
8
|
+
* changes into idempotent ledger writes (backfill) so a database can adopt a
|
|
9
|
+
* regenerated/reordered plan without re-executing what it already has.
|
|
10
|
+
*/
|
|
11
|
+
const refKey = (pkg, name) => `${pkg}:${name}`;
|
|
12
|
+
/**
|
|
13
|
+
* Classify a plan against a ledger snapshot. Pure and name/hash-based:
|
|
14
|
+
* `deployed-identical` requires the ledger's recorded hash to be one of the
|
|
15
|
+
* plan entry's candidate hashes; a name match with a foreign hash is
|
|
16
|
+
* `deployed-drifted`. Semantic satisfaction (same objects under different
|
|
17
|
+
* names) is the coverage layer's job (`coverChanges`), not this one's.
|
|
18
|
+
*/
|
|
19
|
+
export function classifyAgainstLedger(plan, ledger) {
|
|
20
|
+
const ledgerByKey = new Map();
|
|
21
|
+
for (const row of ledger) {
|
|
22
|
+
ledgerByKey.set(refKey(row.package, row.changeName), row);
|
|
23
|
+
}
|
|
24
|
+
const planKeys = new Set(plan.map(ref => refKey(ref.package, ref.name)));
|
|
25
|
+
const entries = plan.map(ref => {
|
|
26
|
+
const row = ledgerByKey.get(refKey(ref.package, ref.name));
|
|
27
|
+
if (!row)
|
|
28
|
+
return { package: ref.package, name: ref.name, status: 'pending' };
|
|
29
|
+
return {
|
|
30
|
+
package: ref.package,
|
|
31
|
+
name: ref.name,
|
|
32
|
+
status: ref.hashes.includes(row.scriptHash) ? 'deployed-identical' : 'deployed-drifted',
|
|
33
|
+
ledgerHash: row.scriptHash
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
const orphaned = ledger.filter(row => !planKeys.has(refKey(row.package, row.changeName)));
|
|
37
|
+
// Inversion detection: walk the ledger in deploy order and flag any
|
|
38
|
+
// plan-known change that lands after a change the plan places later.
|
|
39
|
+
const planIndex = new Map();
|
|
40
|
+
plan.forEach((ref, i) => planIndex.set(refKey(ref.package, ref.name), i));
|
|
41
|
+
const outOfOrder = [];
|
|
42
|
+
let maxSeen = -1;
|
|
43
|
+
for (const row of ledger) {
|
|
44
|
+
const idx = planIndex.get(refKey(row.package, row.changeName));
|
|
45
|
+
if (idx === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
if (idx < maxSeen)
|
|
48
|
+
outOfOrder.push(refKey(row.package, row.changeName));
|
|
49
|
+
else
|
|
50
|
+
maxSeen = idx;
|
|
51
|
+
}
|
|
52
|
+
return { entries, orphaned, outOfOrder };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Choose which plan entries to backfill: those the ledger does not record
|
|
56
|
+
* (`pending`) but whose objects side A already has (coverage `satisfied`).
|
|
57
|
+
* Entries with `inert` or `partial` coverage are reported as unprovable and
|
|
58
|
+
* left for a normal deploy — backfilling them would claim DDL ran that did
|
|
59
|
+
* not. Pure: the caller supplies the plan refs, the ledger classification,
|
|
60
|
+
* and the coverage.
|
|
61
|
+
*/
|
|
62
|
+
export function selectBackfillEntries(refs, classification, coverage) {
|
|
63
|
+
// Coverage names follow the side: `pkg:change` for workspaces, bare change
|
|
64
|
+
// names for a single module. Accept either.
|
|
65
|
+
const coverageByName = new Map();
|
|
66
|
+
for (const cov of coverage)
|
|
67
|
+
coverageByName.set(cov.name, cov);
|
|
68
|
+
const coverageFor = (ref) => coverageByName.get(refKey(ref.package, ref.name)) ?? coverageByName.get(ref.name);
|
|
69
|
+
const pending = new Set(classification.entries.filter(e => e.status === 'pending').map(e => refKey(e.package, e.name)));
|
|
70
|
+
const backfilled = [];
|
|
71
|
+
const unprovable = [];
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const ref of refs) {
|
|
74
|
+
if (!pending.has(refKey(ref.package, ref.name)))
|
|
75
|
+
continue;
|
|
76
|
+
const status = coverageFor(ref)?.status;
|
|
77
|
+
if (status === 'inert' || status === 'partial') {
|
|
78
|
+
unprovable.push(refKey(ref.package, ref.name));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (status !== 'satisfied')
|
|
82
|
+
continue;
|
|
83
|
+
// The content hash is what a default `pgpm deploy` records and skips by;
|
|
84
|
+
// without one there is nothing meaningful to record.
|
|
85
|
+
const scriptHash = ref.hashes[0];
|
|
86
|
+
if (!scriptHash)
|
|
87
|
+
continue;
|
|
88
|
+
backfilled.push(refKey(ref.package, ref.name));
|
|
89
|
+
entries.push({
|
|
90
|
+
package: ref.package,
|
|
91
|
+
changeName: ref.name,
|
|
92
|
+
scriptHash,
|
|
93
|
+
requires: ref.dependencies
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { entries, backfilled, unprovable };
|
|
97
|
+
}
|
|
98
|
+
const sqlLiteral = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
99
|
+
/**
|
|
100
|
+
* Project backfill entries into an idempotent SQL script that records them in
|
|
101
|
+
* `pgpm_migrate` without executing any DDL, by calling the same
|
|
102
|
+
* `pgpm_migrate.deploy` procedure a real deploy uses with `p_log_only =>
|
|
103
|
+
* TRUE` and an empty script. Entries must be in plan order (the procedure
|
|
104
|
+
* enforces that requires are already recorded). The target database must
|
|
105
|
+
* already have the `pgpm_migrate` schema (any prior `pgpm deploy` creates
|
|
106
|
+
* it).
|
|
107
|
+
*/
|
|
108
|
+
export function emitLedgerBackfill(entries, options = {}) {
|
|
109
|
+
const transaction = options.transaction !== false;
|
|
110
|
+
const lines = [
|
|
111
|
+
'-- pgpm ledger backfill: record already-satisfied changes in pgpm_migrate',
|
|
112
|
+
'-- without executing them (log-only deploys through pgpm_migrate.deploy).',
|
|
113
|
+
''
|
|
114
|
+
];
|
|
115
|
+
if (transaction)
|
|
116
|
+
lines.push('BEGIN;', '');
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
const requires = entry.requires.length > 0
|
|
119
|
+
? `ARRAY[${entry.requires.map(sqlLiteral).join(', ')}]`
|
|
120
|
+
: 'NULL';
|
|
121
|
+
lines.push('CALL pgpm_migrate.deploy(' +
|
|
122
|
+
[
|
|
123
|
+
sqlLiteral(entry.package),
|
|
124
|
+
sqlLiteral(entry.changeName),
|
|
125
|
+
sqlLiteral(entry.scriptHash),
|
|
126
|
+
requires,
|
|
127
|
+
"''",
|
|
128
|
+
'TRUE'
|
|
129
|
+
].join(', ') +
|
|
130
|
+
');');
|
|
131
|
+
}
|
|
132
|
+
if (transaction)
|
|
133
|
+
lines.push('', 'COMMIT;');
|
|
134
|
+
return `${lines.join('\n')}\n`;
|
|
135
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export type { DiffSide, DiffSideKind } from './diff-source';
|
|
2
|
-
export { deltaChangesToRows, loadDiffSideFromDisk, resolveDiffSideKind, sqlToDiffChanges, stripDumpPreamble } from './diff-source';
|
|
2
|
+
export { deltaChangesToRows, loadDiffSideFromDisk, resolveDiffSideKind, sqlToDiffChanges, stripDumpPreamble, workspaceModulesToDiffChanges } from './diff-source';
|
|
3
|
+
export type { BackfillSelection, CoverageStatus, LedgerBackfillEntry, LedgerChangeRecord, LedgerClassification, LedgerEntryClassification, LedgerStatus, PlanChangeRef } from './ledger';
|
|
4
|
+
export { classifyAgainstLedger, emitLedgerBackfill, selectBackfillEntries } from './ledger';
|
package/index.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.stripDumpPreamble = exports.sqlToDiffChanges = exports.resolveDiffSideKind = exports.loadDiffSideFromDisk = exports.deltaChangesToRows = void 0;
|
|
3
|
+
exports.selectBackfillEntries = exports.emitLedgerBackfill = exports.classifyAgainstLedger = exports.workspaceModulesToDiffChanges = exports.stripDumpPreamble = exports.sqlToDiffChanges = exports.resolveDiffSideKind = exports.loadDiffSideFromDisk = exports.deltaChangesToRows = void 0;
|
|
4
4
|
var diff_source_1 = require("./diff-source");
|
|
5
5
|
Object.defineProperty(exports, "deltaChangesToRows", { enumerable: true, get: function () { return diff_source_1.deltaChangesToRows; } });
|
|
6
6
|
Object.defineProperty(exports, "loadDiffSideFromDisk", { enumerable: true, get: function () { return diff_source_1.loadDiffSideFromDisk; } });
|
|
7
7
|
Object.defineProperty(exports, "resolveDiffSideKind", { enumerable: true, get: function () { return diff_source_1.resolveDiffSideKind; } });
|
|
8
8
|
Object.defineProperty(exports, "sqlToDiffChanges", { enumerable: true, get: function () { return diff_source_1.sqlToDiffChanges; } });
|
|
9
9
|
Object.defineProperty(exports, "stripDumpPreamble", { enumerable: true, get: function () { return diff_source_1.stripDumpPreamble; } });
|
|
10
|
+
Object.defineProperty(exports, "workspaceModulesToDiffChanges", { enumerable: true, get: function () { return diff_source_1.workspaceModulesToDiffChanges; } });
|
|
11
|
+
var ledger_1 = require("./ledger");
|
|
12
|
+
Object.defineProperty(exports, "classifyAgainstLedger", { enumerable: true, get: function () { return ledger_1.classifyAgainstLedger; } });
|
|
13
|
+
Object.defineProperty(exports, "emitLedgerBackfill", { enumerable: true, get: function () { return ledger_1.emitLedgerBackfill; } });
|
|
14
|
+
Object.defineProperty(exports, "selectBackfillEntries", { enumerable: true, get: function () { return ledger_1.selectBackfillEntries; } });
|
package/ledger.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger-aware classification and backfill for `pgpm diff`.
|
|
3
|
+
*
|
|
4
|
+
* The `pgpm_migrate` ledger is a cursor into a workspace's deployment
|
|
5
|
+
* history: which plan changes a database has executed, with what script
|
|
6
|
+
* hashes, in what order. These pure helpers relate a workspace's plan to a
|
|
7
|
+
* ledger snapshot (classification) and project a set of already-satisfied
|
|
8
|
+
* changes into idempotent ledger writes (backfill) so a database can adopt a
|
|
9
|
+
* regenerated/reordered plan without re-executing what it already has.
|
|
10
|
+
*/
|
|
11
|
+
/** One row of a database's `pgpm_migrate` ledger, in deploy order. */
|
|
12
|
+
export interface LedgerChangeRecord {
|
|
13
|
+
package: string;
|
|
14
|
+
changeName: string;
|
|
15
|
+
scriptHash: string;
|
|
16
|
+
deployedAt?: Date;
|
|
17
|
+
requires?: string[];
|
|
18
|
+
}
|
|
19
|
+
/** One plan entry of a workspace/module side, with its candidate hashes. */
|
|
20
|
+
export interface PlanChangeRef {
|
|
21
|
+
package: string;
|
|
22
|
+
name: string;
|
|
23
|
+
/**
|
|
24
|
+
* Hashes that count as "this exact script": typically the content hash
|
|
25
|
+
* (what `pgpm deploy` records by default) plus the AST hash (formatting-
|
|
26
|
+
* and comment-proof, what `DEPLOYMENT_HASH_METHOD=ast` records).
|
|
27
|
+
*/
|
|
28
|
+
hashes: string[];
|
|
29
|
+
/** Plan dependencies as written (local names or `pkg:change`). */
|
|
30
|
+
dependencies: string[];
|
|
31
|
+
}
|
|
32
|
+
/** How one plan entry relates to the ledger. */
|
|
33
|
+
export type LedgerStatus = 'deployed-identical' | 'deployed-drifted' | 'pending';
|
|
34
|
+
export interface LedgerEntryClassification {
|
|
35
|
+
package: string;
|
|
36
|
+
name: string;
|
|
37
|
+
status: LedgerStatus;
|
|
38
|
+
/** The hash the ledger recorded, when the change name is in the ledger. */
|
|
39
|
+
ledgerHash?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface LedgerClassification {
|
|
42
|
+
entries: LedgerEntryClassification[];
|
|
43
|
+
/** Ledger rows whose (package, change) appears in no plan entry. */
|
|
44
|
+
orphaned: LedgerChangeRecord[];
|
|
45
|
+
/**
|
|
46
|
+
* Plan-known changes whose recorded deploy order inverts the plan order
|
|
47
|
+
* (deployed before a change that precedes them in the plan).
|
|
48
|
+
*/
|
|
49
|
+
outOfOrder: string[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Classify a plan against a ledger snapshot. Pure and name/hash-based:
|
|
53
|
+
* `deployed-identical` requires the ledger's recorded hash to be one of the
|
|
54
|
+
* plan entry's candidate hashes; a name match with a foreign hash is
|
|
55
|
+
* `deployed-drifted`. Semantic satisfaction (same objects under different
|
|
56
|
+
* names) is the coverage layer's job (`coverChanges`), not this one's.
|
|
57
|
+
*/
|
|
58
|
+
export declare function classifyAgainstLedger(plan: PlanChangeRef[], ledger: LedgerChangeRecord[]): LedgerClassification;
|
|
59
|
+
/**
|
|
60
|
+
* A change's semantic coverage, as produced by `coverChanges`. Structural so
|
|
61
|
+
* this layer stays free of the transform driver's imports.
|
|
62
|
+
*/
|
|
63
|
+
export interface CoverageStatus {
|
|
64
|
+
/** Change name as the diff side reported it (`pkg:change` or `change`). */
|
|
65
|
+
name: string;
|
|
66
|
+
status: 'satisfied' | 'unsatisfied' | 'partial' | 'inert';
|
|
67
|
+
}
|
|
68
|
+
/** Which plan entries a ledger backfill should record, and which it cannot. */
|
|
69
|
+
export interface BackfillSelection {
|
|
70
|
+
/** Ledger rows to write, in plan order. */
|
|
71
|
+
entries: LedgerBackfillEntry[];
|
|
72
|
+
/** Entries backfilled: semantically satisfied but absent from the ledger. */
|
|
73
|
+
backfilled: string[];
|
|
74
|
+
/** Entries whose satisfaction is unprovable (inert/partial); left to deploy. */
|
|
75
|
+
unprovable: string[];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Choose which plan entries to backfill: those the ledger does not record
|
|
79
|
+
* (`pending`) but whose objects side A already has (coverage `satisfied`).
|
|
80
|
+
* Entries with `inert` or `partial` coverage are reported as unprovable and
|
|
81
|
+
* left for a normal deploy — backfilling them would claim DDL ran that did
|
|
82
|
+
* not. Pure: the caller supplies the plan refs, the ledger classification,
|
|
83
|
+
* and the coverage.
|
|
84
|
+
*/
|
|
85
|
+
export declare function selectBackfillEntries(refs: PlanChangeRef[], classification: LedgerClassification, coverage: CoverageStatus[]): BackfillSelection;
|
|
86
|
+
/** One ledger row to backfill without executing anything. */
|
|
87
|
+
export interface LedgerBackfillEntry {
|
|
88
|
+
package: string;
|
|
89
|
+
changeName: string;
|
|
90
|
+
scriptHash: string;
|
|
91
|
+
/** Requires exactly as the plan records them (local or `pkg:change`). */
|
|
92
|
+
requires: string[];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Project backfill entries into an idempotent SQL script that records them in
|
|
96
|
+
* `pgpm_migrate` without executing any DDL, by calling the same
|
|
97
|
+
* `pgpm_migrate.deploy` procedure a real deploy uses with `p_log_only =>
|
|
98
|
+
* TRUE` and an empty script. Entries must be in plan order (the procedure
|
|
99
|
+
* enforces that requires are already recorded). The target database must
|
|
100
|
+
* already have the `pgpm_migrate` schema (any prior `pgpm deploy` creates
|
|
101
|
+
* it).
|
|
102
|
+
*/
|
|
103
|
+
export declare function emitLedgerBackfill(entries: LedgerBackfillEntry[], options?: {
|
|
104
|
+
transaction?: boolean;
|
|
105
|
+
}): string;
|
package/ledger.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Ledger-aware classification and backfill for `pgpm diff`.
|
|
4
|
+
*
|
|
5
|
+
* The `pgpm_migrate` ledger is a cursor into a workspace's deployment
|
|
6
|
+
* history: which plan changes a database has executed, with what script
|
|
7
|
+
* hashes, in what order. These pure helpers relate a workspace's plan to a
|
|
8
|
+
* ledger snapshot (classification) and project a set of already-satisfied
|
|
9
|
+
* changes into idempotent ledger writes (backfill) so a database can adopt a
|
|
10
|
+
* regenerated/reordered plan without re-executing what it already has.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.classifyAgainstLedger = classifyAgainstLedger;
|
|
14
|
+
exports.selectBackfillEntries = selectBackfillEntries;
|
|
15
|
+
exports.emitLedgerBackfill = emitLedgerBackfill;
|
|
16
|
+
const refKey = (pkg, name) => `${pkg}:${name}`;
|
|
17
|
+
/**
|
|
18
|
+
* Classify a plan against a ledger snapshot. Pure and name/hash-based:
|
|
19
|
+
* `deployed-identical` requires the ledger's recorded hash to be one of the
|
|
20
|
+
* plan entry's candidate hashes; a name match with a foreign hash is
|
|
21
|
+
* `deployed-drifted`. Semantic satisfaction (same objects under different
|
|
22
|
+
* names) is the coverage layer's job (`coverChanges`), not this one's.
|
|
23
|
+
*/
|
|
24
|
+
function classifyAgainstLedger(plan, ledger) {
|
|
25
|
+
const ledgerByKey = new Map();
|
|
26
|
+
for (const row of ledger) {
|
|
27
|
+
ledgerByKey.set(refKey(row.package, row.changeName), row);
|
|
28
|
+
}
|
|
29
|
+
const planKeys = new Set(plan.map(ref => refKey(ref.package, ref.name)));
|
|
30
|
+
const entries = plan.map(ref => {
|
|
31
|
+
const row = ledgerByKey.get(refKey(ref.package, ref.name));
|
|
32
|
+
if (!row)
|
|
33
|
+
return { package: ref.package, name: ref.name, status: 'pending' };
|
|
34
|
+
return {
|
|
35
|
+
package: ref.package,
|
|
36
|
+
name: ref.name,
|
|
37
|
+
status: ref.hashes.includes(row.scriptHash) ? 'deployed-identical' : 'deployed-drifted',
|
|
38
|
+
ledgerHash: row.scriptHash
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const orphaned = ledger.filter(row => !planKeys.has(refKey(row.package, row.changeName)));
|
|
42
|
+
// Inversion detection: walk the ledger in deploy order and flag any
|
|
43
|
+
// plan-known change that lands after a change the plan places later.
|
|
44
|
+
const planIndex = new Map();
|
|
45
|
+
plan.forEach((ref, i) => planIndex.set(refKey(ref.package, ref.name), i));
|
|
46
|
+
const outOfOrder = [];
|
|
47
|
+
let maxSeen = -1;
|
|
48
|
+
for (const row of ledger) {
|
|
49
|
+
const idx = planIndex.get(refKey(row.package, row.changeName));
|
|
50
|
+
if (idx === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
if (idx < maxSeen)
|
|
53
|
+
outOfOrder.push(refKey(row.package, row.changeName));
|
|
54
|
+
else
|
|
55
|
+
maxSeen = idx;
|
|
56
|
+
}
|
|
57
|
+
return { entries, orphaned, outOfOrder };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Choose which plan entries to backfill: those the ledger does not record
|
|
61
|
+
* (`pending`) but whose objects side A already has (coverage `satisfied`).
|
|
62
|
+
* Entries with `inert` or `partial` coverage are reported as unprovable and
|
|
63
|
+
* left for a normal deploy — backfilling them would claim DDL ran that did
|
|
64
|
+
* not. Pure: the caller supplies the plan refs, the ledger classification,
|
|
65
|
+
* and the coverage.
|
|
66
|
+
*/
|
|
67
|
+
function selectBackfillEntries(refs, classification, coverage) {
|
|
68
|
+
// Coverage names follow the side: `pkg:change` for workspaces, bare change
|
|
69
|
+
// names for a single module. Accept either.
|
|
70
|
+
const coverageByName = new Map();
|
|
71
|
+
for (const cov of coverage)
|
|
72
|
+
coverageByName.set(cov.name, cov);
|
|
73
|
+
const coverageFor = (ref) => coverageByName.get(refKey(ref.package, ref.name)) ?? coverageByName.get(ref.name);
|
|
74
|
+
const pending = new Set(classification.entries.filter(e => e.status === 'pending').map(e => refKey(e.package, e.name)));
|
|
75
|
+
const backfilled = [];
|
|
76
|
+
const unprovable = [];
|
|
77
|
+
const entries = [];
|
|
78
|
+
for (const ref of refs) {
|
|
79
|
+
if (!pending.has(refKey(ref.package, ref.name)))
|
|
80
|
+
continue;
|
|
81
|
+
const status = coverageFor(ref)?.status;
|
|
82
|
+
if (status === 'inert' || status === 'partial') {
|
|
83
|
+
unprovable.push(refKey(ref.package, ref.name));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (status !== 'satisfied')
|
|
87
|
+
continue;
|
|
88
|
+
// The content hash is what a default `pgpm deploy` records and skips by;
|
|
89
|
+
// without one there is nothing meaningful to record.
|
|
90
|
+
const scriptHash = ref.hashes[0];
|
|
91
|
+
if (!scriptHash)
|
|
92
|
+
continue;
|
|
93
|
+
backfilled.push(refKey(ref.package, ref.name));
|
|
94
|
+
entries.push({
|
|
95
|
+
package: ref.package,
|
|
96
|
+
changeName: ref.name,
|
|
97
|
+
scriptHash,
|
|
98
|
+
requires: ref.dependencies
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { entries, backfilled, unprovable };
|
|
102
|
+
}
|
|
103
|
+
const sqlLiteral = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
104
|
+
/**
|
|
105
|
+
* Project backfill entries into an idempotent SQL script that records them in
|
|
106
|
+
* `pgpm_migrate` without executing any DDL, by calling the same
|
|
107
|
+
* `pgpm_migrate.deploy` procedure a real deploy uses with `p_log_only =>
|
|
108
|
+
* TRUE` and an empty script. Entries must be in plan order (the procedure
|
|
109
|
+
* enforces that requires are already recorded). The target database must
|
|
110
|
+
* already have the `pgpm_migrate` schema (any prior `pgpm deploy` creates
|
|
111
|
+
* it).
|
|
112
|
+
*/
|
|
113
|
+
function emitLedgerBackfill(entries, options = {}) {
|
|
114
|
+
const transaction = options.transaction !== false;
|
|
115
|
+
const lines = [
|
|
116
|
+
'-- pgpm ledger backfill: record already-satisfied changes in pgpm_migrate',
|
|
117
|
+
'-- without executing them (log-only deploys through pgpm_migrate.deploy).',
|
|
118
|
+
''
|
|
119
|
+
];
|
|
120
|
+
if (transaction)
|
|
121
|
+
lines.push('BEGIN;', '');
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
const requires = entry.requires.length > 0
|
|
124
|
+
? `ARRAY[${entry.requires.map(sqlLiteral).join(', ')}]`
|
|
125
|
+
: 'NULL';
|
|
126
|
+
lines.push('CALL pgpm_migrate.deploy(' +
|
|
127
|
+
[
|
|
128
|
+
sqlLiteral(entry.package),
|
|
129
|
+
sqlLiteral(entry.changeName),
|
|
130
|
+
sqlLiteral(entry.scriptHash),
|
|
131
|
+
requires,
|
|
132
|
+
"''",
|
|
133
|
+
'TRUE'
|
|
134
|
+
].join(', ') +
|
|
135
|
+
');');
|
|
136
|
+
}
|
|
137
|
+
if (transaction)
|
|
138
|
+
lines.push('', 'COMMIT;');
|
|
139
|
+
return `${lines.join('\n')}\n`;
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pgpmjs/diff",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "pgpm diff — identity-keyed semantic schema diff between pgpm modules, SQL dumps, and live databases",
|
|
6
6
|
"main": "index.js",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"makage": "^0.3.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@pgpmjs/ast": "^0.
|
|
46
|
-
"@pgpmjs/naming-spec": "^0.
|
|
47
|
-
"@pgpmjs/transform": "^0.
|
|
45
|
+
"@pgpmjs/ast": "^0.11.1",
|
|
46
|
+
"@pgpmjs/naming-spec": "^0.3.0",
|
|
47
|
+
"@pgpmjs/transform": "^0.22.0"
|
|
48
48
|
},
|
|
49
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "6da0705f8aa49d62dc2defb8b135b71674611483"
|
|
50
50
|
}
|