Skip to content

Commit 3acde5e

Browse files
wmadden-electricclaude
authored andcommitted
feat: chain per-extension migration regen into fixtures:emit
Adds `scripts/regen-extension-migrations.mjs` and a root `migrations:regen` script that keeps migration metadata consistent with freshly-built extension contracts after `build:contract-space`. For each extension under packages/3-extensions/ containing a migrations/ tree, the script reads the new storageHash from src/contract.json, locates the HEAD migration (via refs/head.json), rewrites the `to` literal in migration.ts, re-emits ops.json + migration.json via tsx, re-pins refs/head.json, and syncs end-contract.{json,d.ts} from src/contract.{json,d.ts}. Chains `pnpm migrations:regen` into `fixtures:emit` after the `build:contract-space` phase so a single `pnpm fixtures:emit` leaves every extension self-consistent without a separate manual regen loop. Collapses the detailed "Authoring / Path B" maintainer sections in the pgvector and paradedb READMEs to a single sentence pointing at the workspace command. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: wmadden-electric <286902546+wmadden-electric@users.noreply.github.com>
1 parent b0902a7 commit 3acde5e

4 files changed

Lines changed: 270 additions & 19 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
"check:release-notes": "node scripts/check-release-notes.mjs",
4545
"check:upgrade-coverage": "node scripts/check-upgrade-coverage.mjs",
4646
"check:clean-tree": "node scripts/check-clean-tree.mjs",
47-
"fixtures:emit": "DATABASE_URL=\"${DATABASE_URL:-postgres://postgres:postgres@localhost:5432/postgres}\" pnpm -r --filter='./examples/*' --filter='./apps/*' --filter='@prisma-next/sql-builder' --filter='@prisma-next/sql-orm-client' --filter='@prisma-next/e2e-tests' --filter='@prisma-next/integration-tests' run --if-present emit && DATABASE_URL=\"${DATABASE_URL:-postgres://postgres:postgres@localhost:5432/postgres}\" pnpm -r --filter='./packages/3-extensions/*' run --if-present build:contract-space",
47+
"migrations:regen": "node scripts/regen-extension-migrations.mjs",
48+
"fixtures:emit": "DATABASE_URL=\"${DATABASE_URL:-postgres://postgres:postgres@localhost:5432/postgres}\" pnpm -r --filter='./examples/*' --filter='./apps/*' --filter='@prisma-next/sql-builder' --filter='@prisma-next/sql-orm-client' --filter='@prisma-next/e2e-tests' --filter='@prisma-next/integration-tests' run --if-present emit && DATABASE_URL=\"${DATABASE_URL:-postgres://postgres:postgres@localhost:5432/postgres}\" pnpm -r --filter='./packages/3-extensions/*' run --if-present build:contract-space && pnpm migrations:regen",
4849
"fixtures:check": "pnpm fixtures:emit && git diff --exit-code -- ':(glob)**/contract.*' ':(glob)**/expected.contract.json'",
4950
"typecheck": "turbo run typecheck",
5051
"typecheck:all": "pnpm typecheck && pnpm typecheck:examples",

packages/3-extensions/paradedb/README.md

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,9 @@ ParadeDB BM25 indexes require a `key_field` — a unique column that identifies
7373

7474
## Authoring (maintainers)
7575

76-
The extension's contract + baseline migration are emitted on-disk inside this package using the same pipeline application authors use:
76+
After changing the contract source, run `pnpm migrations:regen` from the repo root to keep migration metadata, `refs/head.json`, and `end-contract.*` consistent with the freshly-built `src/contract.json`; it is also wired into `pnpm fixtures:emit` automatically.
7777

78-
- `pnpm build:contract-space` — runs `prisma-next contract emit` to produce `src/contract.{json,d.ts}` from `emptyContract({ output: 'src/contract.json', target })` in `prisma-next.config.ts` (migrations-only space: no `contract.prisma` source file).
79-
- `pnpm exec prisma-next migration plan --name <slug>` (run from this package directory) — scaffolds a new migration directory under `migrations/<dirName>/` for schema changes. **Not chained into `pnpm build`**: `migration plan` is non-idempotent (each invocation generates a new timestamped directory), so it runs manually when the contract changes. Note: paradedb's contract declares no tables or models, so the planner currently refuses to scaffold the baseline migration (this is **Path B** authoring per [ADR 212](../../../docs/architecture%20docs/adrs/ADR%20212%20-%20Contract%20spaces.md#contract-space-package-layout)). That directory was hand-authored once (Migration subclass + seed `migration.json` preserving the full `toContract`) and `pnpm tsx migrations/<dirName>/migration.ts` re-emits `ops.json` + `migration.json` deterministically. Future migrations that add tables or models can use `migration plan` directly (Path A).
80-
- `pnpm tsx migrations/<dirName>/migration.ts` (run from this package directory) — re-emits `ops.json` + `migration.json` from the hand-edited subclass. Use `tsx`, not bare `node`, because the Migration subclass imports relative TypeScript siblings which Node's native loader can't resolve without a TS-aware loader.
81-
- `migrations/refs/head.json` is hand-pinned with the latest migration's `to` hash + `providedInvariants`.
82-
83-
The descriptor at `src/exports/control.ts` then JSON-imports those artefacts and synthesises the framework's `MigrationPackage` shape.
84-
85-
See [ADR 212 — Contract spaces](../../../docs/architecture%20docs/adrs/ADR%20212%20-%20Contract%20spaces.md) ("Contract-space package layout") for the canonical layout and rationale.
78+
See [ADR 212 — Contract spaces](../../../docs/architecture%20docs/adrs/ADR%20212%20-%20Contract%20spaces.md) ("Contract-space package layout") for the layout and rationale.
8679

8780
## Not yet implemented
8881

packages/3-extensions/pgvector/README.md

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -200,16 +200,9 @@ The extension declares the following capabilities:
200200

201201
## Authoring (maintainers)
202202

203-
The extension's contract + baseline migration are emitted on-disk inside this package using the same pipeline application authors use:
203+
After changing the contract source, run `pnpm migrations:regen` from the repo root to keep migration metadata, `refs/head.json`, and `end-contract.*` consistent with the freshly-built `src/contract.json`; it is also wired into `pnpm fixtures:emit` automatically.
204204

205-
- `pnpm build:contract-space` — runs `prisma-next contract emit` to produce `src/contract.{json,d.ts}` from the TS source at `src/contract.ts`.
206-
- `pnpm exec prisma-next migration plan --name <slug>` (run from this package directory) — scaffolds a new migration directory under `migrations/<dirName>/` for schema changes that touch tables / models. **Not chained into `pnpm build`**: `migration plan` is non-idempotent (each invocation generates a new timestamped directory), so it runs manually when the contract source changes. Note: pgvector's contract declares only the parameterised `vector` native type under `storage.types` (no tables / models), so the planner currently refuses to scaffold the baseline migration with `PN-CLI-4020 Contract changed but planner produced no operations` (this is **Path B** authoring per [ADR 212](../../../docs/architecture%20docs/adrs/ADR%20212%20-%20Contract%20spaces.md#contract-space-package-layout)). That directory was hand-authored once (Migration subclass + seed `migration.json` preserving the full `toContract`) and `pnpm tsx migrations/<dirName>/migration.ts` re-emits `ops.json` + `migration.json` deterministically. Future migrations that add tables / models can use `migration plan` directly (Path A).
207-
- `pnpm tsx migrations/<dirName>/migration.ts` (run from this package directory) — re-emits `ops.json` + `migration.json` from the hand-edited subclass. Use `tsx`, not bare `node`, because the Migration subclass imports relative TypeScript siblings which Node's native loader can't resolve without a TS-aware loader.
208-
- `migrations/refs/head.json` is hand-pinned with the latest migration's `to` hash + `providedInvariants`.
209-
210-
The descriptor at `src/exports/control.ts` then JSON-imports those artefacts and synthesises the framework's `MigrationPackage` shape.
211-
212-
See [ADR 212 — Contract spaces](../../../docs/architecture%20docs/adrs/ADR%20212%20-%20Contract%20spaces.md) ("Contract-space package layout") for the canonical layout and rationale.
205+
See [ADR 212 — Contract spaces](../../../docs/architecture%20docs/adrs/ADR%20212%20-%20Contract%20spaces.md) ("Contract-space package layout") for the layout and rationale.
213206

214207
## References
215208

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Regenerates migration metadata for all extension packages that carry an
4+
* on-disk `migrations/` tree, keeping them consistent with the freshly-built
5+
* `src/contract.json` after `pnpm build:contract-space` / `pnpm fixtures:emit`.
6+
*
7+
* For each extension under packages/3-extensions/ that contains a
8+
* `migrations/` directory the script:
9+
*
10+
* 1. Reads `src/contract.json` -> `storage.storageHash` (new end-state hash).
11+
* 2. Locates the HEAD migration - the one whose `migration.json` sets
12+
* `"to"` equal to the hash published in `migrations/refs/head.json`.
13+
* Because every current extension has exactly one (baseline) migration
14+
* with `from: null`, the head migration is always unambiguous; the
15+
* script halts with an error if the chain is ambiguous or the head
16+
* migration cannot be identified.
17+
* 3. Rewrites the `to` literal in that migration's `migration.ts` to the
18+
* new storageHash.
19+
* 4. Re-emits `ops.json` + `migration.json` by running `tsx migration.ts`
20+
* from the extension package root (tsx because the migration imports
21+
* relative TypeScript siblings).
22+
* 5. Re-pins `migrations/refs/head.json` with the new hash, preserving
23+
* the existing `invariants` array verbatim.
24+
* 6. Syncs `end-contract.{json,d.ts}` from `src/contract.{json,d.ts}`.
25+
*
26+
* If the new storageHash already matches the published `head.json` hash the
27+
* extension is skipped (already consistent) - making the script idempotent.
28+
*
29+
* Usage:
30+
* node scripts/regen-extension-migrations.mjs
31+
*
32+
* Wired into the root `package.json` as `"migrations:regen"` and chained
33+
* after `build:contract-space` in `fixtures:emit`.
34+
*/
35+
36+
import { execFileSync } from 'node:child_process';
37+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
38+
import { dirname, join, resolve } from 'node:path';
39+
import { fileURLToPath } from 'node:url';
40+
41+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
42+
const extensionsDir = join(repoRoot, 'packages', '3-extensions');
43+
const tsx = join(repoRoot, 'node_modules', '.bin', 'tsx');
44+
45+
/**
46+
* Read and parse a JSON file, returning the parsed object.
47+
* Throws a descriptive error on missing or malformed files.
48+
*/
49+
function readJson(filePath) {
50+
let raw;
51+
try {
52+
raw = readFileSync(filePath, 'utf8');
53+
} catch {
54+
throw new Error(`regen-extension-migrations: cannot read ${filePath}`);
55+
}
56+
try {
57+
return JSON.parse(raw);
58+
} catch (err) {
59+
throw new Error(`regen-extension-migrations: malformed JSON in ${filePath}: ${err.message}`);
60+
}
61+
}
62+
63+
/**
64+
* Find the migration directory whose `migration.json` has a `to` field
65+
* matching `headHash`. Returns the directory path.
66+
*
67+
* Halts (throws) when:
68+
* - No migration directory matches (chain is broken -- head.json is stale
69+
* in a way regen cannot fix automatically).
70+
* - More than one migration directory matches (ambiguous HEAD -- not expected
71+
* in the current single-migration baseline layout; human review required).
72+
*/
73+
function findHeadMigrationDir(migrationsDir, headHash) {
74+
let migrationDirs;
75+
try {
76+
migrationDirs = readdirSync(migrationsDir, { withFileTypes: true })
77+
.filter((e) => e.isDirectory() && e.name !== 'refs')
78+
.map((e) => join(migrationsDir, e.name));
79+
} catch {
80+
throw new Error(
81+
`regen-extension-migrations: cannot list migration directories in ${migrationsDir}`,
82+
);
83+
}
84+
85+
const matching = migrationDirs.filter((dir) => {
86+
const metaPath = join(dir, 'migration.json');
87+
if (!existsSync(metaPath)) return false;
88+
const meta = readJson(metaPath);
89+
return meta.to === headHash;
90+
});
91+
92+
if (matching.length === 0) {
93+
throw new Error(
94+
`regen-extension-migrations: no migration directory in ${migrationsDir} has "to": "${headHash}" -- ` +
95+
'head.json may be stale in an unexpected way; manual review required',
96+
);
97+
}
98+
if (matching.length > 1) {
99+
throw new Error(
100+
`regen-extension-migrations: multiple migration directories match "to": "${headHash}" in ${migrationsDir} -- ` +
101+
`ambiguous HEAD; manual review required: ${matching.join(', ')}`,
102+
);
103+
}
104+
return matching[0];
105+
}
106+
107+
/**
108+
* Rewrite the `to:` hash literal in a `migration.ts` file.
109+
*
110+
* Matches the pattern:
111+
* to: 'sha256:<hex>',
112+
* or
113+
* to: "sha256:<hex>",
114+
* (with optional surrounding whitespace) and replaces the hash value.
115+
*
116+
* Throws if the pattern is not found exactly once.
117+
*/
118+
function rewriteMigrationToHash(migrationTsPath, newHash) {
119+
const src = readFileSync(migrationTsPath, 'utf8');
120+
// Match the `to:` property value -- either single or double-quoted sha256 hash.
121+
const pattern = /(to:\s*['"])sha256:[0-9a-f]+(['"])/g;
122+
const matches = [...src.matchAll(pattern)];
123+
if (matches.length === 0) {
124+
throw new Error(
125+
`regen-extension-migrations: could not find 'to: ...' hash literal in ${migrationTsPath}`,
126+
);
127+
}
128+
if (matches.length > 1) {
129+
throw new Error(
130+
`regen-extension-migrations: found ${matches.length} 'to: ...' hash literals in ${migrationTsPath}; expected exactly 1`,
131+
);
132+
}
133+
const updated = src.replace(pattern, `$1${newHash}$2`);
134+
if (updated === src) {
135+
return false;
136+
}
137+
writeFileSync(migrationTsPath, updated, 'utf8');
138+
return true;
139+
}
140+
141+
/**
142+
* Re-emit ops.json + migration.json for the given extension by running
143+
* `tsx <migrationTsPath>` with the extension package directory as cwd.
144+
*/
145+
function reemitMigrationArtifacts(extDir, migrationTsPath) {
146+
execFileSync(tsx, [migrationTsPath], {
147+
cwd: extDir,
148+
stdio: ['ignore', 'pipe', 'pipe'],
149+
encoding: 'utf8',
150+
});
151+
}
152+
153+
/**
154+
* Rewrite `migrations/refs/head.json`, replacing `hash` with `newHash`
155+
* and preserving the existing `invariants` array verbatim.
156+
*/
157+
function repinHeadRef(headRefPath, newHash) {
158+
const existing = readJson(headRefPath);
159+
const updated = { hash: newHash, invariants: existing.invariants };
160+
writeFileSync(headRefPath, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
161+
}
162+
163+
/**
164+
* Sync `end-contract.{json,d.ts}` from `src/contract.{json,d.ts}` inside
165+
* the head migration directory.
166+
*
167+
* `src/contract.json` is emitted without a trailing newline; the on-disk
168+
* end-contract.json convention includes one. A trailing newline is added
169+
* if absent so idempotence holds on the first run.
170+
*/
171+
function syncEndContract(extDir, headMigrationDir) {
172+
for (const ext of ['json', 'd.ts']) {
173+
const src = join(extDir, 'src', `contract.${ext}`);
174+
const dest = join(headMigrationDir, `end-contract.${ext}`);
175+
const content = readFileSync(src, 'utf8');
176+
const normalized = content.endsWith('\n') ? content : `${content}\n`;
177+
writeFileSync(dest, normalized, 'utf8');
178+
}
179+
}
180+
181+
/**
182+
* Process one extension directory. Returns `'skipped'` or `'updated'`;
183+
* throws on error.
184+
*/
185+
function processExtension(extDir) {
186+
const migrationsDir = join(extDir, 'migrations');
187+
if (!existsSync(migrationsDir)) {
188+
return 'skipped';
189+
}
190+
191+
const contractJsonPath = join(extDir, 'src', 'contract.json');
192+
if (!existsSync(contractJsonPath)) {
193+
throw new Error(
194+
`regen-extension-migrations: ${extDir} has migrations/ but no src/contract.json`,
195+
);
196+
}
197+
198+
const contractJson = readJson(contractJsonPath);
199+
const newHash = contractJson?.storage?.storageHash;
200+
if (typeof newHash !== 'string' || !newHash.startsWith('sha256:')) {
201+
throw new Error(
202+
`regen-extension-migrations: could not read storage.storageHash from ${contractJsonPath}`,
203+
);
204+
}
205+
206+
const headRefPath = join(migrationsDir, 'refs', 'head.json');
207+
if (!existsSync(headRefPath)) {
208+
throw new Error(
209+
`regen-extension-migrations: expected ${headRefPath} to exist; cannot identify HEAD migration`,
210+
);
211+
}
212+
213+
const headRef = readJson(headRefPath);
214+
const oldHash = headRef.hash;
215+
216+
if (oldHash === newHash) {
217+
return 'skipped';
218+
}
219+
220+
const headMigrationDir = findHeadMigrationDir(migrationsDir, oldHash);
221+
const migrationTsPath = join(headMigrationDir, 'migration.ts');
222+
if (!existsSync(migrationTsPath)) {
223+
throw new Error(`regen-extension-migrations: no migration.ts in ${headMigrationDir}`);
224+
}
225+
226+
rewriteMigrationToHash(migrationTsPath, newHash);
227+
reemitMigrationArtifacts(extDir, migrationTsPath);
228+
repinHeadRef(headRefPath, newHash);
229+
syncEndContract(extDir, headMigrationDir);
230+
231+
return 'updated';
232+
}
233+
234+
function main() {
235+
let entries;
236+
try {
237+
entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
238+
} catch (err) {
239+
process.stderr.write(
240+
`regen-extension-migrations: cannot list ${extensionsDir}: ${err.message}\n`,
241+
);
242+
process.exit(1);
243+
}
244+
245+
let errors = 0;
246+
for (const entry of entries) {
247+
const extDir = join(extensionsDir, entry.name);
248+
try {
249+
const result = processExtension(extDir);
250+
if (result === 'updated') {
251+
process.stdout.write(`regen-extension-migrations: updated ${entry.name}\n`);
252+
}
253+
} catch (err) {
254+
process.stderr.write(`${err.message}\n`);
255+
errors++;
256+
}
257+
}
258+
259+
if (errors > 0) {
260+
process.exit(1);
261+
}
262+
}
263+
264+
main();

0 commit comments

Comments
 (0)