@pgpmjs/ast 0.1.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.
Files changed (81) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +99 -0
  3. package/esm/files/extension/index.js +3 -0
  4. package/esm/files/extension/reader.js +79 -0
  5. package/esm/files/extension/writer.js +99 -0
  6. package/esm/files/index.js +6 -0
  7. package/esm/files/plan/generator.js +82 -0
  8. package/esm/files/plan/index.js +5 -0
  9. package/esm/files/plan/parser.js +297 -0
  10. package/esm/files/plan/validators.js +181 -0
  11. package/esm/files/plan/writer.js +142 -0
  12. package/esm/files/sql/header.js +259 -0
  13. package/esm/files/sql/index.js +2 -0
  14. package/esm/files/sql/writer.js +107 -0
  15. package/esm/files/sql-scripts/index.js +2 -0
  16. package/esm/files/sql-scripts/reader.js +19 -0
  17. package/esm/files/types/index.js +1 -0
  18. package/esm/files/types/package.js +1 -0
  19. package/esm/hash.js +9 -0
  20. package/esm/index.js +18 -0
  21. package/esm/module/index.js +12 -0
  22. package/esm/module/parse.js +27 -0
  23. package/esm/module/reader.js +64 -0
  24. package/esm/module/roundtrip.js +65 -0
  25. package/esm/module/types.js +1 -0
  26. package/esm/module/writer.js +45 -0
  27. package/esm/plan-rename.js +28 -0
  28. package/esm/utils/author.js +11 -0
  29. package/files/extension/index.d.ts +2 -0
  30. package/files/extension/index.js +19 -0
  31. package/files/extension/reader.d.ts +24 -0
  32. package/files/extension/reader.js +86 -0
  33. package/files/extension/writer.d.ts +48 -0
  34. package/files/extension/writer.js +107 -0
  35. package/files/index.d.ts +5 -0
  36. package/files/index.js +22 -0
  37. package/files/plan/generator.d.ts +23 -0
  38. package/files/plan/generator.js +90 -0
  39. package/files/plan/index.d.ts +4 -0
  40. package/files/plan/index.js +21 -0
  41. package/files/plan/parser.d.ts +28 -0
  42. package/files/plan/parser.js +304 -0
  43. package/files/plan/validators.d.ts +52 -0
  44. package/files/plan/validators.js +187 -0
  45. package/files/plan/writer.d.ts +27 -0
  46. package/files/plan/writer.js +152 -0
  47. package/files/sql/header.d.ts +114 -0
  48. package/files/sql/header.js +266 -0
  49. package/files/sql/index.d.ts +2 -0
  50. package/files/sql/index.js +18 -0
  51. package/files/sql/writer.d.ts +12 -0
  52. package/files/sql/writer.js +114 -0
  53. package/files/sql-scripts/index.d.ts +1 -0
  54. package/files/sql-scripts/index.js +18 -0
  55. package/files/sql-scripts/reader.d.ts +8 -0
  56. package/files/sql-scripts/reader.js +23 -0
  57. package/files/types/index.d.ts +46 -0
  58. package/files/types/index.js +17 -0
  59. package/files/types/package.d.ts +20 -0
  60. package/files/types/package.js +2 -0
  61. package/hash.d.ts +6 -0
  62. package/hash.js +12 -0
  63. package/index.d.ts +19 -0
  64. package/index.js +36 -0
  65. package/module/index.d.ts +12 -0
  66. package/module/index.js +28 -0
  67. package/module/parse.d.ts +23 -0
  68. package/module/parse.js +31 -0
  69. package/module/reader.d.ts +13 -0
  70. package/module/reader.js +67 -0
  71. package/module/roundtrip.d.ts +22 -0
  72. package/module/roundtrip.js +68 -0
  73. package/module/types.d.ts +72 -0
  74. package/module/types.js +2 -0
  75. package/module/writer.d.ts +30 -0
  76. package/module/writer.js +50 -0
  77. package/package.json +48 -0
  78. package/plan-rename.d.ts +8 -0
  79. package/plan-rename.js +31 -0
  80. package/utils/author.d.ts +5 -0
  81. package/utils/author.js +14 -0
@@ -0,0 +1,259 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { parsePlanFile } from '../plan/parser';
4
+ import { parseReference } from '../plan/validators';
5
+ const HEADER_LINE = /^--\s*(Deploy|Revert|Verify)\b(:)?\s*(.*?)\s*$/;
6
+ const REQUIRES_LINE = /^--\s*requires:\s*(.*?)\s*$/;
7
+ const COMMENT_LINE = /^\s*--/;
8
+ const TO_PG_SUFFIX = /\s+to\s+pg\s*$/;
9
+ /**
10
+ * Parse the header block of a pgpm SQL script into a structured model.
11
+ *
12
+ * Recognizes both header formats:
13
+ * `-- Deploy <project>:<change> to pg` (legacy sqitch-compatible)
14
+ * `-- Deploy <change>` / `-- Deploy: <change>` (new)
15
+ * along with `-- requires: <ref>` lines where `<ref>` may be a plain change
16
+ * name, `{package}:{change}`, `@tag`, or `{package}:@tag`.
17
+ *
18
+ * The parse is lossless: `header.lines` plus `body` reassemble the original
19
+ * content byte-exactly (see {@link writePgpmScript}).
20
+ */
21
+ export function parsePgpmHeader(content) {
22
+ const header = {
23
+ verb: null,
24
+ project: null,
25
+ change: null,
26
+ toPg: false,
27
+ requires: [],
28
+ lines: []
29
+ };
30
+ const lines = content.split('\n');
31
+ let lastHeaderIdx = -1;
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const line = lines[i];
34
+ if (line.trim() === '') {
35
+ continue;
36
+ }
37
+ if (!COMMENT_LINE.test(line)) {
38
+ break;
39
+ }
40
+ const headerMatch = line.match(HEADER_LINE);
41
+ if (headerMatch && header.verb === null) {
42
+ header.verb = headerMatch[1];
43
+ let target = headerMatch[3];
44
+ if (TO_PG_SUFFIX.test(target)) {
45
+ header.toPg = true;
46
+ target = target.replace(TO_PG_SUFFIX, '');
47
+ }
48
+ const colonIndex = target.indexOf(':');
49
+ if (colonIndex > 0 && /^[a-zA-Z0-9_-]+$/.test(target.substring(0, colonIndex))) {
50
+ header.project = target.substring(0, colonIndex);
51
+ header.change = target.substring(colonIndex + 1).trim();
52
+ }
53
+ else {
54
+ header.change = target.trim() || null;
55
+ }
56
+ lastHeaderIdx = i;
57
+ continue;
58
+ }
59
+ const requiresMatch = line.match(REQUIRES_LINE);
60
+ if (requiresMatch) {
61
+ const raw = requiresMatch[1];
62
+ header.requires.push({ raw, ref: parseReference(raw) });
63
+ lastHeaderIdx = i;
64
+ continue;
65
+ }
66
+ // Other comment lines (e.g. attribution) before/between header lines are
67
+ // tolerated but only absorbed into the header when a later header line
68
+ // follows; trailing comments belong to the body.
69
+ }
70
+ header.lines = lines.slice(0, lastHeaderIdx + 1);
71
+ return {
72
+ header,
73
+ body: lines.slice(lastHeaderIdx + 1).join('\n')
74
+ };
75
+ }
76
+ /**
77
+ * Serialize a header back to its raw lines. When the structured fields have
78
+ * been modified via {@link renameInHeader}, the raw lines already reflect the
79
+ * change; this simply joins header and body losslessly.
80
+ */
81
+ export function writePgpmScript(script) {
82
+ if (script.header.lines.length === 0)
83
+ return script.body;
84
+ return script.header.lines.join('\n') + '\n' + script.body;
85
+ }
86
+ /**
87
+ * Rewrite change names in a script header according to a rename map
88
+ * (old change name -> new change name). Rewrites both the `-- Deploy` line's
89
+ * change identity and any `-- requires:` lines referencing renamed changes,
90
+ * preserving format (project qualifier, ` to pg` suffix, tag refs untouched).
91
+ *
92
+ * Returns the number of substitutions applied; mutates `script` in place.
93
+ */
94
+ export function renameInHeader(script, renames) {
95
+ const { header } = script;
96
+ let count = 0;
97
+ const renameRef = (raw) => {
98
+ const ref = parseReference(raw);
99
+ if (!ref) {
100
+ const to = renames.get(raw);
101
+ return to ?? null;
102
+ }
103
+ if (ref.tag || ref.symbolic || ref.relative)
104
+ return null;
105
+ const change = ref.change;
106
+ if (!change)
107
+ return null;
108
+ const to = renames.get(change);
109
+ if (!to)
110
+ return null;
111
+ return ref.package ? `${ref.package}:${to}` : to;
112
+ };
113
+ header.lines = header.lines.map(line => {
114
+ const headerMatch = line.match(HEADER_LINE);
115
+ if (headerMatch && header.change && renames.has(header.change)) {
116
+ const to = renames.get(header.change);
117
+ const oldTarget = header.project ? `${header.project}:${header.change}` : header.change;
118
+ const newTarget = header.project ? `${header.project}:${to}` : to;
119
+ const rewritten = line.replace(oldTarget, newTarget);
120
+ if (rewritten !== line) {
121
+ header.change = to;
122
+ count++;
123
+ return rewritten;
124
+ }
125
+ return line;
126
+ }
127
+ const requiresMatch = line.match(REQUIRES_LINE);
128
+ if (requiresMatch) {
129
+ const raw = requiresMatch[1];
130
+ const renamed = renameRef(raw);
131
+ if (renamed !== null) {
132
+ count++;
133
+ return line.replace(raw, renamed);
134
+ }
135
+ }
136
+ return line;
137
+ });
138
+ header.requires = header.requires.map(req => {
139
+ const renamed = renameRef(req.raw);
140
+ if (renamed === null)
141
+ return req;
142
+ return { raw: renamed, ref: parseReference(renamed) };
143
+ });
144
+ return count;
145
+ }
146
+ /**
147
+ * Scan a deploy script the way dependency resolution historically has:
148
+ * line-by-line over the whole file, collecting `-- requires: <ref>` lines
149
+ * verbatim and validating any `-- Deploy <project>:<change>[ to pg]` line
150
+ * against the script's path identity and project name.
151
+ *
152
+ * Kept strictly line-regex-compatible with the original resolver behavior
153
+ * (requires lines are honored anywhere in the file, and the legacy
154
+ * `-- Deploy: <change>` colon form is not identity-validated).
155
+ *
156
+ * @throws when the Deploy line's project or change identity mismatches.
157
+ */
158
+ export function scanDeployScript(content, options) {
159
+ const { key, extname, makeKey } = options;
160
+ const requires = [];
161
+ for (const line of content.split('\n')) {
162
+ const requiresMatch = line.match(/^-- requires: (.*)/);
163
+ if (requiresMatch) {
164
+ requires.push(requiresMatch[1].trim());
165
+ continue;
166
+ }
167
+ if (/:/.test(line)) {
168
+ const m2 = line.match(/^-- Deploy ([^:]*):([\w\/]+)(?:\s+to\s+pg)?/);
169
+ if (m2) {
170
+ const actualProject = m2[1];
171
+ const keyToTest = m2[2];
172
+ if (extname !== actualProject) {
173
+ throw new Error(`Mismatched project name in deploy file:
174
+ Expected project: ${extname}
175
+ Found in line : ${actualProject}
176
+ Line : ${line}`);
177
+ }
178
+ const expectedKey = makeKey(keyToTest);
179
+ if (key !== expectedKey) {
180
+ throw new Error(`Deployment script path or internal name mismatch:
181
+ Expected key : ${key}
182
+ Found in line : ${expectedKey}
183
+ Line : ${line}`);
184
+ }
185
+ }
186
+ }
187
+ else {
188
+ const m2 = line.match(/^-- Deploy (.*?)(?:\s+to\s+pg)?\s*$/);
189
+ if (m2) {
190
+ const keyToTest = m2[1].trim();
191
+ if (key !== makeKey(keyToTest)) {
192
+ throw new Error('deployment script in wrong place or is named wrong internally\n' + line);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ return { requires };
198
+ }
199
+ /**
200
+ * Verify that every change in a module's pgpm.plan has a deploy script whose
201
+ * header identity matches its path, and report dependency drift between the
202
+ * plan's dep lists and the scripts' `-- requires:` lines.
203
+ *
204
+ * Read-only diagnostic; returns issues rather than throwing.
205
+ */
206
+ export function verifyPlanMatchesHeaders(moduleDir) {
207
+ const issues = [];
208
+ const planPath = join(moduleDir, 'pgpm.plan');
209
+ const parsed = parsePlanFile(planPath);
210
+ if (!parsed.data) {
211
+ throw new Error(`Failed to parse plan file: ${planPath}`);
212
+ }
213
+ for (const planChange of parsed.data.changes) {
214
+ const change = planChange.name;
215
+ const file = join(moduleDir, 'deploy', `${change}.sql`);
216
+ let content;
217
+ try {
218
+ content = readFileSync(file, 'utf-8');
219
+ }
220
+ catch {
221
+ issues.push({
222
+ change,
223
+ file,
224
+ kind: 'missing-file',
225
+ message: `deploy script not found for plan change "${change}"`
226
+ });
227
+ continue;
228
+ }
229
+ const { header } = parsePgpmHeader(content);
230
+ if (header.change && header.change !== change) {
231
+ issues.push({
232
+ change,
233
+ file,
234
+ kind: 'identity-mismatch',
235
+ message: `header declares "${header.change}" but path identity is "${change}"`
236
+ });
237
+ }
238
+ const planDeps = new Set(planChange.dependencies || []);
239
+ const headerDeps = new Set(header.requires.map(r => r.raw));
240
+ const missingInHeader = [...planDeps].filter(d => !headerDeps.has(d));
241
+ const missingInPlan = [...headerDeps].filter(d => !planDeps.has(d));
242
+ if (missingInHeader.length > 0 || missingInPlan.length > 0) {
243
+ const parts = [];
244
+ if (missingInHeader.length > 0) {
245
+ parts.push(`in plan but not in header requires: ${missingInHeader.join(', ')}`);
246
+ }
247
+ if (missingInPlan.length > 0) {
248
+ parts.push(`in header requires but not in plan: ${missingInPlan.join(', ')}`);
249
+ }
250
+ issues.push({
251
+ change,
252
+ file,
253
+ kind: 'requires-drift',
254
+ message: parts.join('; ')
255
+ });
256
+ }
257
+ }
258
+ return issues;
259
+ }
@@ -0,0 +1,2 @@
1
+ export * from './header';
2
+ export * from './writer';
@@ -0,0 +1,107 @@
1
+ import { getEnvOptions } from '@pgpmjs/env';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ /**
5
+ * Write SQL files for PGPM migrations (deploy, revert, verify)
6
+ */
7
+ export const writePgpmFiles = (rows, opts) => {
8
+ rows.forEach((row) => writeVerify(row, opts));
9
+ rows.forEach((row) => writeRevert(row, opts));
10
+ rows.forEach((row) => writeDeploy(row, opts));
11
+ };
12
+ /**
13
+ * Sort dependencies in a consistent order
14
+ */
15
+ const ordered = (arr) => {
16
+ if (!arr)
17
+ return [];
18
+ return arr.sort((a, b) => a.length - b.length || a.localeCompare(b));
19
+ };
20
+ /**
21
+ * Write a deploy SQL file for a PGPM change
22
+ */
23
+ const writeDeploy = (row, opts) => {
24
+ const globalOpts = getEnvOptions({
25
+ migrations: {
26
+ codegen: {
27
+ useTx: opts.useTx
28
+ }
29
+ }
30
+ });
31
+ const useTx = globalOpts.migrations.codegen.useTx;
32
+ const deploy = opts.replacer(row.deploy);
33
+ const dir = path.dirname(deploy);
34
+ const prefix = path.join(opts.outdir, opts.name, 'deploy');
35
+ const actualDir = path.resolve(prefix, dir);
36
+ const actualFile = path.resolve(prefix, `${deploy}.sql`);
37
+ fs.mkdirSync(actualDir, { recursive: true });
38
+ const sqlContent = opts.replacer(row.content);
39
+ const content = `-- Deploy: ${deploy}
40
+ -- made with <3 @ constructive.io
41
+
42
+ ${opts.replacer(ordered(row?.deps)
43
+ .map((dep) => `-- requires: ${dep}`)
44
+ .join('\n') || '')}
45
+
46
+ ${useTx ? 'BEGIN;' : ''}
47
+ ${sqlContent}
48
+ ${useTx ? 'COMMIT;' : ''}
49
+ `;
50
+ fs.writeFileSync(actualFile, content);
51
+ };
52
+ /**
53
+ * Write a verify SQL file for a PGPM change
54
+ */
55
+ const writeVerify = (row, opts) => {
56
+ const globalOpts = getEnvOptions({
57
+ migrations: {
58
+ codegen: {
59
+ useTx: opts.useTx
60
+ }
61
+ }
62
+ });
63
+ const useTx = globalOpts.migrations.codegen.useTx;
64
+ const deploy = opts.replacer(row.deploy);
65
+ const dir = path.dirname(deploy);
66
+ const prefix = path.join(opts.outdir, opts.name, 'verify');
67
+ const actualDir = path.resolve(prefix, dir);
68
+ const actualFile = path.resolve(prefix, `${deploy}.sql`);
69
+ fs.mkdirSync(actualDir, { recursive: true });
70
+ const sqlContent = opts.replacer(row.verify);
71
+ const content = opts.replacer(`-- Verify: ${deploy}
72
+
73
+ ${useTx ? 'BEGIN;' : ''}
74
+ ${sqlContent}
75
+ ${useTx ? 'COMMIT;' : ''}
76
+
77
+ `);
78
+ fs.writeFileSync(actualFile, content);
79
+ };
80
+ /**
81
+ * Write a revert SQL file for a PGPM change
82
+ */
83
+ const writeRevert = (row, opts) => {
84
+ const globalOpts = getEnvOptions({
85
+ migrations: {
86
+ codegen: {
87
+ useTx: opts.useTx
88
+ }
89
+ }
90
+ });
91
+ const useTx = globalOpts.migrations.codegen.useTx;
92
+ const deploy = opts.replacer(row.deploy);
93
+ const dir = path.dirname(deploy);
94
+ const prefix = path.join(opts.outdir, opts.name, 'revert');
95
+ const actualDir = path.resolve(prefix, dir);
96
+ const actualFile = path.resolve(prefix, `${deploy}.sql`);
97
+ fs.mkdirSync(actualDir, { recursive: true });
98
+ const sqlContent = opts.replacer(row.revert);
99
+ const content = `-- Revert: ${deploy}
100
+
101
+ ${useTx ? 'BEGIN;' : ''}
102
+ ${sqlContent}
103
+ ${useTx ? 'COMMIT;' : ''}
104
+
105
+ `;
106
+ fs.writeFileSync(actualFile, content);
107
+ };
@@ -0,0 +1,2 @@
1
+ // Re-export all sql-scripts functionality
2
+ export * from './reader';
@@ -0,0 +1,19 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ /**
4
+ * Read a SQL script file, return empty string if not found
5
+ */
6
+ export function readScript(basePath, scriptType, changeName) {
7
+ const scriptPath = join(basePath, scriptType, `${changeName}.sql`);
8
+ if (!existsSync(scriptPath)) {
9
+ return '';
10
+ }
11
+ return readFileSync(scriptPath, 'utf-8');
12
+ }
13
+ /**
14
+ * Check if a script file exists
15
+ */
16
+ export function scriptExists(basePath, scriptType, changeName) {
17
+ const scriptPath = join(basePath, scriptType, `${changeName}.sql`);
18
+ return existsSync(scriptPath);
19
+ }
@@ -0,0 +1 @@
1
+ export * from './package';
@@ -0,0 +1 @@
1
+ export {};
package/esm/hash.js ADDED
@@ -0,0 +1,9 @@
1
+ import { createHash } from 'crypto';
2
+ /**
3
+ * Generate a SHA256 hash (hex) of a string. Pure content-addressing primitive
4
+ * shared by the bundle layer and core; kept here so the artifact layer has a
5
+ * dependency-free hash without reaching into core.
6
+ */
7
+ export function hashString(content) {
8
+ return createHash('sha256').update(content).digest('hex');
9
+ }
package/esm/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @pgpmjs/ast — the pgpm module AST layer.
3
+ *
4
+ * Two things live here:
5
+ * - `files/*` — the low-level file parsers/writers (plan, control, SQL header,
6
+ * scripts) and their types. These are the primitives everything else composes.
7
+ * - `module/*` — the unified {@link PgpmModuleAst}: `readModule`/`writeModule`
8
+ * (parse/deparse a whole pgpm module) built by composing the `files/*` parsers,
9
+ * lossless round-trip.
10
+ *
11
+ * This package is a pure leaf (no DB, no deploy engine); `@pgpmjs/core` and the
12
+ * bundle/artifact layer build on top of it.
13
+ */
14
+ export * from './files';
15
+ export * from './hash';
16
+ export * from './module';
17
+ export * from './plan-rename';
18
+ export { parseAuthor } from './utils/author';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * pgpm-ast: the unified in-memory AST for pgpm modules ("AST for the migrations").
3
+ *
4
+ * Composes the existing file-level parsers (plan, control, SQL header) into a
5
+ * single typed {@link PgpmModuleAst} object that operators transform, then writes
6
+ * it back losslessly.
7
+ */
8
+ export * from './parse';
9
+ export * from './reader';
10
+ export * from './roundtrip';
11
+ export * from './types';
12
+ export * from './writer';
@@ -0,0 +1,27 @@
1
+ import { readModule } from './reader';
2
+ import { writeModule } from './writer';
3
+ /**
4
+ * Parse a pgpm module directory into a {@link PgpmModuleAst}.
5
+ *
6
+ * The module-level analog of `pgsql-parser`'s `parse()`: where that turns SQL
7
+ * text into a PostgreSQL AST, this turns an on-disk pgpm module (plan + control
8
+ * + deploy/revert/verify scripts) into a single typed tree. Alias of
9
+ * {@link readModule}; the inverse of {@link deparseModule}.
10
+ */
11
+ export function parseModule(dir) {
12
+ return readModule(dir);
13
+ }
14
+ /**
15
+ * Deparse a {@link PgpmModuleAst} back to an on-disk pgpm module.
16
+ *
17
+ * The module-level analog of `pgsql-deparser`'s `deparse()`. With the default
18
+ * `fromRaw: true` it is lossless (byte-exact round-trip of {@link parseModule});
19
+ * with `fromRaw: false` it re-serializes the structured model, for operators
20
+ * that mutate the AST first. Alias of {@link writeModule}.
21
+ *
22
+ * For in-memory deparse-to-string of individual parts, use `serializePlan` /
23
+ * `serializeScript`.
24
+ */
25
+ export function deparseModule(module, options = {}) {
26
+ writeModule(module, options);
27
+ }
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { parseControlFile } from '../files/extension/reader';
4
+ import { parsePlanFile } from '../files/plan/parser';
5
+ import { parsePgpmHeader } from '../files/sql/header';
6
+ import { readScript, scriptExists } from '../files/sql-scripts/reader';
7
+ function readScriptAst(dir, kind, change) {
8
+ if (!scriptExists(dir, kind, change))
9
+ return null;
10
+ const raw = readScript(dir, kind, change);
11
+ const { header, body } = parsePgpmHeader(raw);
12
+ return { kind, header, body, raw };
13
+ }
14
+ /**
15
+ * Read a pgpm module directory into the unified {@link PgpmModuleAst}.
16
+ *
17
+ * Composes the existing plan parser, control reader, and SQL header parser — it
18
+ * does not re-implement any parsing. The module is addressed by plan order: the
19
+ * `changes` array follows `pgpm.plan`, and each change eagerly parses its
20
+ * deploy/revert/verify scripts (missing ones are `null`). Every leaf retains its
21
+ * byte-exact source so {@link writeModule} can reproduce the module losslessly.
22
+ *
23
+ * @throws when `dir` has no `pgpm.plan`, or the plan fails to parse.
24
+ */
25
+ export function readModule(dir) {
26
+ const planPath = join(dir, 'pgpm.plan');
27
+ if (!existsSync(planPath)) {
28
+ throw new Error(`No pgpm.plan found in module directory: ${dir}`);
29
+ }
30
+ const planRaw = readFileSync(planPath, 'utf-8');
31
+ const parsed = parsePlanFile(planPath);
32
+ if (!parsed.data) {
33
+ const detail = parsed.errors
34
+ .map(e => `line ${e.line}: ${e.message}`)
35
+ .join('; ');
36
+ throw new Error(`Failed to parse plan file ${planPath}: ${detail}`);
37
+ }
38
+ const plan = parsed.data;
39
+ const name = plan.package;
40
+ let control = null;
41
+ if (name) {
42
+ const fileName = `${name}.control`;
43
+ const controlPath = join(dir, fileName);
44
+ if (existsSync(controlPath)) {
45
+ const raw = readFileSync(controlPath, 'utf-8');
46
+ const info = parseControlFile(controlPath, dir);
47
+ control = {
48
+ fileName,
49
+ name,
50
+ requires: info.requires,
51
+ version: info.version,
52
+ raw
53
+ };
54
+ }
55
+ }
56
+ const changes = plan.changes.map(planChange => ({
57
+ name: planChange.name,
58
+ plan: planChange,
59
+ deploy: readScriptAst(dir, 'deploy', planChange.name),
60
+ revert: readScriptAst(dir, 'revert', planChange.name),
61
+ verify: readScriptAst(dir, 'verify', planChange.name)
62
+ }));
63
+ return { dir, name, plan, planRaw, control, changes };
64
+ }
@@ -0,0 +1,65 @@
1
+ import { mkdtempSync, rmSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { readModule } from './reader';
5
+ import { serializePlan, serializeScript, writeModule } from './writer';
6
+ /**
7
+ * Read-only invariant check for the module AST: proves a module survives a
8
+ * read → serialize → re-read cycle without drifting.
9
+ *
10
+ * Guarantees checked:
11
+ * - every script serializes byte-identically to its source (the SQL header
12
+ * parser is lossless), and
13
+ * - the plan serialization is idempotent and the structured change set is
14
+ * stable across a serialize → re-read round trip.
15
+ *
16
+ * Returns the issues found rather than throwing.
17
+ */
18
+ export function verifyModuleRoundTrip(dir) {
19
+ const issues = [];
20
+ const module = readModule(dir);
21
+ for (const change of module.changes) {
22
+ for (const script of [change.deploy, change.revert, change.verify]) {
23
+ if (!script)
24
+ continue;
25
+ if (serializeScript(script) !== script.raw) {
26
+ issues.push({
27
+ kind: 'script-drift',
28
+ change: change.name,
29
+ script: script.kind,
30
+ message: 'header serialize is not byte-identical to source'
31
+ });
32
+ }
33
+ }
34
+ }
35
+ const planOnce = serializePlan(module);
36
+ const tmp = mkdtempSync(join(tmpdir(), 'pgpm-ast-rt-'));
37
+ try {
38
+ writeModule(module, { outDir: tmp, fromRaw: false });
39
+ const reread = readModule(tmp);
40
+ if (serializePlan(reread) !== planOnce) {
41
+ issues.push({ kind: 'plan-drift', message: 'plan serialization is not idempotent' });
42
+ }
43
+ if (reread.changes.length !== module.changes.length) {
44
+ issues.push({
45
+ kind: 'change-count',
46
+ message: `change count drifted: ${module.changes.length} -> ${reread.changes.length}`
47
+ });
48
+ }
49
+ else {
50
+ for (let i = 0; i < module.changes.length; i++) {
51
+ if (reread.changes[i].name !== module.changes[i].name) {
52
+ issues.push({
53
+ kind: 'change-identity',
54
+ change: module.changes[i].name,
55
+ message: `change at index ${i} drifted to "${reread.changes[i].name}"`
56
+ });
57
+ }
58
+ }
59
+ }
60
+ }
61
+ finally {
62
+ rmSync(tmp, { recursive: true, force: true });
63
+ }
64
+ return issues;
65
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { mkdirSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { generatePlanFileContent } from '../files/plan/writer';
4
+ import { writePgpmScript } from '../files/sql/header';
5
+ /**
6
+ * Serialize a script AST back to SQL text from its structured header + body.
7
+ * Byte-identical to the source for scripts read via {@link readModule} (the SQL
8
+ * header parser is lossless), and reflects any structural header edits.
9
+ */
10
+ export function serializeScript(script) {
11
+ return writePgpmScript({ header: script.header, body: script.body });
12
+ }
13
+ /**
14
+ * Serialize a module's plan back to `pgpm.plan` text from its structured model.
15
+ */
16
+ export function serializePlan(module) {
17
+ return generatePlanFileContent(module.plan);
18
+ }
19
+ /**
20
+ * Materialize a {@link PgpmModuleAst} back to disk: `pgpm.plan`, the `.control`
21
+ * file (when present), and every change's deploy/revert/verify script.
22
+ *
23
+ * With the default `fromRaw: true` this is a lossless copy of what
24
+ * {@link readModule} loaded; with `fromRaw: false` it emits the structured
25
+ * serialization (used by operators that mutate the AST before writing).
26
+ */
27
+ export function writeModule(module, options = {}) {
28
+ const outDir = options.outDir ?? module.dir;
29
+ const fromRaw = options.fromRaw ?? true;
30
+ mkdirSync(outDir, { recursive: true });
31
+ writeFileSync(join(outDir, 'pgpm.plan'), fromRaw ? module.planRaw : serializePlan(module));
32
+ if (module.control) {
33
+ writeFileSync(join(outDir, module.control.fileName), module.control.raw);
34
+ }
35
+ for (const change of module.changes) {
36
+ const scripts = [change.deploy, change.revert, change.verify];
37
+ for (const script of scripts) {
38
+ if (!script)
39
+ continue;
40
+ const file = join(outDir, script.kind, `${change.name}.sql`);
41
+ mkdirSync(dirname(file), { recursive: true });
42
+ writeFileSync(file, fromRaw ? script.raw : serializeScript(script));
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,28 @@
1
+ function escapeRe(str) {
2
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+ /**
5
+ * Rewrite change names in a pgpm.plan's content according to a rename map.
6
+ * Textual and format-preserving: only the change-name token at the start of a
7
+ * change line, plain (same-package) dependency refs inside `[...]`, and tag
8
+ * lines' change field are rewritten; timestamps, planners, comments,
9
+ * cross-package refs, and tag refs are untouched.
10
+ */
11
+ export function renameInPlanContent(content, renames) {
12
+ const froms = Array.from(renames.keys()).sort((a, b) => b.length - a.length);
13
+ return content
14
+ .split('\n')
15
+ .map(line => {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('%'))
18
+ return line;
19
+ let result = line;
20
+ for (const from of froms) {
21
+ const to = renames.get(from);
22
+ const re = new RegExp(`(^|[\\s\\[])${escapeRe(from)}(?=$|[\\s\\]@])`, 'g');
23
+ result = result.replace(re, `$1${to}`);
24
+ }
25
+ return result;
26
+ })
27
+ .join('\n');
28
+ }