@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 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 raw .sql file, or a live
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; an existing file is raw SQL.
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; an existing file is raw SQL.
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
- throw new Error(`${spec}: directory has no pgpm.plan (not a pgpm module)`);
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);
@@ -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; an existing file is raw SQL.
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
- throw new Error(`${spec}: directory has no pgpm.plan (not a pgpm module)`);
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.1.2",
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.10.3",
46
- "@pgpmjs/naming-spec": "^0.2.0",
47
- "@pgpmjs/transform": "^0.20.0"
45
+ "@pgpmjs/ast": "^0.11.1",
46
+ "@pgpmjs/naming-spec": "^0.3.0",
47
+ "@pgpmjs/transform": "^0.22.0"
48
48
  },
49
- "gitHead": "e6a7a57f88c9deb2a4acecaba2451320d1be8203"
49
+ "gitHead": "6da0705f8aa49d62dc2defb8b135b71674611483"
50
50
  }