@prisma/orm-mongo 8.0.0-rc.22-dev.14 → 8.0.0-rc.23

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.
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ // Names every index of a MongoDB contract written before contracts recorded index names.
3
+ // A contract snapshot's index takes the name its own project's emitted contract gives the index with the same
4
+ // collection, keys and options. A snapshot's project is the nearest folder, going up from the folder that holds
5
+ // its `migrations` folder, that holds an emitted contract (a MongoDB contract outside every `snapshots` folder).
6
+ // The walk stops at the first folder with a `package.json`, the project root, so no name comes from another project.
7
+ // When no emitted contract of the project has the index, or two of them name it differently, the index gets the
8
+ // name earlier releases gave it in the database, its keys and directions such as `email_1`, and the script says so.
9
+ // Usage: node mongo-index-names.ts [<root>]
10
+
11
+ import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join, relative, resolve, sep } from 'node:path';
13
+
14
+ const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build']);
15
+ const TEMPORARY_SUFFIX = '.mongo-index-names-tmp';
16
+
17
+ type JsonObject = { [key: string]: unknown };
18
+
19
+ function isObject(value: unknown): value is JsonObject {
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ function walk(dir: string, found: string[], links: string[]): void {
24
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
25
+ const path = join(dir, entry.name);
26
+ if (entry.isDirectory()) {
27
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(path, found, links);
28
+ continue;
29
+ }
30
+ if (entry.isSymbolicLink()) {
31
+ links.push(path);
32
+ continue;
33
+ }
34
+ if (!entry.isFile()) continue;
35
+ if (entry.name.endsWith(TEMPORARY_SUFFIX)) {
36
+ rmSync(path);
37
+ continue;
38
+ }
39
+ if (entry.name.endsWith('.json')) found.push(path);
40
+ }
41
+ }
42
+
43
+ function isUnnamedIndex(value: unknown): value is JsonObject {
44
+ return (
45
+ isObject(value) &&
46
+ value['kind'] === 'mongo-index' &&
47
+ Array.isArray(value['keys']) &&
48
+ !('name' in value)
49
+ );
50
+ }
51
+
52
+ function defaultName(keys: unknown): string {
53
+ return (Array.isArray(keys) ? keys : [])
54
+ .map((key) => (isObject(key) ? `${String(key['field'])}_${String(key['direction'])}` : ''))
55
+ .join('_');
56
+ }
57
+
58
+ /** The index with its name, its keys in sorted order as `contract emit` writes them. */
59
+ function named(index: JsonObject, name: string): JsonObject {
60
+ const entries = [...Object.entries(index), ['name', name] as const];
61
+ return Object.fromEntries(entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
62
+ }
63
+
64
+ /** An index's collection, keys and options, as a string: what the database tells two indexes apart by. */
65
+ function contentKey(collection: string, index: JsonObject): string {
66
+ const { name: _name, kind: _kind, ...content } = index;
67
+ return canonicalJson([collection, content]);
68
+ }
69
+
70
+ function canonicalJson(value: unknown): string {
71
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
72
+ if (!isObject(value)) return JSON.stringify(value);
73
+ const entries = Object.keys(value)
74
+ .sort()
75
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
76
+ return `{${entries.join(',')}}`;
77
+ }
78
+
79
+ /** Each index of a contract, with the collection it belongs to. */
80
+ function indexesOf(
81
+ value: unknown,
82
+ found: { readonly collection: string; readonly index: JsonObject }[],
83
+ collection = '',
84
+ ): void {
85
+ if (Array.isArray(value)) {
86
+ for (const item of value) indexesOf(item, found, collection);
87
+ return;
88
+ }
89
+ if (!isObject(value)) return;
90
+ if (value['kind'] === 'mongo-index') {
91
+ found.push({ collection, index: value });
92
+ return;
93
+ }
94
+ for (const [key, item] of Object.entries(value)) {
95
+ if (key === 'collection' && isObject(item)) {
96
+ for (const [name, entry] of Object.entries(item)) indexesOf(entry, found, name);
97
+ } else {
98
+ indexesOf(item, found, collection);
99
+ }
100
+ }
101
+ }
102
+
103
+ /** The names emitted contracts give indexes, by collection and content; each name is listed once. */
104
+ type EmittedNames = Map<string, readonly string[]>;
105
+
106
+ function recordEmittedNames(contract: JsonObject, emitted: EmittedNames): void {
107
+ const found: { readonly collection: string; readonly index: JsonObject }[] = [];
108
+ indexesOf(contract, found);
109
+ for (const { collection, index } of found) {
110
+ const name = index['name'];
111
+ if (typeof name !== 'string') continue;
112
+ const key = contentKey(collection, index);
113
+ const names = emitted.get(key) ?? [];
114
+ if (!names.includes(name)) emitted.set(key, [...names, name]);
115
+ }
116
+ }
117
+
118
+ type NameFor = (collection: string, index: JsonObject) => string;
119
+
120
+ /** The value with each unnamed index named, and the names given, in the order the JSON lists the indexes. */
121
+ function nameIndexes(value: unknown, names: string[], nameFor: NameFor, collection = ''): unknown {
122
+ if (Array.isArray(value))
123
+ return value.map((item) => nameIndexes(item, names, nameFor, collection));
124
+ if (!isObject(value)) return value;
125
+ if (isUnnamedIndex(value)) {
126
+ const name = nameFor(collection, value);
127
+ names.push(name);
128
+ return named(value, name);
129
+ }
130
+ return Object.fromEntries(
131
+ Object.entries(value).map(([key, item]) => [
132
+ key,
133
+ key === 'collection' && isObject(item)
134
+ ? Object.fromEntries(
135
+ Object.entries(item).map(([name, entry]) => [
136
+ name,
137
+ nameIndexes(entry, names, nameFor, name),
138
+ ]),
139
+ )
140
+ : nameIndexes(item, names, nameFor, collection),
141
+ ]),
142
+ );
143
+ }
144
+
145
+ function hasUnnamedIndex(contract: JsonObject): boolean {
146
+ const found: { readonly collection: string; readonly index: JsonObject }[] = [];
147
+ indexesOf(contract, found);
148
+ return found.some(({ index }) => !('name' in index));
149
+ }
150
+
151
+ function sortKeys(value: unknown): unknown {
152
+ if (Array.isArray(value)) return value.map(sortKeys);
153
+ if (!isObject(value)) return value;
154
+ return Object.fromEntries(
155
+ Object.keys(value)
156
+ .sort()
157
+ .map((key) => [key, sortKeys(value[key])]),
158
+ );
159
+ }
160
+
161
+ /** The contract in the layout it had: one line with sorted keys, as snapshots are written, or indented by two. */
162
+ function serializeLike(original: string, value: unknown): string {
163
+ const trailingNewline = original.endsWith('\n') ? '\n' : '';
164
+ const singleLine = !original.trimEnd().includes('\n');
165
+ return `${singleLine ? JSON.stringify(sortKeys(value)) : JSON.stringify(value, null, 2)}${trailingNewline}`;
166
+ }
167
+
168
+ const INDEX_KIND = /readonly kind: (['"])mongo-index\1;?(\n[ \t]*)?/g;
169
+
170
+ /** The declarations with `readonly name` added after each index's `readonly kind`, or undefined when their indexes are not the JSON's. */
171
+ function nameIndexTypes(declarations: string, names: readonly string[]): string | undefined {
172
+ const matches = [...declarations.matchAll(INDEX_KIND)];
173
+ if (matches.length !== names.length) return undefined;
174
+ let rewritten = '';
175
+ let from = 0;
176
+ matches.forEach((match, position) => {
177
+ const quote = match[1];
178
+ const name = `readonly name: ${quote}${names[position]}${quote}`;
179
+ const lineBreak = match[2];
180
+ const kindEnd = match.index + match[0].length - (lineBreak?.length ?? 0);
181
+ if (lineBreak === undefined) {
182
+ const kind = declarations.slice(match.index, kindEnd).replace(/;$/, '');
183
+ rewritten += `${declarations.slice(from, match.index)}${kind}; ${name}${match[0].endsWith(';') ? ';' : ''}`;
184
+ } else {
185
+ const indent = declarations.slice(
186
+ declarations.lastIndexOf('\n', match.index) + 1,
187
+ match.index,
188
+ );
189
+ rewritten += `${declarations.slice(from, kindEnd)}\n${indent}${name};`;
190
+ }
191
+ from = kindEnd;
192
+ });
193
+ return rewritten + declarations.slice(from);
194
+ }
195
+
196
+ function writeFile(path: string, content: string): void {
197
+ const temporary = `${path}${TEMPORARY_SUFFIX}`;
198
+ writeFileSync(temporary, content);
199
+ renameSync(temporary, path);
200
+ }
201
+
202
+ function isSnapshot(path: string): boolean {
203
+ return path.split(sep).includes('snapshots');
204
+ }
205
+
206
+ /** The folder a snapshot's project is in: the one that holds the `migrations` folder its `snapshots` folder is in. */
207
+ function migrationsParentOf(snapshotPath: string): string {
208
+ const parts = snapshotPath.split(sep);
209
+ const snapshots = parts.lastIndexOf('snapshots');
210
+ return parts.slice(0, Math.max(snapshots - 1, 1)).join(sep) || sep;
211
+ }
212
+
213
+ function keysText(index: JsonObject): string {
214
+ const keys = Array.isArray(index['keys']) ? index['keys'] : [];
215
+ return keys
216
+ .map((key) => (isObject(key) ? `${String(key['field'])}: ${String(key['direction'])}` : ''))
217
+ .join(', ');
218
+ }
219
+
220
+ function main(): number {
221
+ const root = resolve(process.argv[2] ?? '.');
222
+ const files: string[] = [];
223
+ const links: string[] = [];
224
+ walk(root, files, links);
225
+ const contracts: {
226
+ readonly path: string;
227
+ readonly original: string;
228
+ readonly contract: JsonObject;
229
+ }[] = [];
230
+ for (const path of files.sort()) {
231
+ const original = readFileSync(path, 'utf8');
232
+ let contract: unknown;
233
+ try {
234
+ contract = JSON.parse(original);
235
+ } catch {
236
+ continue;
237
+ }
238
+ if (isObject(contract) && contract['targetFamily'] === 'mongo') {
239
+ contracts.push({ path, original, contract });
240
+ }
241
+ }
242
+ const emittedContracts = contracts.filter(
243
+ ({ path, contract }) => !isSnapshot(path) && !hasUnnamedIndex(contract),
244
+ );
245
+
246
+ /** The names the emitted contracts of the nearest folder up from `start` that holds any give their indexes, going no higher than the first folder with a `package.json`. */
247
+ const projectNames = (
248
+ start: string,
249
+ ): { readonly folder: string; readonly names: EmittedNames } => {
250
+ for (let folder = start; ; folder = dirname(folder)) {
251
+ const inFolder = emittedContracts.filter(({ path }) => path.startsWith(`${folder}${sep}`));
252
+ const projectRoot = existsSync(join(folder, 'package.json'));
253
+ if (inFolder.length > 0 || projectRoot || folder === root || folder === dirname(folder)) {
254
+ const names: EmittedNames = new Map();
255
+ for (const { contract } of inFolder) recordEmittedNames(contract, names);
256
+ return { folder, names };
257
+ }
258
+ }
259
+ };
260
+
261
+ const changed: string[] = [];
262
+ const notes: string[] = [];
263
+ const warnings: string[] = links.map(
264
+ (link) =>
265
+ `${relative(root, link)}: is a symbolic link, so the script did not read it; if it holds migration snapshots, run the script on the folder it points to.`,
266
+ );
267
+ for (const { path, original, contract } of contracts) {
268
+ const project = projectNames(isSnapshot(path) ? migrationsParentOf(path) : dirname(path));
269
+ const nameFor: NameFor = (collection, index) => {
270
+ const fallback = defaultName(index['keys']);
271
+ const emitted = project.names.get(contentKey(collection, index)) ?? [];
272
+ const [only] = emitted;
273
+ if (emitted.length === 1 && only !== undefined) return only;
274
+ const where = `${relative(root, path)}: the index on { ${keysText(index)} } in collection "${collection}"`;
275
+ notes.push(
276
+ emitted.length === 0
277
+ ? `${where} is in no emitted contract under ${relative(root, project.folder) || '.'}; named ${fallback} after its keys.`
278
+ : `${where} is named ${emitted.join(' and ')} by the emitted contracts under ${relative(root, project.folder) || '.'}; named ${fallback} after its keys.`,
279
+ );
280
+ return fallback;
281
+ };
282
+ const names: string[] = [];
283
+ const rewritten = nameIndexes(contract, names, nameFor);
284
+ if (names.length === 0) continue;
285
+ writeFile(path, serializeLike(original, rewritten));
286
+ changed.push(path);
287
+
288
+ const declarationsPath = path.replace(/\.json$/, '.d.ts');
289
+ let declarations: string;
290
+ try {
291
+ declarations = readFileSync(declarationsPath, 'utf8');
292
+ } catch {
293
+ continue;
294
+ }
295
+ const namedDeclarations = nameIndexTypes(declarations, names);
296
+ if (namedDeclarations === undefined) {
297
+ warnings.push(
298
+ `${relative(root, declarationsPath)}: could not match its index types with ${relative(root, path)}. Add readonly name: '<name>' to each index type by hand.`,
299
+ );
300
+ continue;
301
+ }
302
+ writeFile(declarationsPath, namedDeclarations);
303
+ changed.push(declarationsPath);
304
+ }
305
+
306
+ for (const path of changed) console.log(relative(root, path));
307
+ for (const note of notes) console.log(note);
308
+ for (const warning of warnings) console.error(warning);
309
+ console.log(
310
+ changed.length === 0
311
+ ? 'Nothing to change.'
312
+ : `Rewrote ${changed.length} file${changed.length === 1 ? '' : 's'}.`,
313
+ );
314
+ return warnings.length === 0 ? 0 : 1;
315
+ }
316
+
317
+ process.exitCode = main();