@prisma/orm-mongo 8.0.0-rc.4-dev.17 → 8.0.0-rc.5-dev.1

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,226 @@
1
+ /**
2
+ * Removes the inlined `fromContract` / `toContract` fields from every
3
+ * committed `migration.json` manifest reachable from the project root.
4
+ *
5
+ * Background: starting at the 0.9 release, `migration.json` no longer
6
+ * carries `fromContract` / `toContract` (the schema rejects them as
7
+ * unknown keys). The destination contract continues to live next door
8
+ * as `end-contract.json` (and the source as `start-contract.json`); the
9
+ * manifest copy was redundant. `migrationHash` is unaffected — it was
10
+ * already computed without those two fields, so stripping them does
11
+ * not change the stored hash.
12
+ *
13
+ * Behaviour:
14
+ * - Walks the project root recursively, ignoring `node_modules`, `.git`,
15
+ * `dist`, and `build`. Picks up every file named `migration.json`
16
+ * whose JSON object has the migration-manifest shape (`from`, `to`,
17
+ * and `migrationHash` keys). Other `migration.json` files (e.g.
18
+ * unrelated artefacts that happen to share the name) are skipped.
19
+ * - Manifests that already lack both removed keys are left untouched.
20
+ * - Manifests with either removed key are rewritten with the two key /
21
+ * value spans excised at the text level, so the formatting of all
22
+ * surviving fields (whitespace, inline-vs-multiline arrays, key
23
+ * ordering, trailing newline) is preserved byte-for-byte. Only the
24
+ * key being removed and its trailing comma+newline disappear from
25
+ * the diff.
26
+ * - Idempotent: re-running the script after success is a no-op.
27
+ *
28
+ * Flags:
29
+ * --check dry-run; exit 1 if any manifest still needs fixing.
30
+ */
31
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
32
+ import { join } from 'node:path';
33
+
34
+ const REMOVED_KEYS = ['fromContract', 'toContract'] as const;
35
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
36
+
37
+ const dryRun = process.argv.includes('--check');
38
+ const projectRoot = process.cwd();
39
+
40
+ interface Result {
41
+ readonly path: string;
42
+ readonly status: 'already-clean' | 'needs-fix' | 'fixed';
43
+ readonly removed: readonly string[];
44
+ }
45
+
46
+ async function findManifests(root: string): Promise<string[]> {
47
+ const out: string[] = [];
48
+
49
+ async function walk(dir: string): Promise<void> {
50
+ const entries = await readdir(dir, { withFileTypes: true });
51
+ for (const entry of entries) {
52
+ if (entry.isDirectory()) {
53
+ if (SKIP_DIRS.has(entry.name)) continue;
54
+ await walk(join(dir, entry.name));
55
+ } else if (entry.isFile() && entry.name === 'migration.json') {
56
+ const path = join(dir, entry.name);
57
+ try {
58
+ const parsed: unknown = JSON.parse(await readFile(path, 'utf-8'));
59
+ if (looksLikeMigrationManifest(parsed)) out.push(path);
60
+ } catch {
61
+ // Not valid JSON, or not the manifest shape — skip silently.
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ await walk(root);
68
+ return out.sort();
69
+ }
70
+
71
+ function looksLikeMigrationManifest(value: unknown): value is Record<string, unknown> {
72
+ if (typeof value !== 'object' || value === null) return false;
73
+ const obj = value as Record<string, unknown>;
74
+ return 'from' in obj && 'to' in obj && 'migrationHash' in obj;
75
+ }
76
+
77
+ /**
78
+ * Find the end index (exclusive) of a JSON value starting at `start`
79
+ * inside `text`. Handles strings, numbers, booleans, null, arrays, and
80
+ * objects. Brace / bracket nesting is tracked while respecting string
81
+ * literals (with escapes). Throws if `text` is not well-formed JSON
82
+ * starting at `start`.
83
+ */
84
+ function jsonValueEnd(text: string, start: number): number {
85
+ const head = text[start];
86
+ let depth = 0;
87
+ let inString = false;
88
+ let inEscape = false;
89
+
90
+ if (head === '"') {
91
+ let i = start + 1;
92
+ while (i < text.length) {
93
+ const ch = text[i];
94
+ if (inEscape) inEscape = false;
95
+ else if (ch === '\\') inEscape = true;
96
+ else if (ch === '"') return i + 1;
97
+ i += 1;
98
+ }
99
+ throw new Error(`Unterminated string starting at ${start}`);
100
+ }
101
+
102
+ if (head === '{' || head === '[') {
103
+ let i = start;
104
+ while (i < text.length) {
105
+ const ch = text[i];
106
+ if (inString) {
107
+ if (inEscape) inEscape = false;
108
+ else if (ch === '\\') inEscape = true;
109
+ else if (ch === '"') inString = false;
110
+ } else {
111
+ if (ch === '"') inString = true;
112
+ else if (ch === '{' || ch === '[') depth += 1;
113
+ else if (ch === '}' || ch === ']') {
114
+ depth -= 1;
115
+ if (depth === 0) return i + 1;
116
+ }
117
+ }
118
+ i += 1;
119
+ }
120
+ throw new Error(`Unterminated container starting at ${start}`);
121
+ }
122
+
123
+ let i = start;
124
+ while (i < text.length && !',}\n\r\t '.includes(text[i] ?? '')) i += 1;
125
+ return i;
126
+ }
127
+
128
+ /**
129
+ * Remove a top-level key (and its `: value` and trailing comma) from a
130
+ * pretty-printed JSON object text. Returns the new text. If the key
131
+ * isn't present, returns the input unchanged.
132
+ *
133
+ * Preserves all surrounding whitespace and the formatting of every
134
+ * other field byte-for-byte. Handles both "key in the middle" (eats the
135
+ * trailing comma + newline) and "key at the end" (eats the leading
136
+ * comma + newline).
137
+ */
138
+ function removeTopLevelKey(text: string, key: string): string {
139
+ const needle = `"${key}"`;
140
+ const keyIndex = text.indexOf(needle);
141
+ if (keyIndex < 0) return text;
142
+
143
+ let cursor = keyIndex + needle.length;
144
+ while (cursor < text.length && /\s/.test(text[cursor] ?? '')) cursor += 1;
145
+ if (text[cursor] !== ':') {
146
+ throw new Error(`Expected ':' after ${needle} at ${cursor}`);
147
+ }
148
+ cursor += 1;
149
+ while (cursor < text.length && /\s/.test(text[cursor] ?? '')) cursor += 1;
150
+
151
+ const valueEnd = jsonValueEnd(text, cursor);
152
+
153
+ let removeStart = keyIndex;
154
+ let removeEnd = valueEnd;
155
+
156
+ if (text[removeEnd] === ',') {
157
+ removeEnd += 1;
158
+ if (text[removeEnd] === '\n') removeEnd += 1;
159
+ let lineStart = removeStart;
160
+ while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart -= 1;
161
+ if (text.slice(lineStart, removeStart).trim() === '') removeStart = lineStart;
162
+ } else {
163
+ let back = removeStart - 1;
164
+ while (back > 0 && /[ \t]/.test(text[back] ?? '')) back -= 1;
165
+ if (text[back] === '\n') {
166
+ let prev = back - 1;
167
+ while (prev > 0 && /[ \t]/.test(text[prev] ?? '')) prev -= 1;
168
+ if (text[prev] === ',') {
169
+ removeStart = prev;
170
+ if (text[removeEnd] === '\n') removeEnd += 1;
171
+ }
172
+ }
173
+ }
174
+
175
+ return text.slice(0, removeStart) + text.slice(removeEnd);
176
+ }
177
+
178
+ async function processManifest(path: string): Promise<Result> {
179
+ const raw = await readFile(path, 'utf-8');
180
+ const data: Record<string, unknown> = JSON.parse(raw);
181
+ const removed = REMOVED_KEYS.filter((key) => key in data);
182
+ if (removed.length === 0) return { path, status: 'already-clean', removed: [] };
183
+
184
+ let stripped = raw;
185
+ for (const key of removed) stripped = removeTopLevelKey(stripped, key);
186
+
187
+ // Sanity: stripped output must still be valid JSON and must agree on
188
+ // every field except the two we removed.
189
+ const reparsed: Record<string, unknown> = JSON.parse(stripped);
190
+ for (const key of removed) {
191
+ if (key in reparsed) {
192
+ throw new Error(`Internal: ${key} survived strip in ${path}`);
193
+ }
194
+ }
195
+
196
+ if (!dryRun) await writeFile(path, stripped, 'utf-8');
197
+ return { path, status: dryRun ? 'needs-fix' : 'fixed', removed };
198
+ }
199
+
200
+ const manifests = await findManifests(projectRoot);
201
+ if (manifests.length === 0) {
202
+ console.error(`No migration.json files found under ${projectRoot}.`);
203
+ process.exit(1);
204
+ }
205
+
206
+ let changed = 0;
207
+ let alreadyClean = 0;
208
+ for (const path of manifests) {
209
+ const result = await processManifest(path);
210
+ const rel = path.slice(projectRoot.length + 1);
211
+ if (result.status === 'already-clean') {
212
+ alreadyClean += 1;
213
+ console.log(`OK ${rel} (already clean)`);
214
+ } else {
215
+ changed += 1;
216
+ const verb = dryRun ? 'WOULD FIX' : 'FIXED';
217
+ console.log(`${verb} ${rel} (removed: ${result.removed.join(', ')})`);
218
+ }
219
+ }
220
+
221
+ console.log();
222
+ console.log(
223
+ `${manifests.length} manifest(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'}, ${alreadyClean} already clean.`,
224
+ );
225
+
226
+ if (dryRun && changed > 0) process.exit(1);
@@ -0,0 +1,86 @@
1
+ ---
2
+ from: "0.9"
3
+ to: "0.10"
4
+ changes:
5
+ - id: stamp-storage-types-kind-on-contract-snapshots
6
+ summary: Stamp the `kind` discriminator (`"codec-instance"` / `"postgres-enum"`) on every entry in `storage.types` inside every committed `start-contract.json` / `end-contract.json` snapshot. The SQL family's contract serializer is now strict — untagged entries fail to load with a deserializer diagnostic naming the offending entry.
7
+ detection:
8
+ glob: "**/migrations/**/{start,end}-contract.json"
9
+ contains:
10
+ - '"codecId"'
11
+ anyMatch: true
12
+ script: ./stamp-storage-types-kind.ts
13
+ ---
14
+
15
+ # 0.9 → 0.10 — User upgrade instructions
16
+
17
+ ## `stamp-storage-types-kind-on-contract-snapshots`
18
+
19
+ Starting at the 0.10 release, the SQL family's contract serializer (`familyInstance.validateContract`, the seam every on-disk contract read now crosses) is **strict** about the `storage.types` polymorphic slot. Every entry must carry a `kind` discriminator (`"codec-instance"` for codec triples, `"postgres-enum"` for Postgres enums). The previous silent fallthrough in `normaliseTypeEntry` that quietly accepted untagged codec triples is gone.
20
+
21
+ Before 0.10, on-disk contract snapshots committed alongside your migrations looked like this:
22
+
23
+ ```jsonc
24
+ {
25
+ "storage": {
26
+ "types": {
27
+ "Embedding1536": {
28
+ "codecId": "pg/vector@1",
29
+ "nativeType": "vector",
30
+ "typeParams": { "length": 1536 }
31
+ },
32
+ "user_type": {
33
+ "codecId": "pg/enum@1",
34
+ "nativeType": "user_type",
35
+ "typeParams": { "values": ["admin", "user"] }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ Starting at 0.10 the same snapshots must look like this:
43
+
44
+ ```jsonc
45
+ {
46
+ "storage": {
47
+ "types": {
48
+ "Embedding1536": {
49
+ "kind": "codec-instance",
50
+ "codecId": "pg/vector@1",
51
+ "nativeType": "vector",
52
+ "typeParams": { "length": 1536 }
53
+ },
54
+ "user_type": {
55
+ "kind": "postgres-enum",
56
+ "name": "user_type",
57
+ "nativeType": "user_type",
58
+ "values": ["admin", "user"],
59
+ "codecId": "pg/enum@1"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ The two-shape split reflects how the SQL family's hydration path discriminates: codec triples are plain JSON envelopes that round-trip through the slot unchanged; Postgres enums hydrate into a target-specific IR class instance (`PostgresEnumType`) whose structural shape includes `name` / `values` directly (no `typeParams` indirection).
67
+
68
+ Without this stamp, every CLI command that reads an on-disk contract — `prisma-next migration plan`, `migration new`, `migrate`, `migration show`, `db verify` — fails with a `Contract validation failed` envelope pointing at the offending snapshot and naming the missing `kind` discriminator. The failure is loud and the diagnostic identifies the file, but the only fix is to stamp the entries; there is no migration-time legacy shape acceptance any more.
69
+
70
+ ### What `stamp-storage-types-kind.ts` does
71
+
72
+ The colocated script walks the project root, descends into every directory named `migrations/` (skipping `node_modules`, `.git`, `dist`, `build`), and rewrites every `start-contract.json` and `end-contract.json` whose `storage.types` slot has at least one untagged entry:
73
+
74
+ - **Already-stamped entries** (those carrying a `"codec-instance"` or `"postgres-enum"` `kind`) are left untouched. The codemod is idempotent — safe to re-run.
75
+ - **`pg/enum@1`** entries are rewritten as the `"postgres-enum"` shape: the entry's map key is lifted to `name`; `values` is lifted out of `typeParams.values`; `nativeType` and `codecId` are preserved; `typeParams` is dropped (its only meaningful content was `values`).
76
+ - **Every other codecId** (including extension-contributed codecs like `pg/vector@1`, `cipherstash/*`, etc.) is rewritten as the `"codec-instance"` shape: `kind` is prepended; `codecId`, `nativeType`, and `typeParams` are preserved verbatim.
77
+ - The whole snapshot is re-serialised via a JSON pretty-printer that mirrors the CLI's authoring shape: multi-line objects and arrays at `JSON.stringify(value, null, 2)` indentation, with short primitive arrays (`["admin", "user"]`, `["id"]`, etc.) inlined on a single line — the same on-disk shape the CLI emits. On CLI-authored snapshots, the diff outside `storage.types` is zero. Hand-edited snapshots may experience cosmetic whitespace shifts; this is acceptable because on-disk contract snapshots are CLI artefacts, not user-edited source.
78
+ - A `--check` flag turns the script into a dry-run; it lists which snapshots would be modified and exits non-zero if any still need fixing.
79
+
80
+ If a snapshot contains a `storage.types` entry that is neither already-stamped nor an untagged codec triple (i.e. some structurally unexpected shape), the script throws and names the offending entry. That case is a hand-edit required — the codemod refuses to guess.
81
+
82
+ ### Validation
83
+
84
+ After running the script, run `pnpm prisma-next migration plan` against your project. It should be a no-op (no schema changes detected) — that's the loudest end-to-end check that every snapshot now loads cleanly under the strict serializer. If it crashes with `Contract validation failed`, the message names the snapshot path and the entry; either re-run the codemod (some snapshots may have been missed by the glob), or hand-edit the entry to the expected shape above.
85
+
86
+ Then run `pnpm typecheck && pnpm test` per the per-step flow. The new strict serializer changes only the on-disk loading path; in-memory contracts authored via the builder DSL are unaffected.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Stamps the `kind` discriminator on every entry in `storage.types`
3
+ * inside every on-disk contract snapshot reachable from the project
4
+ * root (`start-contract.json` / `end-contract.json` under any
5
+ * `migrations/` directory).
6
+ *
7
+ * Background: starting at the 0.10 release, the SQL family's
8
+ * `SqlStorage.types` polymorphic slot is strictly typed via an
9
+ * enumerable `kind` discriminator (`'codec-instance'` for codec
10
+ * triples; `'postgres-enum'` for Postgres enums). The family
11
+ * `ContractSerializer` rejects untagged entries with a diagnostic
12
+ * naming the offending entry — the previous silent fallthrough in
13
+ * `normaliseTypeEntry` is gone. See TML-2536.
14
+ *
15
+ * Before 0.10, the on-disk shape was:
16
+ *
17
+ * "storage": {
18
+ * "types": {
19
+ * "Embedding1536": {
20
+ * "codecId": "pg/vector@1",
21
+ * "nativeType": "vector",
22
+ * "typeParams": { "length": 1536 }
23
+ * },
24
+ * "user_type": {
25
+ * "codecId": "pg/enum@1",
26
+ * "nativeType": "user_type",
27
+ * "typeParams": { "values": ["admin", "user"] }
28
+ * }
29
+ * }
30
+ * }
31
+ *
32
+ * Starting at 0.10 the same entries are:
33
+ *
34
+ * "storage": {
35
+ * "types": {
36
+ * "Embedding1536": {
37
+ * "kind": "codec-instance",
38
+ * "codecId": "pg/vector@1",
39
+ * "nativeType": "vector",
40
+ * "typeParams": { "length": 1536 }
41
+ * },
42
+ * "user_type": {
43
+ * "kind": "postgres-enum",
44
+ * "name": "user_type",
45
+ * "nativeType": "user_type",
46
+ * "values": ["admin", "user"],
47
+ * "codecId": "pg/enum@1"
48
+ * }
49
+ * }
50
+ * }
51
+ *
52
+ * Dispatch rules (per entry):
53
+ *
54
+ * - Already-stamped entry (carries a `kind` field) → left untouched.
55
+ * - `codecId === "pg/enum@1"` → rewritten as the `postgres-enum`
56
+ * shape: `kind`, `name` (lifted from the entry key), `nativeType`,
57
+ * `values` (lifted out of `typeParams.values`), `codecId`.
58
+ * `typeParams` is dropped (its only meaningful content was `values`).
59
+ * - Any other `codecId` → rewritten as the `codec-instance` shape:
60
+ * `kind` prepended; `codecId`, `nativeType`, `typeParams` preserved.
61
+ * This is the safe default for unknown codec IDs (including any
62
+ * future extension-contributed codecs).
63
+ *
64
+ * The transformation re-serialises each affected file via
65
+ * `JSON.stringify(value, null, 2) + '\n'` — the same formatting the
66
+ * CLI uses when authoring snapshots originally, so the diff outside
67
+ * `storage.types` is zero on files the CLI generated. Hand-edited
68
+ * contract snapshots may experience cosmetic whitespace shifts; this
69
+ * is acceptable because on-disk contract snapshots are CLI-authored
70
+ * artefacts, not user-edited source.
71
+ *
72
+ * The codemod is idempotent: running it on already-stamped snapshots
73
+ * is a no-op (every entry passes the `kind`-already-present check).
74
+ *
75
+ * Flags:
76
+ * --check dry-run; lists affected files and exits 1 if any still
77
+ * need fixing.
78
+ */
79
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
80
+ import { join } from 'node:path';
81
+
82
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
83
+ const CONTRACT_FILES = new Set(['start-contract.json', 'end-contract.json']);
84
+
85
+ const POSTGRES_ENUM_CODEC_ID = 'pg/enum@1';
86
+
87
+ const dryRun = process.argv.includes('--check');
88
+ const projectRoot = process.cwd();
89
+
90
+ interface Result {
91
+ readonly path: string;
92
+ readonly status: 'already-clean' | 'needs-fix' | 'fixed';
93
+ readonly stamped: number;
94
+ }
95
+
96
+ async function findContractSnapshots(root: string): Promise<string[]> {
97
+ const out: string[] = [];
98
+
99
+ // The `inMigrations` flag confines snapshot rewrites to the
100
+ // `migrations/` subtree the doc promises. Without the flag the walk
101
+ // would happily stamp any `start-contract.json` / `end-contract.json`
102
+ // found anywhere under the project root, including non-migration
103
+ // fixtures (e.g. inline contract test snapshots).
104
+ async function walk(dir: string, inMigrations: boolean): Promise<void> {
105
+ let entries: Awaited<ReturnType<typeof readdir>>;
106
+ try {
107
+ entries = await readdir(dir, { withFileTypes: true });
108
+ } catch {
109
+ // Unreadable directory — skip silently. Mirrors the predecessor
110
+ // 0.8→0.9 codemod's failure-tolerant walk; the user's project
111
+ // root may legitimately contain restricted directories.
112
+ return;
113
+ }
114
+ for (const entry of entries) {
115
+ if (entry.isDirectory()) {
116
+ if (SKIP_DIRS.has(entry.name)) continue;
117
+ await walk(join(dir, entry.name), inMigrations || entry.name === 'migrations');
118
+ } else if (inMigrations && entry.isFile() && CONTRACT_FILES.has(entry.name)) {
119
+ out.push(join(dir, entry.name));
120
+ }
121
+ }
122
+ }
123
+
124
+ await walk(root, false);
125
+ return out.sort();
126
+ }
127
+
128
+ interface UntaggedCodecTriple {
129
+ readonly codecId: string;
130
+ readonly nativeType: string;
131
+ readonly typeParams: Record<string, unknown>;
132
+ }
133
+
134
+ interface StampedCodecInstance {
135
+ readonly kind: 'codec-instance';
136
+ readonly codecId: string;
137
+ readonly nativeType: string;
138
+ readonly typeParams: Record<string, unknown>;
139
+ }
140
+
141
+ interface StampedPostgresEnum {
142
+ readonly kind: 'postgres-enum';
143
+ readonly name: string;
144
+ readonly nativeType: string;
145
+ readonly values: readonly string[];
146
+ readonly codecId: string;
147
+ }
148
+
149
+ type StampedEntry = StampedCodecInstance | StampedPostgresEnum;
150
+
151
+ function isAlreadyStamped(value: unknown): boolean {
152
+ if (typeof value !== 'object' || value === null) return false;
153
+ const kind = (value as { kind?: unknown }).kind;
154
+ return kind === 'codec-instance' || kind === 'postgres-enum';
155
+ }
156
+
157
+ function looksLikeUntaggedCodecTriple(value: unknown): value is UntaggedCodecTriple {
158
+ if (typeof value !== 'object' || value === null) return false;
159
+ const obj = value as Record<string, unknown>;
160
+ if (
161
+ typeof obj['codecId'] !== 'string' ||
162
+ typeof obj['nativeType'] !== 'string' ||
163
+ typeof obj['typeParams'] !== 'object' ||
164
+ obj['typeParams'] === null
165
+ ) {
166
+ return false;
167
+ }
168
+ // A `pg/enum@1` triple is only recognisable as an untagged enum if its
169
+ // `typeParams.values` is already a string[]. Without this guard a
170
+ // malformed enum entry would slip through the classifier and surface
171
+ // a different (more specific) diagnostic from `stampEntry` than the
172
+ // outer "neither stamped nor untagged-triple — hand-edit required"
173
+ // throw. Folding that case into the predicate gives every malformed
174
+ // entry the same single diagnostic shape.
175
+ if (obj['codecId'] === POSTGRES_ENUM_CODEC_ID) {
176
+ const values = (obj['typeParams'] as { values?: unknown })['values'];
177
+ if (!Array.isArray(values) || !values.every((v) => typeof v === 'string')) {
178
+ return false;
179
+ }
180
+ }
181
+ return true;
182
+ }
183
+
184
+ function stampEntry(name: string, raw: UntaggedCodecTriple): StampedEntry {
185
+ if (raw.codecId === POSTGRES_ENUM_CODEC_ID) {
186
+ const values = (raw.typeParams as { values?: unknown })['values'];
187
+ // Invariant: `looksLikeUntaggedCodecTriple` already gated this — a
188
+ // `pg/enum@1` entry that reaches `stampEntry` has a string[]
189
+ // `typeParams.values`. The runtime check stays as a defensive
190
+ // marker so a future loosening of the predicate doesn't silently
191
+ // produce a malformed StampedPostgresEnum.
192
+ if (!Array.isArray(values) || !values.every((v) => typeof v === 'string')) {
193
+ throw new Error(
194
+ `invariant: storage.types[${JSON.stringify(name)}] reached stampEntry with codecId="${POSTGRES_ENUM_CODEC_ID}" but typeParams.values is not a string[]; the classifier should have rejected this entry`,
195
+ );
196
+ }
197
+ return {
198
+ kind: 'postgres-enum',
199
+ name,
200
+ nativeType: raw.nativeType,
201
+ values,
202
+ codecId: raw.codecId,
203
+ };
204
+ }
205
+ return {
206
+ kind: 'codec-instance',
207
+ codecId: raw.codecId,
208
+ nativeType: raw.nativeType,
209
+ typeParams: raw.typeParams,
210
+ };
211
+ }
212
+
213
+ interface ProcessOutcome {
214
+ readonly transformed: Record<string, unknown> | null;
215
+ readonly stamped: number;
216
+ }
217
+
218
+ function processContract(parsed: unknown, filePath: string): ProcessOutcome {
219
+ if (typeof parsed !== 'object' || parsed === null) {
220
+ return { transformed: null, stamped: 0 };
221
+ }
222
+ const root = parsed as Record<string, unknown>;
223
+ const storage = root['storage'];
224
+ if (typeof storage !== 'object' || storage === null) {
225
+ return { transformed: null, stamped: 0 };
226
+ }
227
+ const storageObj = storage as Record<string, unknown>;
228
+ const types = storageObj['types'];
229
+ if (typeof types !== 'object' || types === null) {
230
+ return { transformed: null, stamped: 0 };
231
+ }
232
+ const typesObj = types as Record<string, unknown>;
233
+
234
+ let stamped = 0;
235
+ const newTypes: Record<string, unknown> = {};
236
+ for (const [name, entry] of Object.entries(typesObj)) {
237
+ if (isAlreadyStamped(entry)) {
238
+ newTypes[name] = entry;
239
+ continue;
240
+ }
241
+ if (!looksLikeUntaggedCodecTriple(entry)) {
242
+ throw new Error(
243
+ `${filePath}: storage.types[${JSON.stringify(name)}] is neither a stamped entry nor an untagged codec triple — refusing to guess. Hand-edit required.`,
244
+ );
245
+ }
246
+ newTypes[name] = stampEntry(name, entry);
247
+ stamped += 1;
248
+ }
249
+
250
+ if (stamped === 0) return { transformed: null, stamped: 0 };
251
+
252
+ return {
253
+ transformed: { ...root, storage: { ...storageObj, types: newTypes } },
254
+ stamped,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Pretty-print JSON with multi-line objects/arrays (per
260
+ * `JSON.stringify(null, 2)` conventions) but inline arrays of primitives
261
+ * (strings, numbers, booleans, null) whose serialised length fits
262
+ * within `INLINE_ARRAY_THRESHOLD`. This matches the on-disk shape the
263
+ * CLI's contract-snapshot writer produces (e.g. `"columns": ["id"]`,
264
+ * `"values": ["admin", "user"]`) — the alternative (Node's default
265
+ * `JSON.stringify(null, 2)`) reflows every such array onto multiple
266
+ * lines and bloats the diff with hundreds of lines of cosmetic noise.
267
+ *
268
+ * The non-array, non-object surfaces match `JSON.stringify(null, 2)`
269
+ * byte-for-byte (same key ordering — insertion order — and same
270
+ * quoting/escaping). The only divergence is inline-primitive-arrays.
271
+ */
272
+ const INLINE_ARRAY_THRESHOLD = 80;
273
+
274
+ function isPrimitive(value: unknown): boolean {
275
+ return (
276
+ value === null ||
277
+ typeof value === 'string' ||
278
+ typeof value === 'number' ||
279
+ typeof value === 'boolean'
280
+ );
281
+ }
282
+
283
+ function formatJson(value: unknown, indentLevel = 0): string {
284
+ const indent = ' '.repeat(indentLevel);
285
+ const childIndent = ' '.repeat(indentLevel + 1);
286
+
287
+ if (value === null) return 'null';
288
+ if (typeof value === 'string') return JSON.stringify(value);
289
+ if (typeof value === 'number' || typeof value === 'boolean') return JSON.stringify(value);
290
+
291
+ if (Array.isArray(value)) {
292
+ if (value.length === 0) return '[]';
293
+ if (value.every(isPrimitive)) {
294
+ const inline = `[${value.map((v) => JSON.stringify(v)).join(', ')}]`;
295
+ if (inline.length <= INLINE_ARRAY_THRESHOLD) return inline;
296
+ }
297
+ const items = value.map((v) => `${childIndent}${formatJson(v, indentLevel + 1)}`);
298
+ return `[\n${items.join(',\n')}\n${indent}]`;
299
+ }
300
+
301
+ if (typeof value === 'object') {
302
+ const entries = Object.entries(value as Record<string, unknown>);
303
+ if (entries.length === 0) return '{}';
304
+ const lines = entries.map(
305
+ ([k, v]) => `${childIndent}${JSON.stringify(k)}: ${formatJson(v, indentLevel + 1)}`,
306
+ );
307
+ return `{\n${lines.join(',\n')}\n${indent}}`;
308
+ }
309
+
310
+ throw new Error(`Unsupported value: ${typeof value}`);
311
+ }
312
+
313
+ async function processFile(path: string): Promise<Result> {
314
+ const raw = await readFile(path, 'utf-8');
315
+ let parsed: unknown;
316
+ try {
317
+ parsed = JSON.parse(raw);
318
+ } catch (error) {
319
+ throw new Error(
320
+ `${path}: not valid JSON (${error instanceof Error ? error.message : String(error)})`,
321
+ );
322
+ }
323
+ const outcome = processContract(parsed, path);
324
+ if (outcome.transformed === null) {
325
+ return { path, status: 'already-clean', stamped: 0 };
326
+ }
327
+ const serialised = `${formatJson(outcome.transformed)}\n`;
328
+ if (!dryRun) await writeFile(path, serialised, 'utf-8');
329
+ return { path, status: dryRun ? 'needs-fix' : 'fixed', stamped: outcome.stamped };
330
+ }
331
+
332
+ const contracts = await findContractSnapshots(projectRoot);
333
+ if (contracts.length === 0) {
334
+ console.error(`No start-contract.json / end-contract.json files found under ${projectRoot}.`);
335
+ process.exit(1);
336
+ }
337
+
338
+ let changed = 0;
339
+ let alreadyClean = 0;
340
+ let totalStamped = 0;
341
+ for (const path of contracts) {
342
+ const result = await processFile(path);
343
+ const rel = path.slice(projectRoot.length + 1);
344
+ if (result.status === 'already-clean') {
345
+ alreadyClean += 1;
346
+ console.log(`OK ${rel} (already stamped or no storage.types)`);
347
+ } else {
348
+ changed += 1;
349
+ totalStamped += result.stamped;
350
+ const verb = dryRun ? 'WOULD FIX' : 'FIXED';
351
+ console.log(`${verb} ${rel} (stamped ${result.stamped} entry/entries)`);
352
+ }
353
+ }
354
+
355
+ console.log();
356
+ console.log(
357
+ `${contracts.length} snapshot(s) scanned: ${changed} ${dryRun ? 'needing fix' : 'fixed'} (${totalStamped} entries), ${alreadyClean} already clean.`,
358
+ );
359
+
360
+ if (dryRun && changed > 0) process.exit(1);