@prisma-next/target-mongo 0.4.0-dev.9 → 0.4.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.
@@ -1,13 +1,6 @@
1
1
  import { detectScaffoldRuntime, shebangLineFor } from '@prisma-next/migration-tools/migration-ts';
2
- import type {
3
- CollModCall,
4
- CreateCollectionCall,
5
- CreateIndexCall,
6
- DropCollectionCall,
7
- DropIndexCall,
8
- OpFactoryCall,
9
- OpFactoryCallVisitor,
10
- } from './op-factory-call';
2
+ import { type ImportRequirement, jsonToTsSource, renderImports } from '@prisma-next/ts-render';
3
+ import type { OpFactoryCall } from './op-factory-call';
11
4
 
12
5
  export interface RenderMigrationMeta {
13
6
  readonly from: string;
@@ -17,21 +10,49 @@ export interface RenderMigrationMeta {
17
10
  }
18
11
 
19
12
  /**
20
- * Render a list of Mongo `OpFactoryCall`s as a class-flow `migration.ts`
13
+ * Always-present base imports for the rendered scaffold:
14
+ *
15
+ * - `Migration` from `@prisma-next/family-mongo/migration` — the
16
+ * user-facing Mongo `Migration` base; subclasses don't need to
17
+ * redeclare `targetId` or thread family/target generics.
18
+ * - `MigrationCLI` from `@prisma-next/cli/migration-cli` — the
19
+ * migration-file CLI entrypoint that loads `prisma-next.config.ts`,
20
+ * assembles a `ControlStack`, and instantiates the migration class.
21
+ * The migration file owns this dependency directly: pulling CLI
22
+ * machinery in at script run time is acceptable because the script's
23
+ * whole purpose is to be invoked from the project that owns the
24
+ * config. (Mirrors the postgres facade pattern; pulling `MigrationCLI`
25
+ * into `@prisma-next/family-mongo/migration` so a Mongo migration only
26
+ * needs one import is tracked separately as a follow-up.)
27
+ */
28
+ const BASE_IMPORTS: readonly ImportRequirement[] = [
29
+ { moduleSpecifier: '@prisma-next/family-mongo/migration', symbol: 'Migration' },
30
+ { moduleSpecifier: '@prisma-next/cli/migration-cli', symbol: 'MigrationCLI' },
31
+ ];
32
+
33
+ /**
34
+ * Render a list of Mongo `OpFactoryCall`s as a `migration.ts`
21
35
  * source string. The result is shebanged, extends the user-facing
22
36
  * `Migration` (i.e. `MongoMigration`) from `@prisma-next/family-mongo`, and
23
37
  * implements the abstract `operations` and `describe` members. `meta` is
24
38
  * always rendered — `describe()` is part of the `Migration` contract, so
25
39
  * even an empty stub must satisfy it; callers pass empty strings for a
26
40
  * migration-new scaffold.
41
+ *
42
+ * The walk is polymorphic: each call node contributes its own
43
+ * `renderTypeScript()` expression and declares its own
44
+ * `importRequirements()`. The top-level renderer aggregates imports
45
+ * across all nodes and emits one `import { … } from "…"` line per module.
46
+ * The `Migration` and `MigrationCLI` imports are always emitted — they're
47
+ * structural to the rendered scaffold (extends `Migration`, calls
48
+ * `MigrationCLI.run`), not driven by any node.
27
49
  */
28
50
  export function renderCallsToTypeScript(
29
51
  calls: ReadonlyArray<OpFactoryCall>,
30
52
  meta: RenderMigrationMeta,
31
53
  ): string {
32
- const factoryNames = collectFactoryNames(calls);
33
- const imports = buildImports(factoryNames);
34
- const operationsBody = calls.map((c) => c.accept(renderCallVisitor)).join(',\n');
54
+ const imports = buildImports(calls);
55
+ const operationsBody = calls.map((c) => c.renderTypeScript()).join(',\n');
35
56
 
36
57
  return [
37
58
  shebangLineFor(detectScaffoldRuntime()),
@@ -47,25 +68,19 @@ export function renderCallsToTypeScript(
47
68
  '}',
48
69
  '',
49
70
  'export default M;',
50
- 'Migration.run(import.meta.url, M);',
71
+ 'MigrationCLI.run(import.meta.url, M);',
51
72
  '',
52
73
  ].join('\n');
53
74
  }
54
75
 
55
- function collectFactoryNames(calls: ReadonlyArray<OpFactoryCall>): string[] {
56
- const names = new Set<string>();
76
+ function buildImports(calls: ReadonlyArray<OpFactoryCall>): string {
77
+ const requirements: ImportRequirement[] = [...BASE_IMPORTS];
57
78
  for (const call of calls) {
58
- names.add(call.factory);
79
+ for (const req of call.importRequirements()) {
80
+ requirements.push(req);
81
+ }
59
82
  }
60
- return [...names].sort();
61
- }
62
-
63
- function buildImports(factoryNames: string[]): string {
64
- const lines = ["import { Migration } from '@prisma-next/family-mongo/migration';"];
65
- if (factoryNames.length > 0) {
66
- lines.push(`import { ${factoryNames.join(', ')} } from '@prisma-next/target-mongo/migration';`);
67
- }
68
- return lines.join('\n');
83
+ return renderImports(requirements);
69
84
  }
70
85
 
71
86
  function buildDescribeMethod(meta: RenderMigrationMeta): string {
@@ -78,7 +93,7 @@ function buildDescribeMethod(meta: RenderMigrationMeta): string {
78
93
  lines.push(` kind: ${JSON.stringify(meta.kind)},`);
79
94
  }
80
95
  if (meta.labels && meta.labels.length > 0) {
81
- lines.push(` labels: ${renderLiteral(meta.labels)},`);
96
+ lines.push(` labels: ${jsonToTsSource(meta.labels)},`);
82
97
  }
83
98
  lines.push(' };');
84
99
  lines.push(' }');
@@ -86,58 +101,6 @@ function buildDescribeMethod(meta: RenderMigrationMeta): string {
86
101
  return lines.join('\n');
87
102
  }
88
103
 
89
- const renderCallVisitor: OpFactoryCallVisitor<string> = {
90
- createIndex(call: CreateIndexCall) {
91
- return call.options
92
- ? `createIndex(${renderLiteral(call.collection)}, ${renderLiteral(call.keys)}, ${renderLiteral(call.options)})`
93
- : `createIndex(${renderLiteral(call.collection)}, ${renderLiteral(call.keys)})`;
94
- },
95
- dropIndex(call: DropIndexCall) {
96
- return `dropIndex(${renderLiteral(call.collection)}, ${renderLiteral(call.keys)})`;
97
- },
98
- createCollection(call: CreateCollectionCall) {
99
- return call.options
100
- ? `createCollection(${renderLiteral(call.collection)}, ${renderLiteral(call.options)})`
101
- : `createCollection(${renderLiteral(call.collection)})`;
102
- },
103
- dropCollection(call: DropCollectionCall) {
104
- return `dropCollection(${renderLiteral(call.collection)})`;
105
- },
106
- collMod(call: CollModCall) {
107
- return call.meta
108
- ? `collMod(${renderLiteral(call.collection)}, ${renderLiteral(call.options)}, ${renderLiteral(call.meta)})`
109
- : `collMod(${renderLiteral(call.collection)}, ${renderLiteral(call.options)})`;
110
- },
111
- };
112
-
113
- function renderLiteral(value: unknown): string {
114
- if (value === undefined) return 'undefined';
115
- if (value === null) return 'null';
116
- if (typeof value === 'string') return JSON.stringify(value);
117
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
118
- if (Array.isArray(value)) {
119
- if (value.length === 0) return '[]';
120
- const items = value.map((v) => renderLiteral(v));
121
- const singleLine = `[${items.join(', ')}]`;
122
- if (singleLine.length <= 80) return singleLine;
123
- return `[\n${items.map((i) => ` ${i}`).join(',\n')},\n]`;
124
- }
125
- if (typeof value === 'object') {
126
- const entries = Object.entries(value).filter(([, v]) => v !== undefined);
127
- if (entries.length === 0) return '{}';
128
- const items = entries.map(([k, v]) => `${renderKey(k)}: ${renderLiteral(v)}`);
129
- const singleLine = `{ ${items.join(', ')} }`;
130
- if (singleLine.length <= 80) return singleLine;
131
- return `{\n${items.map((i) => ` ${i}`).join(',\n')},\n}`;
132
- }
133
- return String(value);
134
- }
135
-
136
- function renderKey(key: string): string {
137
- if (key === '__proto__') return JSON.stringify(key);
138
- return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
139
- }
140
-
141
104
  function indent(text: string, spaces: number): string {
142
105
  const pad = ' '.repeat(spaces);
143
106
  return text
@@ -0,0 +1,402 @@
1
+ import type {
2
+ SchemaIssue,
3
+ SchemaVerificationNode,
4
+ } from '@prisma-next/framework-components/control';
5
+ import type {
6
+ MongoSchemaCollection,
7
+ MongoSchemaIndex,
8
+ MongoSchemaIR,
9
+ } from '@prisma-next/mongo-schema-ir';
10
+ import { canonicalize, deepEqual } from '@prisma-next/mongo-schema-ir';
11
+
12
+ export function diffMongoSchemas(
13
+ live: MongoSchemaIR,
14
+ expected: MongoSchemaIR,
15
+ strict: boolean,
16
+ ): {
17
+ root: SchemaVerificationNode;
18
+ issues: SchemaIssue[];
19
+ counts: { pass: number; warn: number; fail: number; totalNodes: number };
20
+ } {
21
+ const issues: SchemaIssue[] = [];
22
+ const collectionChildren: SchemaVerificationNode[] = [];
23
+ let pass = 0;
24
+ let warn = 0;
25
+ let fail = 0;
26
+
27
+ const allNames = new Set([...live.collectionNames, ...expected.collectionNames]);
28
+
29
+ for (const name of [...allNames].sort()) {
30
+ const liveColl = live.collection(name);
31
+ const expectedColl = expected.collection(name);
32
+
33
+ if (!liveColl && expectedColl) {
34
+ issues.push({
35
+ kind: 'missing_table',
36
+ table: name,
37
+ message: `Collection "${name}" is missing from the database`,
38
+ });
39
+ collectionChildren.push({
40
+ status: 'fail',
41
+ kind: 'collection',
42
+ name,
43
+ contractPath: `storage.collections.${name}`,
44
+ code: 'MISSING_COLLECTION',
45
+ message: `Collection "${name}" is missing`,
46
+ expected: name,
47
+ actual: null,
48
+ children: [],
49
+ });
50
+ fail++;
51
+ continue;
52
+ }
53
+
54
+ if (liveColl && !expectedColl) {
55
+ const status = strict ? 'fail' : 'warn';
56
+ issues.push({
57
+ kind: 'extra_table',
58
+ table: name,
59
+ message: `Extra collection "${name}" exists in the database but not in the contract`,
60
+ });
61
+ collectionChildren.push({
62
+ status,
63
+ kind: 'collection',
64
+ name,
65
+ contractPath: `storage.collections.${name}`,
66
+ code: 'EXTRA_COLLECTION',
67
+ message: `Extra collection "${name}" found`,
68
+ expected: null,
69
+ actual: name,
70
+ children: [],
71
+ });
72
+ if (status === 'fail') fail++;
73
+ else warn++;
74
+ continue;
75
+ }
76
+
77
+ const lc = liveColl as MongoSchemaCollection;
78
+ const ec = expectedColl as MongoSchemaCollection;
79
+ const indexChildren = diffIndexes(name, lc, ec, strict, issues);
80
+ const validatorChildren = diffValidator(name, lc, ec, strict, issues);
81
+ const optionsChildren = diffOptions(name, lc, ec, strict, issues);
82
+ const children = [...indexChildren, ...validatorChildren, ...optionsChildren];
83
+
84
+ const worstStatus = children.reduce<'pass' | 'warn' | 'fail'>(
85
+ (s, c) => (c.status === 'fail' ? 'fail' : c.status === 'warn' && s !== 'fail' ? 'warn' : s),
86
+ 'pass',
87
+ );
88
+
89
+ for (const c of children) {
90
+ if (c.status === 'pass') pass++;
91
+ else if (c.status === 'warn') warn++;
92
+ else fail++;
93
+ }
94
+
95
+ if (children.length === 0) {
96
+ pass++;
97
+ }
98
+
99
+ collectionChildren.push({
100
+ status: worstStatus,
101
+ kind: 'collection',
102
+ name,
103
+ contractPath: `storage.collections.${name}`,
104
+ code: worstStatus === 'pass' ? 'MATCH' : 'DRIFT',
105
+ message:
106
+ worstStatus === 'pass' ? `Collection "${name}" matches` : `Collection "${name}" has drift`,
107
+ expected: name,
108
+ actual: name,
109
+ children,
110
+ });
111
+ }
112
+
113
+ const rootStatus = fail > 0 ? 'fail' : warn > 0 ? 'warn' : 'pass';
114
+ const totalNodes = pass + warn + fail + collectionChildren.length;
115
+
116
+ const root: SchemaVerificationNode = {
117
+ status: rootStatus,
118
+ kind: 'root',
119
+ name: 'mongo-schema',
120
+ contractPath: 'storage',
121
+ code: rootStatus === 'pass' ? 'MATCH' : 'DRIFT',
122
+ message: rootStatus === 'pass' ? 'Schema matches' : 'Schema has drift',
123
+ expected: null,
124
+ actual: null,
125
+ children: collectionChildren,
126
+ };
127
+
128
+ return { root, issues, counts: { pass, warn, fail, totalNodes } };
129
+ }
130
+
131
+ function buildIndexLookupKey(index: MongoSchemaIndex): string {
132
+ const keys = index.keys.map((k) => `${k.field}:${k.direction}`).join(',');
133
+ const opts = [
134
+ index.unique ? 'unique' : '',
135
+ index.sparse ? 'sparse' : '',
136
+ index.expireAfterSeconds != null ? `ttl:${index.expireAfterSeconds}` : '',
137
+ index.partialFilterExpression ? `pfe:${canonicalize(index.partialFilterExpression)}` : '',
138
+ index.wildcardProjection ? `wp:${canonicalize(index.wildcardProjection)}` : '',
139
+ index.collation ? `col:${canonicalize(index.collation)}` : '',
140
+ index.weights ? `wt:${canonicalize(index.weights)}` : '',
141
+ index.default_language ? `dl:${index.default_language}` : '',
142
+ index.language_override ? `lo:${index.language_override}` : '',
143
+ ]
144
+ .filter(Boolean)
145
+ .join(';');
146
+ return opts ? `${keys}|${opts}` : keys;
147
+ }
148
+
149
+ function formatIndexName(index: MongoSchemaIndex): string {
150
+ return index.keys.map((k) => `${k.field}:${k.direction}`).join(', ');
151
+ }
152
+
153
+ function diffIndexes(
154
+ collName: string,
155
+ live: MongoSchemaCollection,
156
+ expected: MongoSchemaCollection,
157
+ strict: boolean,
158
+ issues: SchemaIssue[],
159
+ ): SchemaVerificationNode[] {
160
+ const nodes: SchemaVerificationNode[] = [];
161
+ const liveLookup = new Map<string, MongoSchemaIndex>();
162
+ for (const idx of live.indexes) liveLookup.set(buildIndexLookupKey(idx), idx);
163
+
164
+ const expectedLookup = new Map<string, MongoSchemaIndex>();
165
+ for (const idx of expected.indexes) expectedLookup.set(buildIndexLookupKey(idx), idx);
166
+
167
+ for (const [key, idx] of expectedLookup) {
168
+ if (liveLookup.has(key)) {
169
+ nodes.push({
170
+ status: 'pass',
171
+ kind: 'index',
172
+ name: formatIndexName(idx),
173
+ contractPath: `storage.collections.${collName}.indexes`,
174
+ code: 'MATCH',
175
+ message: `Index ${formatIndexName(idx)} matches`,
176
+ expected: key,
177
+ actual: key,
178
+ children: [],
179
+ });
180
+ } else {
181
+ issues.push({
182
+ kind: 'index_mismatch',
183
+ table: collName,
184
+ indexOrConstraint: formatIndexName(idx),
185
+ message: `Index ${formatIndexName(idx)} missing on collection "${collName}"`,
186
+ });
187
+ nodes.push({
188
+ status: 'fail',
189
+ kind: 'index',
190
+ name: formatIndexName(idx),
191
+ contractPath: `storage.collections.${collName}.indexes`,
192
+ code: 'MISSING_INDEX',
193
+ message: `Index ${formatIndexName(idx)} missing`,
194
+ expected: key,
195
+ actual: null,
196
+ children: [],
197
+ });
198
+ }
199
+ }
200
+
201
+ for (const [key, idx] of liveLookup) {
202
+ if (!expectedLookup.has(key)) {
203
+ const status = strict ? 'fail' : 'warn';
204
+ issues.push({
205
+ kind: 'extra_index',
206
+ table: collName,
207
+ indexOrConstraint: formatIndexName(idx),
208
+ message: `Extra index ${formatIndexName(idx)} on collection "${collName}"`,
209
+ });
210
+ nodes.push({
211
+ status,
212
+ kind: 'index',
213
+ name: formatIndexName(idx),
214
+ contractPath: `storage.collections.${collName}.indexes`,
215
+ code: 'EXTRA_INDEX',
216
+ message: `Extra index ${formatIndexName(idx)}`,
217
+ expected: null,
218
+ actual: key,
219
+ children: [],
220
+ });
221
+ }
222
+ }
223
+
224
+ return nodes;
225
+ }
226
+
227
+ function diffValidator(
228
+ collName: string,
229
+ live: MongoSchemaCollection,
230
+ expected: MongoSchemaCollection,
231
+ strict: boolean,
232
+ issues: SchemaIssue[],
233
+ ): SchemaVerificationNode[] {
234
+ if (!live.validator && !expected.validator) return [];
235
+
236
+ if (expected.validator && !live.validator) {
237
+ issues.push({
238
+ kind: 'type_missing',
239
+ table: collName,
240
+ message: `Validator missing on collection "${collName}"`,
241
+ });
242
+ return [
243
+ {
244
+ status: 'fail',
245
+ kind: 'validator',
246
+ name: 'validator',
247
+ contractPath: `storage.collections.${collName}.validator`,
248
+ code: 'MISSING_VALIDATOR',
249
+ message: 'Validator missing',
250
+ expected: canonicalize(expected.validator.jsonSchema),
251
+ actual: null,
252
+ children: [],
253
+ },
254
+ ];
255
+ }
256
+
257
+ if (!expected.validator && live.validator) {
258
+ const status = strict ? 'fail' : 'warn';
259
+ issues.push({
260
+ kind: 'extra_validator',
261
+ table: collName,
262
+ message: `Extra validator on collection "${collName}"`,
263
+ });
264
+ return [
265
+ {
266
+ status,
267
+ kind: 'validator',
268
+ name: 'validator',
269
+ contractPath: `storage.collections.${collName}.validator`,
270
+ code: 'EXTRA_VALIDATOR',
271
+ message: 'Extra validator found',
272
+ expected: null,
273
+ actual: canonicalize(live.validator.jsonSchema),
274
+ children: [],
275
+ },
276
+ ];
277
+ }
278
+
279
+ const liveVal = live.validator as NonNullable<typeof live.validator>;
280
+ const expectedVal = expected.validator as NonNullable<typeof expected.validator>;
281
+ const liveSchema = canonicalize(liveVal.jsonSchema);
282
+ const expectedSchema = canonicalize(expectedVal.jsonSchema);
283
+
284
+ if (
285
+ liveSchema !== expectedSchema ||
286
+ liveVal.validationLevel !== expectedVal.validationLevel ||
287
+ liveVal.validationAction !== expectedVal.validationAction
288
+ ) {
289
+ issues.push({
290
+ kind: 'type_mismatch',
291
+ table: collName,
292
+ expected: expectedSchema,
293
+ actual: liveSchema,
294
+ message: `Validator mismatch on collection "${collName}"`,
295
+ });
296
+ return [
297
+ {
298
+ status: 'fail',
299
+ kind: 'validator',
300
+ name: 'validator',
301
+ contractPath: `storage.collections.${collName}.validator`,
302
+ code: 'VALIDATOR_MISMATCH',
303
+ message: 'Validator mismatch',
304
+ expected: {
305
+ jsonSchema: expectedVal.jsonSchema,
306
+ validationLevel: expectedVal.validationLevel,
307
+ validationAction: expectedVal.validationAction,
308
+ },
309
+ actual: {
310
+ jsonSchema: liveVal.jsonSchema,
311
+ validationLevel: liveVal.validationLevel,
312
+ validationAction: liveVal.validationAction,
313
+ },
314
+ children: [],
315
+ },
316
+ ];
317
+ }
318
+
319
+ return [
320
+ {
321
+ status: 'pass',
322
+ kind: 'validator',
323
+ name: 'validator',
324
+ contractPath: `storage.collections.${collName}.validator`,
325
+ code: 'MATCH',
326
+ message: 'Validator matches',
327
+ expected: expectedSchema,
328
+ actual: liveSchema,
329
+ children: [],
330
+ },
331
+ ];
332
+ }
333
+
334
+ function diffOptions(
335
+ collName: string,
336
+ live: MongoSchemaCollection,
337
+ expected: MongoSchemaCollection,
338
+ strict: boolean,
339
+ issues: SchemaIssue[],
340
+ ): SchemaVerificationNode[] {
341
+ if (!live.options && !expected.options) return [];
342
+
343
+ if (!expected.options && live.options) {
344
+ const status = strict ? 'fail' : 'warn';
345
+ issues.push({
346
+ kind: 'type_mismatch',
347
+ table: collName,
348
+ actual: canonicalize(live.options),
349
+ message: `Extra collection options on "${collName}"`,
350
+ });
351
+ return [
352
+ {
353
+ status,
354
+ kind: 'options',
355
+ name: 'options',
356
+ contractPath: `storage.collections.${collName}.options`,
357
+ code: 'EXTRA_OPTIONS',
358
+ message: 'Extra collection options found',
359
+ expected: null,
360
+ actual: live.options,
361
+ children: [],
362
+ },
363
+ ];
364
+ }
365
+
366
+ if (deepEqual(live.options, expected.options)) {
367
+ return [
368
+ {
369
+ status: 'pass',
370
+ kind: 'options',
371
+ name: 'options',
372
+ contractPath: `storage.collections.${collName}.options`,
373
+ code: 'MATCH',
374
+ message: 'Collection options match',
375
+ expected: canonicalize(expected.options),
376
+ actual: canonicalize(live.options),
377
+ children: [],
378
+ },
379
+ ];
380
+ }
381
+
382
+ issues.push({
383
+ kind: 'type_mismatch',
384
+ table: collName,
385
+ expected: canonicalize(expected.options),
386
+ actual: canonicalize(live.options),
387
+ message: `Collection options mismatch on "${collName}"`,
388
+ });
389
+ return [
390
+ {
391
+ status: 'fail',
392
+ kind: 'options',
393
+ name: 'options',
394
+ contractPath: `storage.collections.${collName}.options`,
395
+ code: 'OPTIONS_MISMATCH',
396
+ message: 'Collection options mismatch',
397
+ expected: expected.options,
398
+ actual: live.options,
399
+ children: [],
400
+ },
401
+ ];
402
+ }