@prisma/orm-mongo 8.0.0-rc.5 → 8.0.0-rc.5-dev.2

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 (64) hide show
  1. package/package.json +11 -10
  2. package/skills/prisma-8/SKILL.md +84 -0
  3. package/skills/prisma-8/references/build.md +142 -0
  4. package/skills/prisma-8/references/contract.md +417 -0
  5. package/skills/prisma-8/references/debug.md +141 -0
  6. package/skills/prisma-8/references/feedback.md +251 -0
  7. package/skills/prisma-8/references/migration-review.md +224 -0
  8. package/skills/prisma-8/references/migrations.md +519 -0
  9. package/skills/prisma-8/references/queries-mongo.md +236 -0
  10. package/skills/prisma-8/references/queries-postgres.md +415 -0
  11. package/skills/prisma-8/references/queries.md +168 -0
  12. package/skills/prisma-8/references/quickstart.md +326 -0
  13. package/skills/prisma-8/references/runtime.md +344 -0
  14. package/skills/prisma-8/references/supabase.md +244 -0
  15. package/skills/prisma-8/references/upgrade-app.md +101 -0
  16. package/skills/prisma-8/references/upgrade-extension.md +105 -0
  17. package/skills/prisma-8/upgrading/app/upgrades/0.10-to-0.11/instructions.md +56 -0
  18. package/skills/prisma-8/upgrading/app/upgrades/0.11-to-0.12/instructions.md +381 -0
  19. package/skills/prisma-8/upgrading/app/upgrades/0.11-to-0.12/re-emit-closed-mongo-contracts.ts +202 -0
  20. package/skills/prisma-8/upgrading/app/upgrades/0.11-to-0.12/re-emit-domain-namespaced-contracts.ts +201 -0
  21. package/skills/prisma-8/upgrading/app/upgrades/0.11-to-0.12/re-emit-postgres-public-default.ts +198 -0
  22. package/skills/prisma-8/upgrading/app/upgrades/0.11-to-0.12/strip-migration-labels-hints.ts +340 -0
  23. package/skills/prisma-8/upgrading/app/upgrades/0.12-to-0.13/instructions.md +339 -0
  24. package/skills/prisma-8/upgrading/app/upgrades/0.12-to-0.13/re-emit-mti-variant-link-columns.ts +229 -0
  25. package/skills/prisma-8/upgrading/app/upgrades/0.13-to-0.14/instructions.md +543 -0
  26. package/skills/prisma-8/upgrading/app/upgrades/0.13-to-0.14/migration-op-factories-to-methods.ts +290 -0
  27. package/skills/prisma-8/upgrading/app/upgrades/0.13-to-0.14/uuid-preset-rename.ts +43 -0
  28. package/skills/prisma-8/upgrading/app/upgrades/0.14-to-0.15/instructions.md +359 -0
  29. package/skills/prisma-8/upgrading/app/upgrades/0.15-to-0.16/instructions.md +173 -0
  30. package/skills/prisma-8/upgrading/app/upgrades/0.16-to-0.17/instructions.md +805 -0
  31. package/skills/prisma-8/upgrading/app/upgrades/0.16-to-0.17/strip-sha256-hash-prefixes.ts +382 -0
  32. package/skills/prisma-8/upgrading/app/upgrades/0.17-to-8.0.0-rc.1/instructions.md +72 -0
  33. package/skills/prisma-8/upgrading/app/upgrades/0.7-to-0.8/instructions.md +8 -0
  34. package/skills/prisma-8/upgrading/app/upgrades/0.8-to-0.9/instructions.md +36 -0
  35. package/skills/prisma-8/upgrading/app/upgrades/0.8-to-0.9/strip-inline-contracts.ts +226 -0
  36. package/skills/prisma-8/upgrading/app/upgrades/0.9-to-0.10/instructions.md +86 -0
  37. package/skills/prisma-8/upgrading/app/upgrades/0.9-to-0.10/stamp-storage-types-kind.ts +360 -0
  38. package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.1-to-8.0.0-rc.2/instructions.md +588 -0
  39. package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.2-to-8.0.0-rc.3/instructions.md +5 -0
  40. package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.3-to-8.0.0-rc.4/instructions.md +158 -0
  41. package/skills/prisma-8/upgrading/app/upgrades/8.0.0-rc.4-to-8.0.0-rc.5/instructions.md +42 -0
  42. package/skills/prisma-8/upgrading/extension/upgrades/0.10-to-0.11/instructions.md +276 -0
  43. package/skills/prisma-8/upgrading/extension/upgrades/0.11-to-0.12/instructions.md +738 -0
  44. package/skills/prisma-8/upgrading/extension/upgrades/0.11-to-0.12/migrate-contract-testing-imports.ts +97 -0
  45. package/skills/prisma-8/upgrading/extension/upgrades/0.11-to-0.12/regenerate-extension-public-baseline.ts +223 -0
  46. package/skills/prisma-8/upgrading/extension/upgrades/0.11-to-0.12/strip-migration-labels-hints.ts +340 -0
  47. package/skills/prisma-8/upgrading/extension/upgrades/0.12-to-0.13/instructions.md +266 -0
  48. package/skills/prisma-8/upgrading/extension/upgrades/0.13-to-0.14/instructions.md +522 -0
  49. package/skills/prisma-8/upgrading/extension/upgrades/0.13-to-0.14/migration-op-factories-to-methods.ts +290 -0
  50. package/skills/prisma-8/upgrading/extension/upgrades/0.13-to-0.14/uuid-preset-rename.ts +43 -0
  51. package/skills/prisma-8/upgrading/extension/upgrades/0.14-to-0.15/instructions.md +803 -0
  52. package/skills/prisma-8/upgrading/extension/upgrades/0.15-to-0.16/instructions.md +219 -0
  53. package/skills/prisma-8/upgrading/extension/upgrades/0.16-to-0.17/instructions.md +731 -0
  54. package/skills/prisma-8/upgrading/extension/upgrades/0.16-to-0.17/strip-sha256-hash-prefixes.ts +382 -0
  55. package/skills/prisma-8/upgrading/extension/upgrades/0.17-to-8.0.0-rc.1/instructions.md +194 -0
  56. package/skills/prisma-8/upgrading/extension/upgrades/0.7-to-0.8/instructions.md +8 -0
  57. package/skills/prisma-8/upgrading/extension/upgrades/0.8-to-0.9/instructions.md +57 -0
  58. package/skills/prisma-8/upgrading/extension/upgrades/0.8-to-0.9/strip-inline-contracts.ts +226 -0
  59. package/skills/prisma-8/upgrading/extension/upgrades/0.9-to-0.10/instructions.md +150 -0
  60. package/skills/prisma-8/upgrading/extension/upgrades/0.9-to-0.10/stamp-storage-types-kind.ts +360 -0
  61. package/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.1-to-8.0.0-rc.2/instructions.md +746 -0
  62. package/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.2-to-8.0.0-rc.3/instructions.md +5 -0
  63. package/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.3-to-8.0.0-rc.4/instructions.md +137 -0
  64. package/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.4-to-8.0.0-rc.5/instructions.md +129 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Rewrites test-only imports from the removed `@internal/contract/testing`
3
+ * subpath to `@repo/test-utils`.
4
+ *
5
+ * Background: starting at 0.12 the contract test factories (`createContract`,
6
+ * `createSqlContract`, `DUMMY_HASH`, `applicationDomainOf`, …) live in
7
+ * `@repo/test-utils`. The `@internal/contract/testing` export was
8
+ * removed from `@internal/contract`.
9
+ *
10
+ * Behaviour:
11
+ * - Walks the project root recursively, ignoring `node_modules`, `.git`,
12
+ * `dist`, and `build`.
13
+ * - Rewrites every `.ts` / `.tsx` file whose source contains
14
+ * `@internal/contract/testing`.
15
+ * - Idempotent: files already importing from `@repo/test-utils` are
16
+ * left untouched.
17
+ *
18
+ * Flags:
19
+ * --check dry-run; lists files that still need rewriting and exits 1 if
20
+ * any remain.
21
+ */
22
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
23
+ import { join } from 'node:path';
24
+
25
+ const FROM = '@internal/contract/testing';
26
+ const TO = '@repo/test-utils';
27
+
28
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
29
+
30
+ const dryRun = process.argv.includes('--check');
31
+ const projectRoot = process.cwd();
32
+
33
+ async function findSourceFiles(root: string): Promise<string[]> {
34
+ const out: string[] = [];
35
+
36
+ async function walk(dir: string): Promise<void> {
37
+ let entries: Awaited<ReturnType<typeof readdir>>;
38
+ try {
39
+ entries = await readdir(dir, { withFileTypes: true });
40
+ } catch {
41
+ return;
42
+ }
43
+ for (const entry of entries) {
44
+ if (entry.isDirectory()) {
45
+ if (SKIP_DIRS.has(entry.name)) continue;
46
+ await walk(join(dir, entry.name));
47
+ } else if (
48
+ entry.isFile() &&
49
+ (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) &&
50
+ entry.name !== 'migrate-contract-testing-imports.ts'
51
+ ) {
52
+ out.push(join(dir, entry.name));
53
+ }
54
+ }
55
+ }
56
+
57
+ await walk(root);
58
+ return out.sort();
59
+ }
60
+
61
+ const files = await findSourceFiles(projectRoot);
62
+ const targets: string[] = [];
63
+
64
+ for (const path of files) {
65
+ const raw = await readFile(path, 'utf-8');
66
+ if (raw.includes(FROM)) targets.push(path);
67
+ }
68
+
69
+ if (targets.length === 0) {
70
+ console.log('No @internal/contract/testing imports found.');
71
+ process.exit(0);
72
+ }
73
+
74
+ let needsFix = 0;
75
+ let fixed = 0;
76
+
77
+ for (const path of targets) {
78
+ const rel = path.slice(projectRoot.length + 1);
79
+ const raw = await readFile(path, 'utf-8');
80
+ const next = raw.replaceAll(FROM, TO);
81
+ if (next === raw) continue;
82
+ needsFix += 1;
83
+ if (dryRun) {
84
+ console.log(`WOULD REWRITE ${rel}`);
85
+ continue;
86
+ }
87
+ await writeFile(path, next);
88
+ fixed += 1;
89
+ console.log(`REWRITE ${rel}`);
90
+ }
91
+
92
+ console.log();
93
+ console.log(
94
+ `${targets.length} file(s) with legacy import: ${dryRun ? needsFix : fixed} ${dryRun ? 'needing rewrite' : 'rewritten'}.`,
95
+ );
96
+
97
+ if (dryRun && needsFix > 0) process.exit(1);
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Re-emits a Postgres extension pack's contract-space and regenerates its
3
+ * install migration baseline after the 0.12 public-by-default flip
4
+ * (`__unbound__`/`postgres-unbound-schema` → `public`/`postgres-schema`).
5
+ *
6
+ * The migration ops are unchanged — only the contract hash envelope moves.
7
+ * This script:
8
+ * 1. Finds extension package roots (nearest `package.json` with a
9
+ * `build:contract-space` script) whose `src/contract.json` still
10
+ * carries `postgres-unbound-schema`.
11
+ * 2. Runs `pnpm build:contract-space`.
12
+ * 3. Patches each baseline `migrations/<dir>/migration.ts` `describe().to`
13
+ * hash to match the new `storageHash`.
14
+ * 4. Self-emits each migration (`pnpm exec tsx <migration.ts>`).
15
+ * 5. Updates `migrations/refs/head.json` `hash` to the new storage hash
16
+ * (preserves `invariants`).
17
+ *
18
+ * Flags:
19
+ * --check dry-run; lists extension roots that still need regeneration
20
+ * and exits 1 if any remain.
21
+ */
22
+ import { execFile } from 'node:child_process';
23
+ import { access, copyFile, readdir, readFile, writeFile } from 'node:fs/promises';
24
+ import { join } from 'node:path';
25
+ import { promisify } from 'node:util';
26
+
27
+ const execFileAsync = promisify(execFile);
28
+
29
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
30
+
31
+ const dryRun = process.argv.includes('--check');
32
+ const projectRoot = process.cwd();
33
+
34
+ async function pathExists(path: string): Promise<boolean> {
35
+ try {
36
+ await access(path);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ async function findPackageJsonFiles(root: string): Promise<string[]> {
48
+ const out: string[] = [];
49
+
50
+ async function walk(dir: string): Promise<void> {
51
+ let entries: Awaited<ReturnType<typeof readdir>>;
52
+ try {
53
+ entries = await readdir(dir, { withFileTypes: true });
54
+ } catch {
55
+ return;
56
+ }
57
+ for (const entry of entries) {
58
+ if (entry.isDirectory()) {
59
+ if (SKIP_DIRS.has(entry.name)) continue;
60
+ await walk(join(dir, entry.name));
61
+ } else if (entry.isFile() && entry.name === 'package.json') {
62
+ out.push(join(dir, entry.name));
63
+ }
64
+ }
65
+ }
66
+
67
+ await walk(root);
68
+ return out.sort();
69
+ }
70
+
71
+ function contractNeedsPublicDefaultMigration(raw: string): boolean {
72
+ return (
73
+ raw.includes('"kind": "postgres-unbound-schema"') ||
74
+ raw.includes('"kind":"postgres-unbound-schema"')
75
+ );
76
+ }
77
+
78
+ async function packageHasBuildContractSpace(pkgPath: string): Promise<boolean> {
79
+ const raw = await readFile(pkgPath, 'utf-8');
80
+ try {
81
+ const parsed: unknown = JSON.parse(raw);
82
+ if (!isJsonObject(parsed)) return false;
83
+ const scripts = parsed['scripts'];
84
+ if (!isJsonObject(scripts)) return false;
85
+ return (
86
+ typeof scripts['build:contract-space'] === 'string' &&
87
+ scripts['build:contract-space'].length > 0
88
+ );
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ async function findMigrationDirs(migrationsDir: string): Promise<string[]> {
95
+ const out: string[] = [];
96
+ let entries: Awaited<ReturnType<typeof readdir>>;
97
+ try {
98
+ entries = await readdir(migrationsDir, { withFileTypes: true });
99
+ } catch {
100
+ return out;
101
+ }
102
+ for (const entry of entries) {
103
+ if (!entry.isDirectory()) continue;
104
+ const migrationDir = join(migrationsDir, entry.name);
105
+ if (await pathExists(join(migrationDir, 'migration.ts'))) out.push(migrationDir);
106
+ }
107
+ return out.sort();
108
+ }
109
+
110
+ async function readStorageHash(contractPath: string): Promise<string | null> {
111
+ const raw = await readFile(contractPath, 'utf-8');
112
+ try {
113
+ const parsed: unknown = JSON.parse(raw);
114
+ if (!isJsonObject(parsed)) return null;
115
+ const storage = parsed['storage'];
116
+ if (!isJsonObject(storage)) return null;
117
+ const storageHash = storage['storageHash'];
118
+ return typeof storageHash === 'string' ? storageHash : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function patchMigrationToHash(
125
+ migrationTsPath: string,
126
+ storageHash: string,
127
+ ): Promise<boolean> {
128
+ const raw = await readFile(migrationTsPath, 'utf-8');
129
+ const patched = raw.replace(/(\bto:\s*['"])sha256:[0-9a-f]{64}(['"])/, `$1${storageHash}$2`);
130
+ if (patched === raw) return false;
131
+ await writeFile(migrationTsPath, patched);
132
+ return true;
133
+ }
134
+
135
+ async function patchHeadRef(headPath: string, storageHash: string): Promise<boolean> {
136
+ const raw = await readFile(headPath, 'utf-8');
137
+ let parsed: unknown;
138
+ try {
139
+ parsed = JSON.parse(raw);
140
+ } catch {
141
+ return false;
142
+ }
143
+ if (!isJsonObject(parsed)) return false;
144
+ if (parsed['hash'] === storageHash) return false;
145
+ parsed['hash'] = storageHash;
146
+ await writeFile(headPath, `${JSON.stringify(parsed, null, 2)}\n`);
147
+ return true;
148
+ }
149
+
150
+ interface ExtensionRoot {
151
+ readonly dir: string;
152
+ readonly contractPath: string;
153
+ }
154
+
155
+ const extensionRoots: ExtensionRoot[] = [];
156
+
157
+ for (const pkgPath of await findPackageJsonFiles(projectRoot)) {
158
+ if (!(await packageHasBuildContractSpace(pkgPath))) continue;
159
+ const dir = join(pkgPath, '..');
160
+ const contractPath = join(dir, 'src', 'contract.json');
161
+ if (!(await pathExists(contractPath))) continue;
162
+ const raw = await readFile(contractPath, 'utf-8');
163
+ if (!contractNeedsPublicDefaultMigration(raw)) continue;
164
+ extensionRoots.push({ dir, contractPath });
165
+ }
166
+
167
+ if (extensionRoots.length === 0) {
168
+ console.error(`No extension public-default migration candidates under ${projectRoot}.`);
169
+ process.exit(dryRun ? 0 : 1);
170
+ }
171
+
172
+ let needsFix = 0;
173
+ let alreadyClean = 0;
174
+
175
+ for (const { dir, contractPath } of extensionRoots) {
176
+ const rel = dir.slice(projectRoot.length + 1) || '.';
177
+ const raw = await readFile(contractPath, 'utf-8');
178
+ if (!contractNeedsPublicDefaultMigration(raw)) {
179
+ alreadyClean += 1;
180
+ console.log(`OK ${rel}`);
181
+ continue;
182
+ }
183
+
184
+ needsFix += 1;
185
+ if (dryRun) {
186
+ console.log(`WOULD REGENERATE ${rel}`);
187
+ continue;
188
+ }
189
+
190
+ console.log(`REGENERATE ${rel}`);
191
+ await execFileAsync('pnpm', ['build:contract-space'], { cwd: dir, env: process.env });
192
+
193
+ const storageHash = await readStorageHash(contractPath);
194
+ if (storageHash === null) {
195
+ throw new Error(`Could not read storageHash from ${contractPath}`);
196
+ }
197
+
198
+ const migrationsDir = join(dir, 'migrations');
199
+ const srcContractJson = join(dir, 'src', 'contract.json');
200
+ const srcContractDts = join(dir, 'src', 'contract.d.ts');
201
+
202
+ for (const migrationDir of await findMigrationDirs(migrationsDir)) {
203
+ await copyFile(srcContractJson, join(migrationDir, 'end-contract.json'));
204
+ if (await pathExists(srcContractDts)) {
205
+ await copyFile(srcContractDts, join(migrationDir, 'end-contract.d.ts'));
206
+ }
207
+ const migrationTs = join(migrationDir, 'migration.ts');
208
+ await patchMigrationToHash(migrationTs, storageHash);
209
+ await execFileAsync('pnpm', ['exec', 'tsx', migrationTs], { cwd: dir, env: process.env });
210
+ }
211
+
212
+ const headPath = join(migrationsDir, 'refs', 'head.json');
213
+ if (await pathExists(headPath)) {
214
+ await patchHeadRef(headPath, storageHash);
215
+ }
216
+ }
217
+
218
+ console.log();
219
+ console.log(
220
+ `${extensionRoots.length} extension pack(s): ${needsFix} ${dryRun ? 'needing regeneration' : 'regenerated'}, ${alreadyClean} already on public default.`,
221
+ );
222
+
223
+ if (dryRun && needsFix > 0) process.exit(1);
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Brings on-disk `migration.json` manifests into the slimmed 0.12 metadata
3
+ * model: drops the now-removed `labels` and `hints` keys and recomputes
4
+ * `migrationHash` over the surviving metadata envelope + sibling `ops.json`.
5
+ *
6
+ * Background: starting at the 0.12 release the migration manifest schema is
7
+ * closed (`'+': 'reject'`) — `labels` and `hints` are no longer part of the
8
+ * model, so any manifest still carrying either key fails to load with
9
+ * `INVALID_MANIFEST` naming the offending key. The two fields also no longer
10
+ * participate in the content-addressed migration identity: `migrationHash` is
11
+ * now computed over `{ from, to, providedInvariants, createdAt }` plus the
12
+ * sibling operations, so every migrated manifest gets a freshly recomputed
13
+ * hash over the slimmed envelope.
14
+ *
15
+ * Before 0.12 the on-disk shape was:
16
+ *
17
+ * {
18
+ * "from": null,
19
+ * "to": "sha256:…",
20
+ * "labels": [],
21
+ * "providedInvariants": ["…"],
22
+ * "createdAt": "2026-…",
23
+ * "hints": { "used": [], "applied": [], "plannerVersion": "2.0.0" },
24
+ * "migrationHash": "sha256:…"
25
+ * }
26
+ *
27
+ * Starting at 0.12 the same manifest is:
28
+ *
29
+ * {
30
+ * "from": null,
31
+ * "to": "sha256:…",
32
+ * "providedInvariants": ["…"],
33
+ * "createdAt": "2026-…",
34
+ * "migrationHash": "sha256:…" // recomputed over the slimmed envelope
35
+ * }
36
+ *
37
+ * Format-preserving edit: rather than reparse-and-reserialise (which would
38
+ * reflow every value to a single canonical style and bloat the diff), this
39
+ * codemod performs a surgical text edit — it removes only the `labels` and
40
+ * `hints` top-level key lines and swaps the `migrationHash` value in place.
41
+ * Every other byte (key order, indentation, and whether arrays like
42
+ * `providedInvariants` are written inline or expanded) is left exactly as the
43
+ * authoring tool wrote it, so the diff is limited to the two removed keys and
44
+ * the new hash value.
45
+ *
46
+ * Confinement: an on-disk migration package is a `migration.json` paired with
47
+ * a sibling `ops.json` (the operations the hash is computed over). The walk
48
+ * keys off that pair rather than off a `migrations/` directory name, because
49
+ * migration packages live under several roots in practice (`migrations/`,
50
+ * `migration-fixtures/`, …); a `migration.json` with no sibling `ops.json` is
51
+ * not a complete package and is left untouched.
52
+ *
53
+ * The hash algorithm is replicated inline (canonicalisation rules from
54
+ * `@internal/framework-components` `canonicalizeJson` + the migration-tools
55
+ * `computeMigrationHash`) so this script stays self-contained — consumers run
56
+ * it via `pnpm exec tsx` from their project root with no dependency on any
57
+ * `@internal/*` package being resolvable from that root.
58
+ *
59
+ * The codemod is idempotent: an already-slimmed manifest carries no
60
+ * `labels`/`hints` and already has its recomputed hash, so the edit is a no-op
61
+ * and the file is left untouched.
62
+ *
63
+ * Flags:
64
+ * --check dry-run; lists manifests that still need fixing and exits 1 if
65
+ * any remain.
66
+ */
67
+ import { createHash } from 'node:crypto';
68
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
69
+ import { dirname, join } from 'node:path';
70
+
71
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
72
+
73
+ const dryRun = process.argv.includes('--check');
74
+ const projectRoot = process.cwd();
75
+
76
+ // --- Inline canonicalisation + hash --------------------------------------
77
+ // Replicated from `@internal/framework-components` `canonicalizeJson`
78
+ // (sortKeys + JSON.stringify) and the migration-tools `computeMigrationHash`.
79
+ // Kept inline so the script has no `@internal/*` import — pnpm's strict
80
+ // node_modules layout won't resolve transitive framework deps from a
81
+ // consumer's project root.
82
+
83
+ function sortKeys(value: unknown): unknown {
84
+ if (value === null || typeof value !== 'object') {
85
+ return value;
86
+ }
87
+ if (Array.isArray(value)) {
88
+ return value.map(sortKeys);
89
+ }
90
+ const sorted: Record<string, unknown> = Object.create(null);
91
+ for (const [key, entry] of Object.entries(value).sort(([a], [b]) =>
92
+ a < b ? -1 : a > b ? 1 : 0,
93
+ )) {
94
+ sorted[key] = sortKeys(entry);
95
+ }
96
+ return sorted;
97
+ }
98
+
99
+ function canonicalizeJson(value: unknown): string {
100
+ return JSON.stringify(sortKeys(value));
101
+ }
102
+
103
+ function sha256Hex(input: string): string {
104
+ return createHash('sha256').update(input).digest('hex');
105
+ }
106
+
107
+ /**
108
+ * Content-addressed migration hash over (metadata envelope, ops). The
109
+ * `migrationHash` field is stripped before hashing so the same function works
110
+ * at write time (no hash yet) and at recompute time (rehashing an
111
+ * already-attested record over the slimmed envelope).
112
+ */
113
+ function computeMigrationHash(metadata: Record<string, unknown>, ops: unknown): string {
114
+ const { migrationHash: _migrationHash, ...strippedMeta } = metadata;
115
+
116
+ const partHashes = [canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex);
117
+ return `sha256:${sha256Hex(canonicalizeJson(partHashes))}`;
118
+ }
119
+
120
+ // --- Format-preserving text surgery --------------------------------------
121
+
122
+ /**
123
+ * Returns the index just past the end of the JSON value that starts at
124
+ * `start` (which must point at the value's first character). Handles strings
125
+ * (with escapes), nested objects/arrays, and primitives. Used to locate the
126
+ * full span of a top-level key's value when removing the key from the raw
127
+ * text without reparsing the whole document.
128
+ */
129
+ function scanValueEnd(text: string, start: number): number {
130
+ const c = text[start];
131
+
132
+ if (c === '"') {
133
+ let i = start + 1;
134
+ while (i < text.length) {
135
+ if (text[i] === '\\') {
136
+ i += 2;
137
+ continue;
138
+ }
139
+ if (text[i] === '"') return i + 1;
140
+ i += 1;
141
+ }
142
+ throw new Error('unterminated string while scanning JSON value');
143
+ }
144
+
145
+ if (c === '{' || c === '[') {
146
+ const open = c;
147
+ const close = c === '{' ? '}' : ']';
148
+ let depth = 0;
149
+ let i = start;
150
+ while (i < text.length) {
151
+ const ch = text[i];
152
+ if (ch === '"') {
153
+ i = scanValueEnd(text, i);
154
+ continue;
155
+ }
156
+ if (ch === open) depth += 1;
157
+ else if (ch === close) {
158
+ depth -= 1;
159
+ if (depth === 0) return i + 1;
160
+ }
161
+ i += 1;
162
+ }
163
+ throw new Error('unterminated container while scanning JSON value');
164
+ }
165
+
166
+ // Primitive (number / true / false / null) — run to the next structural
167
+ // terminator.
168
+ let i = start;
169
+ while (i < text.length && !',}]\r\n \t'.includes(text[i]!)) i += 1;
170
+ return i;
171
+ }
172
+
173
+ /**
174
+ * Removes a top-level object key (and its value) from `text`, preserving the
175
+ * surrounding bytes exactly. No-op (returns `text`) if the key is absent.
176
+ * Only the top-level `labels` / `hints` keys are ever passed here; both always
177
+ * precede the trailing `migrationHash` key, so a removed key always carries a
178
+ * trailing comma that is consumed along with the line.
179
+ */
180
+ function removeTopLevelKey(text: string, key: string): string {
181
+ // A top-level key is the only occurrence of `"key":` preceded by a newline
182
+ // (line 1 is the opening `{`). Tolerant of any indentation width.
183
+ const re = new RegExp(`\\n([ \\t]*)"${key}"[ \\t]*:[ \\t]*`);
184
+ const match = re.exec(text);
185
+ if (match === null) return text;
186
+
187
+ const lineStart = match.index + 1; // position just after the leading newline
188
+ const valueStart = match.index + match[0].length;
189
+ let after = scanValueEnd(text, valueStart);
190
+
191
+ while (text[after] === ' ' || text[after] === '\t') after += 1;
192
+ if (text[after] === ',') after += 1;
193
+ if (text[after] === '\r') after += 1;
194
+ if (text[after] === '\n') after += 1;
195
+
196
+ return text.slice(0, lineStart) + text.slice(after);
197
+ }
198
+
199
+ function replaceMigrationHash(text: string, oldHash: string, newHash: string): string {
200
+ if (oldHash === newHash) return text;
201
+ // Tolerate any whitespace around the colon (`"migrationHash":"…"`,
202
+ // `"migrationHash" : "…"`), matching the leniency of `removeTopLevelKey`.
203
+ const escapedOld = oldHash.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
204
+ const re = new RegExp(`("migrationHash"[ \\t]*:[ \\t]*)"${escapedOld}"`);
205
+ const match = re.exec(text);
206
+ if (match === null) {
207
+ throw new Error('could not locate the migrationHash value to replace');
208
+ }
209
+ return text.replace(re, (_full, prefix: string) => `${prefix}"${newHash}"`);
210
+ }
211
+
212
+ // --- Filesystem walk ------------------------------------------------------
213
+
214
+ async function findMigrationManifests(root: string): Promise<string[]> {
215
+ const out: string[] = [];
216
+
217
+ async function walk(dir: string): Promise<void> {
218
+ let entries: Awaited<ReturnType<typeof readdir>>;
219
+ try {
220
+ entries = await readdir(dir, { withFileTypes: true });
221
+ } catch {
222
+ // Unreadable directory — skip silently. The consumer's project root may
223
+ // legitimately contain restricted directories.
224
+ return;
225
+ }
226
+ for (const entry of entries) {
227
+ if (entry.isDirectory()) {
228
+ if (SKIP_DIRS.has(entry.name)) continue;
229
+ await walk(join(dir, entry.name));
230
+ } else if (entry.isFile() && entry.name === 'migration.json') {
231
+ out.push(join(dir, entry.name));
232
+ }
233
+ }
234
+ }
235
+
236
+ await walk(root);
237
+ return out.sort();
238
+ }
239
+
240
+ // --- Per-file transform ---------------------------------------------------
241
+
242
+ /** Narrows an arbitrary JSON-parsed value to a plain object (manifest shape). */
243
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
244
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
245
+ }
246
+
247
+ type Status = 'already-clean' | 'needs-fix' | 'fixed' | 'skipped-no-ops';
248
+
249
+ interface Result {
250
+ readonly path: string;
251
+ readonly status: Status;
252
+ }
253
+
254
+ async function processFile(path: string): Promise<Result> {
255
+ const raw = await readFile(path, 'utf-8');
256
+
257
+ let parsed: unknown;
258
+ try {
259
+ parsed = JSON.parse(raw);
260
+ } catch (error) {
261
+ throw new Error(
262
+ `${path}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,
263
+ );
264
+ }
265
+ if (!isJsonObject(parsed)) {
266
+ return { path, status: 'already-clean' }; // not a manifest object
267
+ }
268
+ const metadata = parsed;
269
+
270
+ // A complete on-disk migration package pairs `migration.json` with a sibling
271
+ // `ops.json` (the operations the hash is computed over); without it we cannot
272
+ // recompute the hash, so this is not a package we should touch.
273
+ const opsPath = join(dirname(path), 'ops.json');
274
+ let ops: unknown;
275
+ try {
276
+ ops = JSON.parse(await readFile(opsPath, 'utf-8'));
277
+ } catch {
278
+ return { path, status: 'skipped-no-ops' };
279
+ }
280
+
281
+ // Recompute over the slimmed envelope (canonicalisation is order/whitespace
282
+ // independent, so the parsed object is the right input regardless of on-disk
283
+ // formatting). `computeMigrationHash` strips `migrationHash` internally.
284
+ const slimmed = { ...metadata };
285
+ delete slimmed['labels'];
286
+ delete slimmed['hints'];
287
+ const newHash = computeMigrationHash(slimmed, ops);
288
+
289
+ let out = raw;
290
+ out = removeTopLevelKey(out, 'labels');
291
+ out = removeTopLevelKey(out, 'hints');
292
+
293
+ const oldHash = metadata['migrationHash'];
294
+ if (typeof oldHash === 'string') {
295
+ out = replaceMigrationHash(out, oldHash, newHash);
296
+ } else if (out !== raw) {
297
+ // labels/hints were present but there is no string migrationHash to update
298
+ // — a malformed manifest we refuse to guess at.
299
+ throw new Error(`${path}: manifest is missing a string \`migrationHash\` field`);
300
+ }
301
+
302
+ if (out === raw) {
303
+ return { path, status: 'already-clean' };
304
+ }
305
+ if (!dryRun) await writeFile(path, out, 'utf-8');
306
+ return { path, status: dryRun ? 'needs-fix' : 'fixed' };
307
+ }
308
+
309
+ // --- Driver ---------------------------------------------------------------
310
+
311
+ const manifests = await findMigrationManifests(projectRoot);
312
+ if (manifests.length === 0) {
313
+ console.error(`No migration.json files found under ${projectRoot}.`);
314
+ process.exit(1);
315
+ }
316
+
317
+ let changed = 0;
318
+ let alreadyClean = 0;
319
+ let skipped = 0;
320
+ for (const path of manifests) {
321
+ const result = await processFile(path);
322
+ const rel = path.slice(projectRoot.length + 1);
323
+ if (result.status === 'already-clean') {
324
+ alreadyClean += 1;
325
+ } else if (result.status === 'skipped-no-ops') {
326
+ skipped += 1;
327
+ console.log(`SKIP ${rel} (no sibling ops.json — not a migration package)`);
328
+ } else {
329
+ changed += 1;
330
+ const verb = dryRun ? 'WOULD FIX' : 'FIXED';
331
+ console.log(`${verb} ${rel}`);
332
+ }
333
+ }
334
+
335
+ console.log();
336
+ console.log(
337
+ `${manifests.length} manifest(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean${skipped > 0 ? `, ${skipped} skipped (no ops.json)` : ''}.`,
338
+ );
339
+
340
+ if (dryRun && changed > 0) process.exit(1);