@prisma-next/target-mongo 0.4.0-dev.1 → 0.4.0-dev.3

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,306 @@
1
+ import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
2
+ import type {
3
+ MigrationOperationClass,
4
+ MigrationOperationPolicy,
5
+ MigrationPlanner,
6
+ MigrationPlannerConflict,
7
+ MigrationPlannerResult,
8
+ } from '@prisma-next/framework-components/control';
9
+ import type { MongoContract } from '@prisma-next/mongo-contract';
10
+ import type {
11
+ MongoSchemaCollection,
12
+ MongoSchemaCollectionOptions,
13
+ MongoSchemaIndex,
14
+ MongoSchemaIR,
15
+ MongoSchemaValidator,
16
+ } from '@prisma-next/mongo-schema-ir';
17
+ import { canonicalize, deepEqual } from '@prisma-next/mongo-schema-ir';
18
+ import { contractToMongoSchemaIR } from './contract-to-schema';
19
+ import type { OpFactoryCall } from './op-factory-call';
20
+ import {
21
+ CollModCall,
22
+ CreateCollectionCall,
23
+ CreateIndexCall,
24
+ DropCollectionCall,
25
+ DropIndexCall,
26
+ schemaCollectionToCreateCollectionOptions,
27
+ schemaIndexToCreateIndexOptions,
28
+ } from './op-factory-call';
29
+ import { renderOps } from './render-ops';
30
+
31
+ function buildIndexLookupKey(index: MongoSchemaIndex): string {
32
+ const keys = index.keys.map((k) => `${k.field}:${k.direction}`).join(',');
33
+ const opts = [
34
+ index.unique ? 'unique' : '',
35
+ index.sparse ? 'sparse' : '',
36
+ index.expireAfterSeconds != null ? `ttl:${index.expireAfterSeconds}` : '',
37
+ index.partialFilterExpression ? `pfe:${canonicalize(index.partialFilterExpression)}` : '',
38
+ index.wildcardProjection ? `wp:${canonicalize(index.wildcardProjection)}` : '',
39
+ index.collation ? `col:${canonicalize(index.collation)}` : '',
40
+ index.weights ? `wt:${canonicalize(index.weights)}` : '',
41
+ index.default_language ? `dl:${index.default_language}` : '',
42
+ index.language_override ? `lo:${index.language_override}` : '',
43
+ ]
44
+ .filter(Boolean)
45
+ .join(';');
46
+ return opts ? `${keys}|${opts}` : keys;
47
+ }
48
+
49
+ function validatorsEqual(
50
+ a: MongoSchemaValidator | undefined,
51
+ b: MongoSchemaValidator | undefined,
52
+ ): boolean {
53
+ if (!a && !b) return true;
54
+ if (!a || !b) return false;
55
+ return (
56
+ a.validationLevel === b.validationLevel &&
57
+ a.validationAction === b.validationAction &&
58
+ canonicalize(a.jsonSchema) === canonicalize(b.jsonSchema)
59
+ );
60
+ }
61
+
62
+ function classifyValidatorUpdate(
63
+ origin: MongoSchemaValidator,
64
+ dest: MongoSchemaValidator,
65
+ ): 'widening' | 'destructive' {
66
+ let hasDestructive = false;
67
+
68
+ if (canonicalize(origin.jsonSchema) !== canonicalize(dest.jsonSchema)) {
69
+ hasDestructive = true;
70
+ }
71
+
72
+ if (origin.validationAction !== dest.validationAction) {
73
+ if (dest.validationAction === 'error') hasDestructive = true;
74
+ }
75
+
76
+ if (origin.validationLevel !== dest.validationLevel) {
77
+ if (dest.validationLevel === 'strict') hasDestructive = true;
78
+ }
79
+
80
+ return hasDestructive ? 'destructive' : 'widening';
81
+ }
82
+
83
+ function hasImmutableOptionChange(
84
+ origin: MongoSchemaCollectionOptions | undefined,
85
+ dest: MongoSchemaCollectionOptions | undefined,
86
+ ): string | undefined {
87
+ if (canonicalize(origin?.capped) !== canonicalize(dest?.capped)) return 'capped';
88
+ if (canonicalize(origin?.timeseries) !== canonicalize(dest?.timeseries)) return 'timeseries';
89
+ if (canonicalize(origin?.collation) !== canonicalize(dest?.collation)) return 'collation';
90
+ if (canonicalize(origin?.clusteredIndex) !== canonicalize(dest?.clusteredIndex))
91
+ return 'clusteredIndex';
92
+ return undefined;
93
+ }
94
+
95
+ function collectionHasOptions(coll: MongoSchemaCollection): boolean {
96
+ return !!(coll.options || coll.validator);
97
+ }
98
+
99
+ export type PlanCallsResult =
100
+ | { readonly kind: 'success'; readonly calls: OpFactoryCall[] }
101
+ | { readonly kind: 'failure'; readonly conflicts: MigrationPlannerConflict[] };
102
+
103
+ export class MongoMigrationPlanner implements MigrationPlanner<'mongo', 'mongo'> {
104
+ planCalls(options: {
105
+ readonly contract: unknown;
106
+ readonly schema: unknown;
107
+ readonly policy: MigrationOperationPolicy;
108
+ readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'mongo', 'mongo'>>;
109
+ }): PlanCallsResult {
110
+ const contract = options.contract as MongoContract;
111
+ const originIR = options.schema as MongoSchemaIR;
112
+ const destinationIR = contractToMongoSchemaIR(contract);
113
+
114
+ const collCreates: OpFactoryCall[] = [];
115
+ const drops: OpFactoryCall[] = [];
116
+ const creates: OpFactoryCall[] = [];
117
+ const validatorOps: OpFactoryCall[] = [];
118
+ const mutableOptionOps: OpFactoryCall[] = [];
119
+ const collDrops: OpFactoryCall[] = [];
120
+ const conflicts: MigrationPlannerConflict[] = [];
121
+
122
+ const allCollectionNames = new Set([
123
+ ...originIR.collectionNames,
124
+ ...destinationIR.collectionNames,
125
+ ]);
126
+
127
+ for (const collName of [...allCollectionNames].sort()) {
128
+ const originColl = originIR.collection(collName);
129
+ const destColl = destinationIR.collection(collName);
130
+
131
+ if (!originColl) {
132
+ if (destColl && collectionHasOptions(destColl)) {
133
+ const opts = schemaCollectionToCreateCollectionOptions(destColl);
134
+ collCreates.push(new CreateCollectionCall(collName, opts));
135
+ }
136
+ } else if (!destColl) {
137
+ collDrops.push(new DropCollectionCall(collName));
138
+ } else {
139
+ const immutableChange = hasImmutableOptionChange(originColl.options, destColl.options);
140
+ if (immutableChange) {
141
+ conflicts.push({
142
+ kind: 'policy-violation',
143
+ summary: `Cannot change immutable collection option '${immutableChange}' on ${collName}`,
144
+ why: `MongoDB does not support modifying the '${immutableChange}' option after collection creation`,
145
+ });
146
+ }
147
+
148
+ const mutableCall = planMutableOptionsDiffCall(
149
+ collName,
150
+ originColl.options,
151
+ destColl.options,
152
+ );
153
+ if (mutableCall) mutableOptionOps.push(mutableCall);
154
+
155
+ const validatorCall = planValidatorDiffCall(
156
+ collName,
157
+ originColl.validator,
158
+ destColl.validator,
159
+ );
160
+ if (validatorCall) validatorOps.push(validatorCall);
161
+ }
162
+
163
+ const originLookup = new Map<string, MongoSchemaIndex>();
164
+ if (originColl) {
165
+ for (const idx of originColl.indexes) {
166
+ originLookup.set(buildIndexLookupKey(idx), idx);
167
+ }
168
+ }
169
+
170
+ const destLookup = new Map<string, MongoSchemaIndex>();
171
+ if (destColl) {
172
+ for (const idx of destColl.indexes) {
173
+ destLookup.set(buildIndexLookupKey(idx), idx);
174
+ }
175
+ }
176
+
177
+ for (const [lookupKey, idx] of originLookup) {
178
+ if (!destLookup.has(lookupKey)) {
179
+ drops.push(new DropIndexCall(collName, idx.keys));
180
+ }
181
+ }
182
+
183
+ for (const [lookupKey, idx] of destLookup) {
184
+ if (!originLookup.has(lookupKey)) {
185
+ creates.push(
186
+ new CreateIndexCall(collName, idx.keys, schemaIndexToCreateIndexOptions(idx)),
187
+ );
188
+ }
189
+ }
190
+ }
191
+
192
+ if (conflicts.length > 0) {
193
+ return { kind: 'failure', conflicts };
194
+ }
195
+
196
+ const allCalls = [
197
+ ...collCreates,
198
+ ...drops,
199
+ ...creates,
200
+ ...validatorOps,
201
+ ...mutableOptionOps,
202
+ ...collDrops,
203
+ ];
204
+
205
+ for (const call of allCalls) {
206
+ if (!options.policy.allowedOperationClasses.includes(call.operationClass)) {
207
+ conflicts.push({
208
+ kind: 'policy-violation',
209
+ summary: `${call.operationClass} operation disallowed: ${call.label}`,
210
+ why: `Policy does not allow '${call.operationClass}' operations`,
211
+ });
212
+ }
213
+ }
214
+
215
+ if (conflicts.length > 0) {
216
+ return { kind: 'failure', conflicts };
217
+ }
218
+
219
+ return { kind: 'success', calls: allCalls };
220
+ }
221
+
222
+ plan(options: {
223
+ readonly contract: unknown;
224
+ readonly schema: unknown;
225
+ readonly policy: MigrationOperationPolicy;
226
+ readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'mongo', 'mongo'>>;
227
+ }): MigrationPlannerResult {
228
+ const contract = options.contract as MongoContract;
229
+ const result = this.planCalls(options);
230
+ if (result.kind === 'failure') return result;
231
+ return {
232
+ kind: 'success',
233
+ plan: {
234
+ targetId: 'mongo',
235
+ destination: {
236
+ storageHash: contract.storage.storageHash,
237
+ },
238
+ operations: renderOps(result.calls),
239
+ },
240
+ };
241
+ }
242
+ }
243
+
244
+ function planValidatorDiffCall(
245
+ collName: string,
246
+ originValidator: MongoSchemaValidator | undefined,
247
+ destValidator: MongoSchemaValidator | undefined,
248
+ ): OpFactoryCall | undefined {
249
+ if (validatorsEqual(originValidator, destValidator)) return undefined;
250
+
251
+ if (destValidator) {
252
+ const operationClass: MigrationOperationClass = originValidator
253
+ ? classifyValidatorUpdate(originValidator, destValidator)
254
+ : 'destructive';
255
+ return new CollModCall(
256
+ collName,
257
+ {
258
+ validator: { $jsonSchema: destValidator.jsonSchema },
259
+ validationLevel: destValidator.validationLevel,
260
+ validationAction: destValidator.validationAction,
261
+ },
262
+ {
263
+ id: `validator.${collName}.${originValidator ? 'update' : 'add'}`,
264
+ label: `${originValidator ? 'Update' : 'Add'} validator on ${collName}`,
265
+ operationClass,
266
+ },
267
+ );
268
+ }
269
+
270
+ return new CollModCall(
271
+ collName,
272
+ {
273
+ validator: {},
274
+ validationLevel: 'strict',
275
+ validationAction: 'error',
276
+ },
277
+ {
278
+ id: `validator.${collName}.remove`,
279
+ label: `Remove validator on ${collName}`,
280
+ operationClass: 'widening',
281
+ },
282
+ );
283
+ }
284
+
285
+ function planMutableOptionsDiffCall(
286
+ collName: string,
287
+ origin: MongoSchemaCollectionOptions | undefined,
288
+ dest: MongoSchemaCollectionOptions | undefined,
289
+ ): OpFactoryCall | undefined {
290
+ const originCSPPI = origin?.changeStreamPreAndPostImages;
291
+ const destCSPPI = dest?.changeStreamPreAndPostImages;
292
+ if (deepEqual(originCSPPI, destCSPPI)) return undefined;
293
+
294
+ const desiredCSPPI = destCSPPI ?? { enabled: false };
295
+ return new CollModCall(
296
+ collName,
297
+ {
298
+ changeStreamPreAndPostImages: desiredCSPPI,
299
+ },
300
+ {
301
+ id: `options.${collName}.update`,
302
+ label: `Update mutable options on ${collName}`,
303
+ operationClass: desiredCSPPI.enabled ? 'widening' : 'destructive',
304
+ },
305
+ );
306
+ }
@@ -0,0 +1,275 @@
1
+ import type { ContractMarkerRecord } from '@prisma-next/contract/types';
2
+ import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
3
+ import type {
4
+ MigrationOperationPolicy,
5
+ MigrationPlan,
6
+ MigrationPlanOperation,
7
+ MigrationRunnerExecutionChecks,
8
+ MigrationRunnerFailure,
9
+ MigrationRunnerResult,
10
+ } from '@prisma-next/framework-components/control';
11
+ import type {
12
+ MongoDdlCommandVisitor,
13
+ MongoInspectionCommandVisitor,
14
+ MongoMigrationCheck,
15
+ MongoMigrationPlanOperation,
16
+ } from '@prisma-next/mongo-query-ast/control';
17
+ import { notOk, ok } from '@prisma-next/utils/result';
18
+ import { FilterEvaluator } from './filter-evaluator';
19
+ import { deserializeMongoOps } from './mongo-ops-serializer';
20
+
21
+ export interface MarkerOperations {
22
+ readMarker(): Promise<ContractMarkerRecord | null>;
23
+ initMarker(destination: {
24
+ readonly storageHash: string;
25
+ readonly profileHash: string;
26
+ }): Promise<void>;
27
+ updateMarker(
28
+ expectedFrom: string,
29
+ destination: { readonly storageHash: string; readonly profileHash: string },
30
+ ): Promise<boolean>;
31
+ writeLedgerEntry(entry: {
32
+ readonly edgeId: string;
33
+ readonly from: string;
34
+ readonly to: string;
35
+ }): Promise<void>;
36
+ }
37
+
38
+ export interface MongoRunnerDependencies {
39
+ readonly commandExecutor: MongoDdlCommandVisitor<Promise<void>>;
40
+ readonly inspectionExecutor: MongoInspectionCommandVisitor<Promise<Record<string, unknown>[]>>;
41
+ readonly markerOps: MarkerOperations;
42
+ }
43
+
44
+ function runnerFailure(
45
+ code: string,
46
+ summary: string,
47
+ opts?: { why?: string; meta?: Record<string, unknown> },
48
+ ): MigrationRunnerResult {
49
+ return notOk<MigrationRunnerFailure>({
50
+ code,
51
+ summary,
52
+ ...opts,
53
+ });
54
+ }
55
+
56
+ export class MongoMigrationRunner {
57
+ constructor(private readonly deps: MongoRunnerDependencies) {}
58
+
59
+ async execute(options: {
60
+ readonly plan: MigrationPlan;
61
+ readonly destinationContract: unknown;
62
+ readonly policy: MigrationOperationPolicy;
63
+ readonly callbacks?: {
64
+ onOperationStart?(op: MigrationPlanOperation): void;
65
+ onOperationComplete?(op: MigrationPlanOperation): void;
66
+ };
67
+ readonly executionChecks?: MigrationRunnerExecutionChecks;
68
+ readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'mongo', 'mongo'>>;
69
+ }): Promise<MigrationRunnerResult> {
70
+ const { commandExecutor, inspectionExecutor, markerOps } = this.deps;
71
+ const operations = deserializeMongoOps(options.plan.operations as readonly unknown[]);
72
+
73
+ const policyCheck = this.enforcePolicyCompatibility(options.policy, operations);
74
+ if (policyCheck) return policyCheck;
75
+
76
+ const existingMarker = await markerOps.readMarker();
77
+
78
+ const markerCheck = this.ensureMarkerCompatibility(existingMarker, options.plan);
79
+ if (markerCheck) return markerCheck;
80
+
81
+ const checks = options.executionChecks;
82
+ const runPrechecks = checks?.prechecks !== false;
83
+ const runPostchecks = checks?.postchecks !== false;
84
+ const runIdempotency = checks?.idempotencyChecks !== false;
85
+
86
+ const filterEvaluator = new FilterEvaluator();
87
+
88
+ let operationsExecuted = 0;
89
+
90
+ for (const operation of operations) {
91
+ options.callbacks?.onOperationStart?.(operation);
92
+ try {
93
+ if (runPostchecks && runIdempotency) {
94
+ const allSatisfied = await this.allChecksSatisfied(
95
+ operation.postcheck,
96
+ inspectionExecutor,
97
+ filterEvaluator,
98
+ );
99
+ if (allSatisfied) continue;
100
+ }
101
+
102
+ if (runPrechecks) {
103
+ const precheckResult = await this.evaluateChecks(
104
+ operation.precheck,
105
+ inspectionExecutor,
106
+ filterEvaluator,
107
+ );
108
+ if (!precheckResult) {
109
+ return runnerFailure(
110
+ 'PRECHECK_FAILED',
111
+ `Operation ${operation.id} failed during precheck`,
112
+ { meta: { operationId: operation.id } },
113
+ );
114
+ }
115
+ }
116
+
117
+ for (const step of operation.execute) {
118
+ await step.command.accept(commandExecutor);
119
+ }
120
+
121
+ if (runPostchecks) {
122
+ const postcheckResult = await this.evaluateChecks(
123
+ operation.postcheck,
124
+ inspectionExecutor,
125
+ filterEvaluator,
126
+ );
127
+ if (!postcheckResult) {
128
+ return runnerFailure(
129
+ 'POSTCHECK_FAILED',
130
+ `Operation ${operation.id} failed during postcheck`,
131
+ { meta: { operationId: operation.id } },
132
+ );
133
+ }
134
+ }
135
+
136
+ operationsExecuted += 1;
137
+ } finally {
138
+ options.callbacks?.onOperationComplete?.(operation);
139
+ }
140
+ }
141
+
142
+ const destination = options.plan.destination;
143
+ const contract = options.destinationContract as { profileHash?: string };
144
+ const profileHash = contract.profileHash ?? destination.storageHash;
145
+
146
+ if (
147
+ operationsExecuted === 0 &&
148
+ existingMarker?.storageHash === destination.storageHash &&
149
+ existingMarker.profileHash === profileHash
150
+ ) {
151
+ return ok({ operationsPlanned: operations.length, operationsExecuted });
152
+ }
153
+
154
+ if (existingMarker) {
155
+ const updated = await markerOps.updateMarker(existingMarker.storageHash, {
156
+ storageHash: destination.storageHash,
157
+ profileHash,
158
+ });
159
+ if (!updated) {
160
+ return runnerFailure(
161
+ 'MARKER_CAS_FAILURE',
162
+ 'Marker was modified by another process during migration execution.',
163
+ {
164
+ meta: {
165
+ expectedStorageHash: existingMarker.storageHash,
166
+ destinationStorageHash: destination.storageHash,
167
+ },
168
+ },
169
+ );
170
+ }
171
+ } else {
172
+ await markerOps.initMarker({
173
+ storageHash: destination.storageHash,
174
+ profileHash,
175
+ });
176
+ }
177
+
178
+ const originHash = existingMarker?.storageHash ?? '';
179
+ await markerOps.writeLedgerEntry({
180
+ edgeId: `${originHash}->${destination.storageHash}`,
181
+ from: originHash,
182
+ to: destination.storageHash,
183
+ });
184
+
185
+ return ok({ operationsPlanned: operations.length, operationsExecuted });
186
+ }
187
+
188
+ private async evaluateChecks(
189
+ checks: readonly MongoMigrationCheck[],
190
+ inspectionExecutor: MongoInspectionCommandVisitor<Promise<Record<string, unknown>[]>>,
191
+ filterEvaluator: FilterEvaluator,
192
+ ): Promise<boolean> {
193
+ for (const check of checks) {
194
+ const documents = await check.source.accept(inspectionExecutor);
195
+ const matchFound = documents.some((doc) =>
196
+ filterEvaluator.evaluate(check.filter, doc as Record<string, unknown>),
197
+ );
198
+ const passed = check.expect === 'exists' ? matchFound : !matchFound;
199
+ if (!passed) return false;
200
+ }
201
+ return true;
202
+ }
203
+
204
+ private async allChecksSatisfied(
205
+ checks: readonly MongoMigrationCheck[],
206
+ inspectionExecutor: MongoInspectionCommandVisitor<Promise<Record<string, unknown>[]>>,
207
+ filterEvaluator: FilterEvaluator,
208
+ ): Promise<boolean> {
209
+ if (checks.length === 0) return false;
210
+ return this.evaluateChecks(checks, inspectionExecutor, filterEvaluator);
211
+ }
212
+
213
+ private enforcePolicyCompatibility(
214
+ policy: MigrationOperationPolicy,
215
+ operations: readonly MongoMigrationPlanOperation[],
216
+ ): MigrationRunnerResult | undefined {
217
+ const allowedClasses = new Set(policy.allowedOperationClasses);
218
+ for (const operation of operations) {
219
+ if (!allowedClasses.has(operation.operationClass)) {
220
+ return runnerFailure(
221
+ 'POLICY_VIOLATION',
222
+ `Operation ${operation.id} has class "${operation.operationClass}" which is not allowed by policy.`,
223
+ {
224
+ why: `Policy only allows: ${[...allowedClasses].join(', ')}.`,
225
+ meta: {
226
+ operationId: operation.id,
227
+ operationClass: operation.operationClass,
228
+ },
229
+ },
230
+ );
231
+ }
232
+ }
233
+ return undefined;
234
+ }
235
+
236
+ private ensureMarkerCompatibility(
237
+ marker: ContractMarkerRecord | null,
238
+ plan: MigrationPlan,
239
+ ): MigrationRunnerResult | undefined {
240
+ const origin = plan.origin ?? null;
241
+ if (!origin) {
242
+ if (marker) {
243
+ return runnerFailure(
244
+ 'MARKER_ORIGIN_MISMATCH',
245
+ 'Database already has a contract marker but the plan has no origin. This would silently overwrite the existing marker.',
246
+ { meta: { markerStorageHash: marker.storageHash } },
247
+ );
248
+ }
249
+ return undefined;
250
+ }
251
+
252
+ if (!marker) {
253
+ return runnerFailure(
254
+ 'MARKER_ORIGIN_MISMATCH',
255
+ `Missing contract marker: expected origin storage hash ${origin.storageHash}.`,
256
+ { meta: { expectedOriginStorageHash: origin.storageHash } },
257
+ );
258
+ }
259
+
260
+ if (marker.storageHash !== origin.storageHash) {
261
+ return runnerFailure(
262
+ 'MARKER_ORIGIN_MISMATCH',
263
+ `Existing contract marker (${marker.storageHash}) does not match plan origin (${origin.storageHash}).`,
264
+ {
265
+ meta: {
266
+ markerStorageHash: marker.storageHash,
267
+ expectedOriginStorageHash: origin.storageHash,
268
+ },
269
+ },
270
+ );
271
+ }
272
+
273
+ return undefined;
274
+ }
275
+ }