@prisma-next/adapter-mongo 0.3.0-dev.147 → 0.3.0-dev.162

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,470 @@
1
+ import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
2
+ import type {
3
+ MigrationOperationPolicy,
4
+ MigrationPlanner,
5
+ MigrationPlannerConflict,
6
+ MigrationPlannerResult,
7
+ } from '@prisma-next/framework-components/control';
8
+ import type { MongoContract, MongoIndexKey } from '@prisma-next/mongo-contract';
9
+ import {
10
+ buildIndexOpId,
11
+ CollModCommand,
12
+ CreateCollectionCommand,
13
+ CreateIndexCommand,
14
+ DropCollectionCommand,
15
+ DropIndexCommand,
16
+ defaultMongoIndexName,
17
+ keysToKeySpec,
18
+ ListCollectionsCommand,
19
+ ListIndexesCommand,
20
+ MongoAndExpr,
21
+ MongoFieldFilter,
22
+ type MongoMigrationPlanOperation,
23
+ } from '@prisma-next/mongo-query-ast/control';
24
+ import {
25
+ canonicalize,
26
+ deepEqual,
27
+ type MongoSchemaCollection,
28
+ type MongoSchemaCollectionOptions,
29
+ type MongoSchemaIndex,
30
+ type MongoSchemaIR,
31
+ type MongoSchemaValidator,
32
+ } from '@prisma-next/mongo-schema-ir';
33
+ import { contractToMongoSchemaIR } from './contract-to-schema';
34
+
35
+ function buildIndexLookupKey(index: MongoSchemaIndex): string {
36
+ const keys = index.keys.map((k) => `${k.field}:${k.direction}`).join(',');
37
+ const opts = [
38
+ index.unique ? 'unique' : '',
39
+ index.sparse ? 'sparse' : '',
40
+ index.expireAfterSeconds != null ? `ttl:${index.expireAfterSeconds}` : '',
41
+ index.partialFilterExpression ? `pfe:${canonicalize(index.partialFilterExpression)}` : '',
42
+ index.wildcardProjection ? `wp:${canonicalize(index.wildcardProjection)}` : '',
43
+ index.collation ? `col:${canonicalize(index.collation)}` : '',
44
+ index.weights ? `wt:${canonicalize(index.weights)}` : '',
45
+ index.default_language ? `dl:${index.default_language}` : '',
46
+ index.language_override ? `lo:${index.language_override}` : '',
47
+ ]
48
+ .filter(Boolean)
49
+ .join(';');
50
+ return opts ? `${keys}|${opts}` : keys;
51
+ }
52
+
53
+ function formatKeys(keys: ReadonlyArray<MongoIndexKey>): string {
54
+ return keys.map((k) => `${k.field}:${k.direction}`).join(', ');
55
+ }
56
+
57
+ function isTextIndex(keys: ReadonlyArray<MongoIndexKey>): boolean {
58
+ return keys.some((k) => k.direction === 'text');
59
+ }
60
+
61
+ function planCreateIndex(collection: string, index: MongoSchemaIndex): MongoMigrationPlanOperation {
62
+ const { keys } = index;
63
+ const name = defaultMongoIndexName(keys);
64
+
65
+ const textIndex = isTextIndex(keys);
66
+ const keyFilter = textIndex
67
+ ? MongoFieldFilter.eq('key._fts', 'text')
68
+ : MongoFieldFilter.eq('key', keysToKeySpec(keys));
69
+ const fullFilter = index.unique
70
+ ? MongoAndExpr.of([keyFilter, MongoFieldFilter.eq('unique', true)])
71
+ : keyFilter;
72
+
73
+ return {
74
+ id: buildIndexOpId('create', collection, keys),
75
+ label: `Create index on ${collection} (${formatKeys(keys)})`,
76
+ operationClass: 'additive',
77
+ precheck: [
78
+ {
79
+ description: `index does not already exist on ${collection}`,
80
+ source: new ListIndexesCommand(collection),
81
+ filter: keyFilter,
82
+ expect: 'notExists',
83
+ },
84
+ ],
85
+ execute: [
86
+ {
87
+ description: `create index on ${collection}`,
88
+ command: new CreateIndexCommand(collection, keys, {
89
+ unique: index.unique || undefined,
90
+ sparse: index.sparse,
91
+ expireAfterSeconds: index.expireAfterSeconds,
92
+ partialFilterExpression: index.partialFilterExpression,
93
+ wildcardProjection: index.wildcardProjection,
94
+ collation: index.collation,
95
+ weights: index.weights,
96
+ default_language: index.default_language,
97
+ language_override: index.language_override,
98
+ name,
99
+ }),
100
+ },
101
+ ],
102
+ postcheck: [
103
+ {
104
+ description: `index exists on ${collection}`,
105
+ source: new ListIndexesCommand(collection),
106
+ filter: fullFilter,
107
+ expect: 'exists',
108
+ },
109
+ ],
110
+ };
111
+ }
112
+
113
+ function planDropIndex(collection: string, index: MongoSchemaIndex): MongoMigrationPlanOperation {
114
+ const { keys } = index;
115
+ const indexName = defaultMongoIndexName(keys);
116
+ const textIndex = isTextIndex(keys);
117
+ const keyFilter = textIndex
118
+ ? MongoFieldFilter.eq('key._fts', 'text')
119
+ : MongoFieldFilter.eq('key', keysToKeySpec(keys));
120
+
121
+ return {
122
+ id: buildIndexOpId('drop', collection, keys),
123
+ label: `Drop index on ${collection} (${formatKeys(keys)})`,
124
+ operationClass: 'destructive',
125
+ precheck: [
126
+ {
127
+ description: `index exists on ${collection}`,
128
+ source: new ListIndexesCommand(collection),
129
+ filter: keyFilter,
130
+ expect: 'exists',
131
+ },
132
+ ],
133
+ execute: [
134
+ {
135
+ description: `drop index on ${collection}`,
136
+ command: new DropIndexCommand(collection, indexName),
137
+ },
138
+ ],
139
+ postcheck: [
140
+ {
141
+ description: `index no longer exists on ${collection}`,
142
+ source: new ListIndexesCommand(collection),
143
+ filter: keyFilter,
144
+ expect: 'notExists',
145
+ },
146
+ ],
147
+ };
148
+ }
149
+
150
+ function validatorsEqual(
151
+ a: MongoSchemaValidator | undefined,
152
+ b: MongoSchemaValidator | undefined,
153
+ ): boolean {
154
+ if (!a && !b) return true;
155
+ if (!a || !b) return false;
156
+ return (
157
+ a.validationLevel === b.validationLevel &&
158
+ a.validationAction === b.validationAction &&
159
+ canonicalize(a.jsonSchema) === canonicalize(b.jsonSchema)
160
+ );
161
+ }
162
+
163
+ function classifyValidatorUpdate(
164
+ origin: MongoSchemaValidator,
165
+ dest: MongoSchemaValidator,
166
+ ): 'widening' | 'destructive' {
167
+ let hasDestructive = false;
168
+
169
+ if (canonicalize(origin.jsonSchema) !== canonicalize(dest.jsonSchema)) {
170
+ hasDestructive = true;
171
+ }
172
+
173
+ if (origin.validationAction !== dest.validationAction) {
174
+ if (dest.validationAction === 'error') hasDestructive = true;
175
+ }
176
+
177
+ if (origin.validationLevel !== dest.validationLevel) {
178
+ if (dest.validationLevel === 'strict') hasDestructive = true;
179
+ }
180
+
181
+ return hasDestructive ? 'destructive' : 'widening';
182
+ }
183
+
184
+ function planValidatorDiff(
185
+ collName: string,
186
+ originValidator: MongoSchemaValidator | undefined,
187
+ destValidator: MongoSchemaValidator | undefined,
188
+ ): MongoMigrationPlanOperation | undefined {
189
+ if (validatorsEqual(originValidator, destValidator)) return undefined;
190
+
191
+ const collExistsPrecheck = {
192
+ description: `collection ${collName} exists`,
193
+ source: new ListCollectionsCommand(),
194
+ filter: MongoFieldFilter.eq('name', collName),
195
+ expect: 'exists' as const,
196
+ };
197
+
198
+ if (destValidator) {
199
+ const operationClass = originValidator
200
+ ? classifyValidatorUpdate(originValidator, destValidator)
201
+ : 'destructive';
202
+ return {
203
+ id: `validator.${collName}.${originValidator ? 'update' : 'add'}`,
204
+ label: `${originValidator ? 'Update' : 'Add'} validator on ${collName}`,
205
+ operationClass,
206
+ precheck: [collExistsPrecheck],
207
+ execute: [
208
+ {
209
+ description: `set validator on ${collName}`,
210
+ command: new CollModCommand(collName, {
211
+ validator: { $jsonSchema: destValidator.jsonSchema },
212
+ validationLevel: destValidator.validationLevel,
213
+ validationAction: destValidator.validationAction,
214
+ }),
215
+ },
216
+ ],
217
+ postcheck: [
218
+ {
219
+ description: `validator applied on ${collName}`,
220
+ source: new ListCollectionsCommand(),
221
+ filter: MongoAndExpr.of([
222
+ MongoFieldFilter.eq('name', collName),
223
+ MongoFieldFilter.eq('options.validationLevel', destValidator.validationLevel),
224
+ MongoFieldFilter.eq('options.validationAction', destValidator.validationAction),
225
+ ]),
226
+ expect: 'exists' as const,
227
+ },
228
+ ],
229
+ };
230
+ }
231
+
232
+ return {
233
+ id: `validator.${collName}.remove`,
234
+ label: `Remove validator on ${collName}`,
235
+ operationClass: 'widening',
236
+ precheck: [collExistsPrecheck],
237
+ execute: [
238
+ {
239
+ description: `remove validator on ${collName}`,
240
+ command: new CollModCommand(collName, {
241
+ validator: {},
242
+ validationLevel: 'strict',
243
+ validationAction: 'error',
244
+ }),
245
+ },
246
+ ],
247
+ postcheck: [],
248
+ };
249
+ }
250
+
251
+ function hasImmutableOptionChange(
252
+ origin: MongoSchemaCollectionOptions | undefined,
253
+ dest: MongoSchemaCollectionOptions | undefined,
254
+ ): string | undefined {
255
+ if (canonicalize(origin?.capped) !== canonicalize(dest?.capped)) return 'capped';
256
+ if (canonicalize(origin?.timeseries) !== canonicalize(dest?.timeseries)) return 'timeseries';
257
+ if (canonicalize(origin?.collation) !== canonicalize(dest?.collation)) return 'collation';
258
+ if (canonicalize(origin?.clusteredIndex) !== canonicalize(dest?.clusteredIndex))
259
+ return 'clusteredIndex';
260
+ return undefined;
261
+ }
262
+
263
+ function planCreateCollection(
264
+ collName: string,
265
+ dest: MongoSchemaCollection,
266
+ ): MongoMigrationPlanOperation {
267
+ const opts = dest.options;
268
+ const validator = dest.validator;
269
+ return {
270
+ id: `collection.${collName}.create`,
271
+ label: `Create collection ${collName}`,
272
+ operationClass: 'additive',
273
+ precheck: [
274
+ {
275
+ description: `collection ${collName} does not exist`,
276
+ source: new ListCollectionsCommand(),
277
+ filter: MongoFieldFilter.eq('name', collName),
278
+ expect: 'notExists',
279
+ },
280
+ ],
281
+ execute: [
282
+ {
283
+ description: `create collection ${collName}`,
284
+ command: new CreateCollectionCommand(collName, {
285
+ capped: opts?.capped ? true : undefined,
286
+ size: opts?.capped?.size,
287
+ max: opts?.capped?.max,
288
+ timeseries: opts?.timeseries,
289
+ collation: opts?.collation,
290
+ clusteredIndex: opts?.clusteredIndex
291
+ ? {
292
+ key: { _id: 1 } as Record<string, number>,
293
+ unique: true as boolean,
294
+ ...(opts.clusteredIndex.name != null ? { name: opts.clusteredIndex.name } : {}),
295
+ }
296
+ : undefined,
297
+ validator: validator ? { $jsonSchema: validator.jsonSchema } : undefined,
298
+ validationLevel: validator?.validationLevel,
299
+ validationAction: validator?.validationAction,
300
+ changeStreamPreAndPostImages: opts?.changeStreamPreAndPostImages,
301
+ }),
302
+ },
303
+ ],
304
+ postcheck: [],
305
+ };
306
+ }
307
+
308
+ function planDropCollection(collName: string): MongoMigrationPlanOperation {
309
+ return {
310
+ id: `collection.${collName}.drop`,
311
+ label: `Drop collection ${collName}`,
312
+ operationClass: 'destructive',
313
+ precheck: [],
314
+ execute: [
315
+ {
316
+ description: `drop collection ${collName}`,
317
+ command: new DropCollectionCommand(collName),
318
+ },
319
+ ],
320
+ postcheck: [],
321
+ };
322
+ }
323
+
324
+ function planMutableOptionsDiff(
325
+ collName: string,
326
+ origin: MongoSchemaCollectionOptions | undefined,
327
+ dest: MongoSchemaCollectionOptions | undefined,
328
+ ): MongoMigrationPlanOperation | undefined {
329
+ const originCSPPI = origin?.changeStreamPreAndPostImages;
330
+ const destCSPPI = dest?.changeStreamPreAndPostImages;
331
+ if (deepEqual(originCSPPI, destCSPPI)) return undefined;
332
+
333
+ return {
334
+ id: `options.${collName}.update`,
335
+ label: `Update mutable options on ${collName}`,
336
+ operationClass: destCSPPI?.enabled ? 'widening' : 'destructive',
337
+ precheck: [],
338
+ execute: [
339
+ {
340
+ description: `update options on ${collName}`,
341
+ command: new CollModCommand(collName, {
342
+ changeStreamPreAndPostImages: destCSPPI,
343
+ }),
344
+ },
345
+ ],
346
+ postcheck: [],
347
+ };
348
+ }
349
+
350
+ function collectionHasOptions(coll: MongoSchemaCollection): boolean {
351
+ return !!(coll.options || coll.validator);
352
+ }
353
+
354
+ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> {
355
+ plan(options: {
356
+ readonly contract: unknown;
357
+ readonly schema: unknown;
358
+ readonly policy: MigrationOperationPolicy;
359
+ readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'mongo', 'mongo'>>;
360
+ }): MigrationPlannerResult {
361
+ const contract = options.contract as MongoContract;
362
+ const originIR = options.schema as MongoSchemaIR;
363
+ const destinationIR = contractToMongoSchemaIR(contract);
364
+
365
+ const collCreates: MongoMigrationPlanOperation[] = [];
366
+ const drops: MongoMigrationPlanOperation[] = [];
367
+ const creates: MongoMigrationPlanOperation[] = [];
368
+ const validatorOps: MongoMigrationPlanOperation[] = [];
369
+ const mutableOptionOps: MongoMigrationPlanOperation[] = [];
370
+ const collDrops: MongoMigrationPlanOperation[] = [];
371
+ const conflicts: MigrationPlannerConflict[] = [];
372
+
373
+ const allCollectionNames = new Set([
374
+ ...originIR.collectionNames,
375
+ ...destinationIR.collectionNames,
376
+ ]);
377
+
378
+ for (const collName of [...allCollectionNames].sort()) {
379
+ const originColl = originIR.collection(collName);
380
+ const destColl = destinationIR.collection(collName);
381
+
382
+ if (!originColl && destColl) {
383
+ if (collectionHasOptions(destColl)) {
384
+ collCreates.push(planCreateCollection(collName, destColl));
385
+ }
386
+ } else if (originColl && !destColl) {
387
+ collDrops.push(planDropCollection(collName));
388
+ } else if (originColl && destColl) {
389
+ const immutableChange = hasImmutableOptionChange(originColl.options, destColl.options);
390
+ if (immutableChange) {
391
+ conflicts.push({
392
+ kind: 'policy-violation',
393
+ summary: `Cannot change immutable collection option '${immutableChange}' on ${collName}`,
394
+ why: `MongoDB does not support modifying the '${immutableChange}' option after collection creation`,
395
+ });
396
+ }
397
+
398
+ const mutableOp = planMutableOptionsDiff(collName, originColl.options, destColl.options);
399
+ if (mutableOp) mutableOptionOps.push(mutableOp);
400
+
401
+ const validatorOp = planValidatorDiff(collName, originColl.validator, destColl.validator);
402
+ if (validatorOp) validatorOps.push(validatorOp);
403
+ }
404
+
405
+ const originLookup = new Map<string, MongoSchemaIndex>();
406
+ if (originColl) {
407
+ for (const idx of originColl.indexes) {
408
+ originLookup.set(buildIndexLookupKey(idx), idx);
409
+ }
410
+ }
411
+
412
+ const destLookup = new Map<string, MongoSchemaIndex>();
413
+ if (destColl) {
414
+ for (const idx of destColl.indexes) {
415
+ destLookup.set(buildIndexLookupKey(idx), idx);
416
+ }
417
+ }
418
+
419
+ for (const [lookupKey, idx] of originLookup) {
420
+ if (!destLookup.has(lookupKey)) {
421
+ drops.push(planDropIndex(collName, idx));
422
+ }
423
+ }
424
+
425
+ for (const [lookupKey, idx] of destLookup) {
426
+ if (!originLookup.has(lookupKey)) {
427
+ creates.push(planCreateIndex(collName, idx));
428
+ }
429
+ }
430
+ }
431
+
432
+ if (conflicts.length > 0) {
433
+ return { kind: 'failure', conflicts };
434
+ }
435
+
436
+ const allOps = [
437
+ ...collCreates,
438
+ ...drops,
439
+ ...creates,
440
+ ...validatorOps,
441
+ ...mutableOptionOps,
442
+ ...collDrops,
443
+ ];
444
+
445
+ for (const op of allOps) {
446
+ if (!options.policy.allowedOperationClasses.includes(op.operationClass)) {
447
+ conflicts.push({
448
+ kind: 'policy-violation',
449
+ summary: `${op.operationClass} operation disallowed: ${op.label}`,
450
+ why: `Policy does not allow '${op.operationClass}' operations`,
451
+ });
452
+ }
453
+ }
454
+
455
+ if (conflicts.length > 0) {
456
+ return { kind: 'failure', conflicts };
457
+ }
458
+
459
+ return {
460
+ kind: 'success',
461
+ plan: {
462
+ targetId: 'mongo',
463
+ destination: {
464
+ storageHash: contract.storage.storageHash,
465
+ },
466
+ operations: allOps,
467
+ },
468
+ };
469
+ }
470
+ }