@prisma-next/family-mongo 0.7.0 → 0.8.0-dev.10
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.
- package/README.md +22 -9
- package/dist/control-adapter.d.mts +75 -0
- package/dist/control-adapter.d.mts.map +1 -0
- package/dist/control-adapter.mjs +1 -0
- package/dist/control.d.mts +60 -16
- package/dist/control.d.mts.map +1 -1
- package/dist/control.mjs +257 -276
- package/dist/control.mjs.map +1 -1
- package/dist/ir.d.mts +131 -0
- package/dist/ir.d.mts.map +1 -0
- package/dist/ir.mjs +54 -0
- package/dist/ir.mjs.map +1 -0
- package/dist/mongo-contract-serializer-Co3EaTVj.mjs +98 -0
- package/dist/mongo-contract-serializer-Co3EaTVj.mjs.map +1 -0
- package/dist/schema-verify.d.mts +22 -2
- package/dist/schema-verify.d.mts.map +1 -0
- package/dist/schema-verify.mjs +1 -1
- package/dist/verify-mongo-schema-BL7t9YTB.mjs +592 -0
- package/dist/verify-mongo-schema-BL7t9YTB.mjs.map +1 -0
- package/package.json +20 -22
- package/src/core/contract-to-schema.ts +84 -0
- package/src/core/control-adapter.ts +97 -0
- package/src/core/control-instance.ts +275 -272
- package/src/core/control-target-descriptor.ts +26 -0
- package/src/core/control-types.ts +6 -4
- package/src/core/ir/mongo-contract-serializer-base.ts +124 -0
- package/src/core/ir/mongo-contract-serializer.ts +18 -0
- package/src/core/ir/mongo-schema-verifier-base.ts +87 -0
- package/src/core/operation-preview.ts +131 -0
- package/src/core/schema-diff.ts +402 -0
- package/src/core/schema-verify/canonicalize-introspection.ts +389 -0
- package/src/core/schema-verify/verify-mongo-schema.ts +60 -0
- package/src/exports/control-adapter.ts +1 -0
- package/src/exports/control.ts +8 -1
- package/src/exports/ir.ts +3 -0
- package/src/exports/schema-verify.ts +4 -2
- package/src/core/mongo-target-descriptor.ts +0 -180
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonicalizes a live (introspected) `MongoSchemaIR` against the expected
|
|
3
|
+
* (contract-built) IR before diffing. MongoDB applies server-side defaults
|
|
4
|
+
* to several option/index families that are absent from authored contracts,
|
|
5
|
+
* which would otherwise cause `verifyMongoSchema` to report false-positive
|
|
6
|
+
* drift on a fresh `migrate` run.
|
|
7
|
+
*
|
|
8
|
+
* The normalization is contract-aware where it has to be: server defaults
|
|
9
|
+
* are stripped from the live IR for fields the contract did not specify, so
|
|
10
|
+
* a contract that *does* specify a value still gets compared faithfully.
|
|
11
|
+
*
|
|
12
|
+
* Symmetric defaults — like `changeStreamPreAndPostImages: { enabled: false }`,
|
|
13
|
+
* which is equivalent to "absent" on both sides — are stripped from both IRs
|
|
14
|
+
* so either authoring style verifies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
MongoSchemaCollection,
|
|
19
|
+
MongoSchemaCollectionOptions,
|
|
20
|
+
MongoSchemaIndex,
|
|
21
|
+
MongoSchemaIR,
|
|
22
|
+
} from '@prisma-next/mongo-schema-ir';
|
|
23
|
+
import {
|
|
24
|
+
MongoSchemaCollection as MongoSchemaCollectionCtor,
|
|
25
|
+
MongoSchemaCollectionOptions as MongoSchemaCollectionOptionsCtor,
|
|
26
|
+
MongoSchemaIndex as MongoSchemaIndexCtor,
|
|
27
|
+
MongoSchemaIR as MongoSchemaIRCtor,
|
|
28
|
+
} from '@prisma-next/mongo-schema-ir';
|
|
29
|
+
import { ifDefined } from '@prisma-next/utils/defined';
|
|
30
|
+
|
|
31
|
+
export interface CanonicalizedSchemas {
|
|
32
|
+
readonly live: MongoSchemaIR;
|
|
33
|
+
readonly expected: MongoSchemaIR;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function canonicalizeSchemasForVerification(
|
|
37
|
+
live: MongoSchemaIR,
|
|
38
|
+
expected: MongoSchemaIR,
|
|
39
|
+
): CanonicalizedSchemas {
|
|
40
|
+
const expectedByName = new Map<string, MongoSchemaCollection>();
|
|
41
|
+
for (const c of expected.collections) expectedByName.set(c.name, c);
|
|
42
|
+
|
|
43
|
+
const liveByName = new Map<string, MongoSchemaCollection>();
|
|
44
|
+
for (const c of live.collections) liveByName.set(c.name, c);
|
|
45
|
+
|
|
46
|
+
const canonicalLive = live.collections.map((c) =>
|
|
47
|
+
canonicalizeLiveCollection(c, expectedByName.get(c.name)),
|
|
48
|
+
);
|
|
49
|
+
const canonicalExpected = expected.collections.map((c) =>
|
|
50
|
+
canonicalizeExpectedCollection(c, liveByName.get(c.name)),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
live: new MongoSchemaIRCtor(canonicalLive),
|
|
55
|
+
expected: new MongoSchemaIRCtor(canonicalExpected),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function canonicalizeLiveCollection(
|
|
60
|
+
liveColl: MongoSchemaCollection,
|
|
61
|
+
expectedColl: MongoSchemaCollection | undefined,
|
|
62
|
+
): MongoSchemaCollection {
|
|
63
|
+
const expectedIndexes = expectedColl?.indexes ?? [];
|
|
64
|
+
const indexes = liveColl.indexes.map((idx) =>
|
|
65
|
+
canonicalizeLiveIndex(idx, findExpectedIndexCounterpart(idx, expectedIndexes)),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const options = liveColl.options
|
|
69
|
+
? canonicalizeLiveOptions(liveColl.options, expectedColl?.options)
|
|
70
|
+
: undefined;
|
|
71
|
+
|
|
72
|
+
return new MongoSchemaCollectionCtor({
|
|
73
|
+
name: liveColl.name,
|
|
74
|
+
indexes,
|
|
75
|
+
...ifDefined('validator', liveColl.validator),
|
|
76
|
+
...ifDefined('options', options),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function canonicalizeExpectedCollection(
|
|
81
|
+
expectedColl: MongoSchemaCollection,
|
|
82
|
+
liveColl: MongoSchemaCollection | undefined,
|
|
83
|
+
): MongoSchemaCollection {
|
|
84
|
+
// Symmetric text-index key ordering: a contract-shaped text index preserves
|
|
85
|
+
// the user-authored field order, but the introspected counterpart comes
|
|
86
|
+
// back from MongoDB with `weights` keys in alphabetical order, so we
|
|
87
|
+
// canonicalize both sides to alphabetical text-key order. The order of
|
|
88
|
+
// text fields within the text block is semantically irrelevant — relevance
|
|
89
|
+
// is governed by `weights`, not key order.
|
|
90
|
+
const indexes = expectedColl.indexes.map(canonicalizeTextIndexKeyOrder);
|
|
91
|
+
|
|
92
|
+
const options = expectedColl.options
|
|
93
|
+
? canonicalizeExpectedOptions(expectedColl.options, liveColl?.options)
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
return new MongoSchemaCollectionCtor({
|
|
97
|
+
name: expectedColl.name,
|
|
98
|
+
indexes,
|
|
99
|
+
...ifDefined('validator', expectedColl.validator),
|
|
100
|
+
...ifDefined('options', options),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function canonicalizeTextIndexKeyOrder(index: MongoSchemaIndex): MongoSchemaIndex {
|
|
105
|
+
const hasTextKey = index.keys.some((k) => k.direction === 'text');
|
|
106
|
+
if (!hasTextKey) return index;
|
|
107
|
+
return new MongoSchemaIndexCtor({
|
|
108
|
+
keys: sortTextKeys(index.keys),
|
|
109
|
+
unique: index.unique,
|
|
110
|
+
...ifDefined('sparse', index.sparse),
|
|
111
|
+
...ifDefined('expireAfterSeconds', index.expireAfterSeconds),
|
|
112
|
+
...ifDefined('partialFilterExpression', index.partialFilterExpression),
|
|
113
|
+
...ifDefined('wildcardProjection', index.wildcardProjection),
|
|
114
|
+
...ifDefined('collation', index.collation),
|
|
115
|
+
...ifDefined('weights', index.weights),
|
|
116
|
+
...ifDefined('default_language', index.default_language),
|
|
117
|
+
...ifDefined('language_override', index.language_override),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Returns a copy of `keys` with text-direction entries sorted alphabetically
|
|
123
|
+
* while preserving the relative position of non-text entries. Compound text
|
|
124
|
+
* indexes (`{a: 1, _fts: 'text', _ftsx: 1, b: 1}`) keep their scalar
|
|
125
|
+
* prefix/suffix layout; only the contiguous text block is reordered.
|
|
126
|
+
*/
|
|
127
|
+
function sortTextKeys(
|
|
128
|
+
keys: ReadonlyArray<{
|
|
129
|
+
readonly field: string;
|
|
130
|
+
readonly direction: 'text' | 1 | -1 | '2dsphere' | '2d' | 'hashed';
|
|
131
|
+
}>,
|
|
132
|
+
): ReadonlyArray<{
|
|
133
|
+
readonly field: string;
|
|
134
|
+
readonly direction: 'text' | 1 | -1 | '2dsphere' | '2d' | 'hashed';
|
|
135
|
+
}> {
|
|
136
|
+
const textEntries = keys.filter((k) => k.direction === 'text');
|
|
137
|
+
if (textEntries.length <= 1) return keys;
|
|
138
|
+
const sortedText = [...textEntries].sort((a, b) => a.field.localeCompare(b.field));
|
|
139
|
+
let textIdx = 0;
|
|
140
|
+
return keys.map((k) => {
|
|
141
|
+
if (k.direction !== 'text') return k;
|
|
142
|
+
const next = sortedText[textIdx++];
|
|
143
|
+
/* v8 ignore next 3 -- @preserve invariant guard: textIdx is always < sortedText.length here because we only consume sortedText for text-direction entries and sortedText is built from the same filter. */
|
|
144
|
+
if (next === undefined) {
|
|
145
|
+
throw new Error('sortTextKeys: text-key counts mismatched');
|
|
146
|
+
}
|
|
147
|
+
return next;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function canonicalizeLiveIndex(
|
|
152
|
+
liveIndex: MongoSchemaIndex,
|
|
153
|
+
expectedIndex: MongoSchemaIndex | undefined,
|
|
154
|
+
): MongoSchemaIndex {
|
|
155
|
+
const projectedKeys = sortTextKeys(projectTextIndexKeys(liveIndex));
|
|
156
|
+
const collation = liveIndex.collation
|
|
157
|
+
? stripUnspecifiedFields(liveIndex.collation, expectedIndex?.collation)
|
|
158
|
+
: liveIndex.collation;
|
|
159
|
+
|
|
160
|
+
// Text-index server defaults: when the contract did not set
|
|
161
|
+
// `weights`/`default_language`/`language_override`, MongoDB applies
|
|
162
|
+
// `weights = {<field>: 1, ...}` (uniform), `'english'`, and `'language'`
|
|
163
|
+
// respectively. Strip them from live *only* when the live value matches
|
|
164
|
+
// those defaults — preserving non-default live values lets the verifier
|
|
165
|
+
// surface drift when the live index is tampered (e.g. weights tuned
|
|
166
|
+
// out-of-band, custom `default_language`/`language_override`) even though
|
|
167
|
+
// the contract authored neither.
|
|
168
|
+
const weights =
|
|
169
|
+
expectedIndex?.weights === undefined && hasDefaultTextWeights(projectedKeys, liveIndex.weights)
|
|
170
|
+
? undefined
|
|
171
|
+
: liveIndex.weights;
|
|
172
|
+
const default_language =
|
|
173
|
+
expectedIndex?.default_language === undefined && liveIndex.default_language === 'english'
|
|
174
|
+
? undefined
|
|
175
|
+
: liveIndex.default_language;
|
|
176
|
+
const language_override =
|
|
177
|
+
expectedIndex?.language_override === undefined && liveIndex.language_override === 'language'
|
|
178
|
+
? undefined
|
|
179
|
+
: liveIndex.language_override;
|
|
180
|
+
|
|
181
|
+
return new MongoSchemaIndexCtor({
|
|
182
|
+
keys: projectedKeys,
|
|
183
|
+
unique: liveIndex.unique,
|
|
184
|
+
...ifDefined('sparse', liveIndex.sparse),
|
|
185
|
+
...ifDefined('expireAfterSeconds', liveIndex.expireAfterSeconds),
|
|
186
|
+
...ifDefined('partialFilterExpression', liveIndex.partialFilterExpression),
|
|
187
|
+
...ifDefined('wildcardProjection', liveIndex.wildcardProjection),
|
|
188
|
+
...ifDefined('collation', collation),
|
|
189
|
+
...ifDefined('weights', weights),
|
|
190
|
+
...ifDefined('default_language', default_language),
|
|
191
|
+
...ifDefined('language_override', language_override),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Locate the contract-side index that corresponds to a live index for the
|
|
197
|
+
* purpose of contract-aware normalization. We deliberately match by the
|
|
198
|
+
* *projected* (contract-shaped) key list — so a live `_fts/_ftsx` index
|
|
199
|
+
* resolves to a contract `{title: 'text', body: 'text'}` index — and pick
|
|
200
|
+
* the first match. Contracts very rarely contain duplicate-key indexes; if
|
|
201
|
+
* we have no counterpart we fall back to no normalization for that index.
|
|
202
|
+
*/
|
|
203
|
+
function findExpectedIndexCounterpart(
|
|
204
|
+
liveIndex: MongoSchemaIndex,
|
|
205
|
+
expectedIndexes: ReadonlyArray<MongoSchemaIndex>,
|
|
206
|
+
): MongoSchemaIndex | undefined {
|
|
207
|
+
const projectedLiveKeys = sortTextKeys(projectTextIndexKeys(liveIndex));
|
|
208
|
+
const liveKeySig = projectedLiveKeys.map((k) => `${k.field}:${k.direction}`).join(',');
|
|
209
|
+
for (const expected of expectedIndexes) {
|
|
210
|
+
const expectedKeySig = sortTextKeys(expected.keys)
|
|
211
|
+
.map((k) => `${k.field}:${k.direction}`)
|
|
212
|
+
.join(',');
|
|
213
|
+
if (expectedKeySig === liveKeySig) return expected;
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* MongoDB expands a contract-shaped text index like
|
|
220
|
+
* `[{title: 'text'}, {body: 'text'}]` into its internal weighted vector
|
|
221
|
+
* representation `[{_fts: 'text'}, {_ftsx: 1}]`. We project back to
|
|
222
|
+
* contract-shaped keys via `weights`, iterating in whatever order MongoDB
|
|
223
|
+
* returns them (alphabetical, in practice). `sortTextKeys` is applied
|
|
224
|
+
* downstream to canonicalize the order on both sides, so this projection
|
|
225
|
+
* does not depend on a specific iteration order.
|
|
226
|
+
*/
|
|
227
|
+
function projectTextIndexKeys(liveIndex: MongoSchemaIndex): ReadonlyArray<{
|
|
228
|
+
readonly field: string;
|
|
229
|
+
readonly direction: 'text' | 1 | -1 | '2dsphere' | '2d' | 'hashed';
|
|
230
|
+
}> {
|
|
231
|
+
const isTextIndex =
|
|
232
|
+
liveIndex.keys.length >= 1 &&
|
|
233
|
+
liveIndex.keys.some((k) => k.field === '_fts' && k.direction === 'text');
|
|
234
|
+
|
|
235
|
+
if (!isTextIndex || !liveIndex.weights) return liveIndex.keys;
|
|
236
|
+
|
|
237
|
+
const textKeys = Object.keys(liveIndex.weights).map((field) => ({
|
|
238
|
+
field,
|
|
239
|
+
direction: 'text' as const,
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
// Splice the projected text fields into the original `_fts/_ftsx` slot so
|
|
243
|
+
// compound text indexes that mix scalar prefixes *and* suffixes — e.g.
|
|
244
|
+
// `[prefix, _fts, _ftsx, suffix]` — keep their original layout. Flattening
|
|
245
|
+
// scalars first would yield `[prefix, suffix, ...text]`, which `sortTextKeys`
|
|
246
|
+
// (downstream) cannot recover because it only reorders text-direction
|
|
247
|
+
// entries within their existing positions. MongoDB always emits exactly one
|
|
248
|
+
// `_fts`/`_ftsx` pair per index, so we don't need to guard against
|
|
249
|
+
// duplicates.
|
|
250
|
+
type IndexKey = (typeof liveIndex.keys)[number];
|
|
251
|
+
const projectedKeys: IndexKey[] = [];
|
|
252
|
+
for (const key of liveIndex.keys) {
|
|
253
|
+
if (key.field === '_ftsx') continue;
|
|
254
|
+
if (key.field === '_fts') {
|
|
255
|
+
projectedKeys.push(...textKeys);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
projectedKeys.push(key);
|
|
259
|
+
}
|
|
260
|
+
return projectedKeys;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* MongoDB's server-default `weights` for an authored-without-weights text
|
|
265
|
+
* index assigns `1` to every text-direction field. Returns `true` only when
|
|
266
|
+
* `liveWeights` is exactly that uniform shape (every projected text-direction
|
|
267
|
+
* key weighted at `1`) so the canonicalizer leaves non-default weights —
|
|
268
|
+
* including out-of-band relevance tweaks — visible to the verifier.
|
|
269
|
+
*
|
|
270
|
+
* `projectTextIndexKeys` derives text-direction keys from the live weights
|
|
271
|
+
* map, so the count is guaranteed to match; we only have to check the value
|
|
272
|
+
* shape.
|
|
273
|
+
*/
|
|
274
|
+
function hasDefaultTextWeights(
|
|
275
|
+
projectedKeys: ReadonlyArray<{
|
|
276
|
+
readonly field: string;
|
|
277
|
+
readonly direction: 'text' | 1 | -1 | '2dsphere' | '2d' | 'hashed';
|
|
278
|
+
}>,
|
|
279
|
+
liveWeights: MongoSchemaIndex['weights'],
|
|
280
|
+
): boolean {
|
|
281
|
+
if (liveWeights === undefined) return false;
|
|
282
|
+
const textFields = projectedKeys.filter((k) => k.direction === 'text').map((k) => k.field);
|
|
283
|
+
return textFields.every((field) => liveWeights[field] === 1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function canonicalizeLiveOptions(
|
|
287
|
+
liveOptions: MongoSchemaCollectionOptions,
|
|
288
|
+
expectedOptions: MongoSchemaCollectionOptions | undefined,
|
|
289
|
+
): MongoSchemaCollectionOptions | undefined {
|
|
290
|
+
const collation = liveOptions.collation
|
|
291
|
+
? stripUnspecifiedFields(liveOptions.collation, expectedOptions?.collation)
|
|
292
|
+
: undefined;
|
|
293
|
+
|
|
294
|
+
// Timeseries: drop `bucketMaxSpanSeconds` (and any other server-applied
|
|
295
|
+
// extras) when the contract did not specify them.
|
|
296
|
+
const timeseries = liveOptions.timeseries
|
|
297
|
+
? (stripUnspecifiedFields(
|
|
298
|
+
liveOptions.timeseries as Record<string, unknown>,
|
|
299
|
+
expectedOptions?.timeseries as Record<string, unknown> | undefined,
|
|
300
|
+
) as MongoSchemaCollectionOptions['timeseries'])
|
|
301
|
+
: undefined;
|
|
302
|
+
|
|
303
|
+
// ClusteredIndex: drop `key`, `unique`, `v` and any other server-applied
|
|
304
|
+
// extras when the contract did not specify them.
|
|
305
|
+
const clusteredIndex = liveOptions.clusteredIndex
|
|
306
|
+
? (stripUnspecifiedFields(
|
|
307
|
+
liveOptions.clusteredIndex as Record<string, unknown>,
|
|
308
|
+
expectedOptions?.clusteredIndex as Record<string, unknown> | undefined,
|
|
309
|
+
) as MongoSchemaCollectionOptions['clusteredIndex'])
|
|
310
|
+
: undefined;
|
|
311
|
+
|
|
312
|
+
// changeStreamPreAndPostImages: `{enabled: false}` is equivalent to
|
|
313
|
+
// "absent". Strip it from live so it round-trips with a contract that
|
|
314
|
+
// omits the field, and is symmetric with the expected-side stripping.
|
|
315
|
+
const changeStreamPreAndPostImages = isDisabledChangeStream(
|
|
316
|
+
liveOptions.changeStreamPreAndPostImages,
|
|
317
|
+
)
|
|
318
|
+
? undefined
|
|
319
|
+
: liveOptions.changeStreamPreAndPostImages;
|
|
320
|
+
|
|
321
|
+
const hasMeaningful =
|
|
322
|
+
liveOptions.capped || timeseries || collation || changeStreamPreAndPostImages || clusteredIndex;
|
|
323
|
+
if (!hasMeaningful) return undefined;
|
|
324
|
+
|
|
325
|
+
return new MongoSchemaCollectionOptionsCtor({
|
|
326
|
+
...ifDefined('capped', liveOptions.capped),
|
|
327
|
+
...ifDefined('timeseries', timeseries),
|
|
328
|
+
...ifDefined('collation', collation),
|
|
329
|
+
...ifDefined('changeStreamPreAndPostImages', changeStreamPreAndPostImages),
|
|
330
|
+
...ifDefined('clusteredIndex', clusteredIndex),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function canonicalizeExpectedOptions(
|
|
335
|
+
expectedOptions: MongoSchemaCollectionOptions,
|
|
336
|
+
_liveOptions: MongoSchemaCollectionOptions | undefined,
|
|
337
|
+
): MongoSchemaCollectionOptions | undefined {
|
|
338
|
+
// Symmetric: a contract `{enabled: false}` is equivalent to absent.
|
|
339
|
+
const changeStreamPreAndPostImages = isDisabledChangeStream(
|
|
340
|
+
expectedOptions.changeStreamPreAndPostImages,
|
|
341
|
+
)
|
|
342
|
+
? undefined
|
|
343
|
+
: expectedOptions.changeStreamPreAndPostImages;
|
|
344
|
+
|
|
345
|
+
const hasMeaningful =
|
|
346
|
+
expectedOptions.capped ||
|
|
347
|
+
expectedOptions.timeseries ||
|
|
348
|
+
expectedOptions.collation ||
|
|
349
|
+
changeStreamPreAndPostImages ||
|
|
350
|
+
expectedOptions.clusteredIndex;
|
|
351
|
+
if (!hasMeaningful) return undefined;
|
|
352
|
+
|
|
353
|
+
return new MongoSchemaCollectionOptionsCtor({
|
|
354
|
+
...ifDefined('capped', expectedOptions.capped),
|
|
355
|
+
...ifDefined('timeseries', expectedOptions.timeseries),
|
|
356
|
+
...ifDefined('collation', expectedOptions.collation),
|
|
357
|
+
...ifDefined('changeStreamPreAndPostImages', changeStreamPreAndPostImages),
|
|
358
|
+
...ifDefined('clusteredIndex', expectedOptions.clusteredIndex),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isDisabledChangeStream(value: { enabled: boolean } | undefined): boolean {
|
|
363
|
+
return value !== undefined && value.enabled === false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Returns a copy of `live` containing only the keys that `expected` defines.
|
|
368
|
+
* Used for option families whose individual sub-fields are server-extended
|
|
369
|
+
* with platform defaults (collation, timeseries, clusteredIndex), so the
|
|
370
|
+
* verifier should compare only what the contract actually authored.
|
|
371
|
+
*
|
|
372
|
+
* When `expected` is `undefined` — i.e. the contract authored nothing for
|
|
373
|
+
* this whole option family but the live IR has it — we return `live`
|
|
374
|
+
* unchanged so the verifier still sees the entire live block and can
|
|
375
|
+
* surface it as drift. (Returning `undefined` here would silently strip a
|
|
376
|
+
* server-attached collation/timeseries/clusteredIndex that the contract
|
|
377
|
+
* never asked for, hiding real drift.)
|
|
378
|
+
*/
|
|
379
|
+
function stripUnspecifiedFields(
|
|
380
|
+
live: Record<string, unknown>,
|
|
381
|
+
expected: Record<string, unknown> | undefined,
|
|
382
|
+
): Record<string, unknown> {
|
|
383
|
+
if (expected === undefined) return live;
|
|
384
|
+
const out: Record<string, unknown> = {};
|
|
385
|
+
for (const key of Object.keys(expected)) {
|
|
386
|
+
if (Object.hasOwn(live, key)) out[key] = live[key];
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
|
|
2
|
+
import type {
|
|
3
|
+
OperationContext,
|
|
4
|
+
VerifyDatabaseSchemaResult,
|
|
5
|
+
} from '@prisma-next/framework-components/control';
|
|
6
|
+
import { VERIFY_CODE_SCHEMA_FAILURE } from '@prisma-next/framework-components/control';
|
|
7
|
+
import type { MongoContract } from '@prisma-next/mongo-contract';
|
|
8
|
+
import type { MongoSchemaIR } from '@prisma-next/mongo-schema-ir';
|
|
9
|
+
import { ifDefined } from '@prisma-next/utils/defined';
|
|
10
|
+
import { contractToMongoSchemaIR } from '../contract-to-schema';
|
|
11
|
+
import { diffMongoSchemas } from '../schema-diff';
|
|
12
|
+
import { canonicalizeSchemasForVerification } from './canonicalize-introspection';
|
|
13
|
+
|
|
14
|
+
export interface VerifyMongoSchemaOptions {
|
|
15
|
+
readonly contract: MongoContract;
|
|
16
|
+
readonly schema: MongoSchemaIR;
|
|
17
|
+
readonly strict: boolean;
|
|
18
|
+
readonly context?: OperationContext;
|
|
19
|
+
/**
|
|
20
|
+
* Active framework components participating in this composition. Mongo
|
|
21
|
+
* verification does not currently consult them, but the parameter exists
|
|
22
|
+
* for parity with `verifySqlSchema` so callers can pass the same envelope.
|
|
23
|
+
*/
|
|
24
|
+
readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'mongo', string>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function verifyMongoSchema(options: VerifyMongoSchemaOptions): VerifyDatabaseSchemaResult {
|
|
28
|
+
const { contract, schema, strict, context } = options;
|
|
29
|
+
const startTime = Date.now();
|
|
30
|
+
|
|
31
|
+
const expectedIR = contractToMongoSchemaIR(contract);
|
|
32
|
+
// Strip server-applied defaults (and authored equivalents) before diffing so
|
|
33
|
+
// the verifier compares like-with-like — see `canonicalize-introspection.ts`.
|
|
34
|
+
const { live: canonicalLive, expected: canonicalExpected } = canonicalizeSchemasForVerification(
|
|
35
|
+
schema,
|
|
36
|
+
expectedIR,
|
|
37
|
+
);
|
|
38
|
+
const { root, issues, counts } = diffMongoSchemas(canonicalLive, canonicalExpected, strict);
|
|
39
|
+
|
|
40
|
+
const ok = counts.fail === 0;
|
|
41
|
+
const profileHash = typeof contract.profileHash === 'string' ? contract.profileHash : '';
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
ok,
|
|
45
|
+
...ifDefined('code', ok ? undefined : VERIFY_CODE_SCHEMA_FAILURE),
|
|
46
|
+
summary: ok ? 'Schema matches contract' : `Schema verification found ${counts.fail} issue(s)`,
|
|
47
|
+
contract: {
|
|
48
|
+
storageHash: contract.storage.storageHash,
|
|
49
|
+
...(profileHash ? { profileHash } : {}),
|
|
50
|
+
},
|
|
51
|
+
target: { expected: contract.target },
|
|
52
|
+
schema: { issues, root, counts },
|
|
53
|
+
meta: {
|
|
54
|
+
strict,
|
|
55
|
+
...ifDefined('contractPath', context?.contractPath),
|
|
56
|
+
...ifDefined('configPath', context?.configPath),
|
|
57
|
+
},
|
|
58
|
+
timings: { total: Date.now() - startTime },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { MongoControlAdapter, MongoControlAdapterDescriptor } from '../core/control-adapter';
|
package/src/exports/control.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
export { contractToMongoSchemaIR } from '../core/contract-to-schema';
|
|
1
2
|
export { mongoFamilyDescriptor } from '../core/control-descriptor';
|
|
2
3
|
export {
|
|
3
4
|
createMongoFamilyInstance,
|
|
4
5
|
type MongoControlFamilyInstance,
|
|
5
6
|
} from '../core/control-instance';
|
|
7
|
+
export type { MongoControlTargetDescriptor } from '../core/control-target-descriptor';
|
|
6
8
|
export type { MongoControlExtensionDescriptor } from '../core/control-types';
|
|
7
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
formatMongoOperations,
|
|
11
|
+
mongoOperationsToPreview,
|
|
12
|
+
} from '../core/operation-preview';
|
|
13
|
+
export { diffMongoSchemas } from '../core/schema-diff';
|
|
14
|
+
export { canonicalizeSchemasForVerification } from '../core/schema-verify/canonicalize-introspection';
|
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
type VerifyMongoSchemaOptions,
|
|
3
|
+
verifyMongoSchema,
|
|
4
|
+
} from '../core/schema-verify/verify-mongo-schema';
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { createMongoRunnerDeps, extractDb } from '@prisma-next/adapter-mongo/control';
|
|
2
|
-
import type { Contract } from '@prisma-next/contract/types';
|
|
3
|
-
import { MongoDriverImpl } from '@prisma-next/driver-mongo';
|
|
4
|
-
import type {
|
|
5
|
-
MigratableTargetDescriptor,
|
|
6
|
-
MigrationRunner,
|
|
7
|
-
MigrationRunnerResult,
|
|
8
|
-
MigrationRunnerSuccessValue,
|
|
9
|
-
MultiSpaceCapableRunner,
|
|
10
|
-
MultiSpaceRunnerFailure,
|
|
11
|
-
MultiSpaceRunnerPerSpaceOptions,
|
|
12
|
-
MultiSpaceRunnerResult,
|
|
13
|
-
} from '@prisma-next/framework-components/control';
|
|
14
|
-
import {
|
|
15
|
-
type ContractSpaceMember,
|
|
16
|
-
projectSchemaToSpace,
|
|
17
|
-
} from '@prisma-next/migration-tools/aggregate';
|
|
18
|
-
import type { MongoContract } from '@prisma-next/mongo-contract';
|
|
19
|
-
import type { MongoSchemaCollection } from '@prisma-next/mongo-schema-ir';
|
|
20
|
-
import { MongoSchemaIR } from '@prisma-next/mongo-schema-ir';
|
|
21
|
-
import {
|
|
22
|
-
contractToMongoSchemaIR,
|
|
23
|
-
MongoMigrationPlanner,
|
|
24
|
-
MongoMigrationRunner,
|
|
25
|
-
type MongoMigrationRunnerExecuteOptions,
|
|
26
|
-
type MongoRunnerDependencies,
|
|
27
|
-
} from '@prisma-next/target-mongo/control';
|
|
28
|
-
import mongoTargetDescriptorMeta from '@prisma-next/target-mongo/pack';
|
|
29
|
-
import { notOk, ok } from '@prisma-next/utils/result';
|
|
30
|
-
import type { MongoControlFamilyInstance } from './control-instance';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* `migration.ts` default-exports a `Migration` subclass whose `operations`
|
|
34
|
-
* getter returns the ordered list of operations and whose `describe()`
|
|
35
|
-
* returns the manifest identity metadata. `MongoMigrationPlanner.plan()`
|
|
36
|
-
* returns a `MigrationPlanWithAuthoringSurface` that knows how to render
|
|
37
|
-
* itself back to such a file; `MongoMigrationPlanner.emptyMigration()`
|
|
38
|
-
* returns the same shape for `migration new`. Users run the scaffolded
|
|
39
|
-
* `migration.ts` directly (via `node migration.ts`) to self-emit
|
|
40
|
-
* `ops.json` and attest the `migrationHash`.
|
|
41
|
-
*/
|
|
42
|
-
export const mongoTargetDescriptor: MigratableTargetDescriptor<
|
|
43
|
-
'mongo',
|
|
44
|
-
'mongo',
|
|
45
|
-
MongoControlFamilyInstance
|
|
46
|
-
> = {
|
|
47
|
-
...mongoTargetDescriptorMeta,
|
|
48
|
-
migrations: {
|
|
49
|
-
createPlanner(_family: MongoControlFamilyInstance) {
|
|
50
|
-
return new MongoMigrationPlanner();
|
|
51
|
-
},
|
|
52
|
-
createRunner(family: MongoControlFamilyInstance) {
|
|
53
|
-
// Deps are bound to the first driver passed to execute() and cached for
|
|
54
|
-
// subsequent calls. Callers must not change the driver between calls.
|
|
55
|
-
let cachedDeps: MongoRunnerDependencies | undefined;
|
|
56
|
-
|
|
57
|
-
const runMongo = async (
|
|
58
|
-
driver: Parameters<MigrationRunner<'mongo', 'mongo'>['execute']>[0]['driver'],
|
|
59
|
-
runnerOptions: Omit<MongoMigrationRunnerExecuteOptions, 'destinationContract'> & {
|
|
60
|
-
readonly destinationContract: unknown;
|
|
61
|
-
},
|
|
62
|
-
): Promise<MigrationRunnerResult> => {
|
|
63
|
-
cachedDeps ??= createMongoRunnerDeps(
|
|
64
|
-
driver,
|
|
65
|
-
MongoDriverImpl.fromDb(extractDb(driver)),
|
|
66
|
-
family,
|
|
67
|
-
);
|
|
68
|
-
// The framework `MigrationRunner` interface types `destinationContract`
|
|
69
|
-
// as `unknown`; the Mongo runner narrows to `MongoContract`. Validation
|
|
70
|
-
// happens upstream — `migration apply` calls
|
|
71
|
-
// `familyInstance.validateContract(migration.toContract)` before
|
|
72
|
-
// routing the contract here, so this cast preserves the framework
|
|
73
|
-
// signature without weakening the runner's typed surface.
|
|
74
|
-
return new MongoMigrationRunner(cachedDeps).execute({
|
|
75
|
-
...runnerOptions,
|
|
76
|
-
destinationContract: runnerOptions.destinationContract as MongoContract,
|
|
77
|
-
});
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const runner: MigrationRunner<'mongo', 'mongo'> & MultiSpaceCapableRunner<'mongo', 'mongo'> =
|
|
81
|
-
{
|
|
82
|
-
async execute(options) {
|
|
83
|
-
const { driver, ...runnerOptions } = options;
|
|
84
|
-
return runMongo(driver, runnerOptions);
|
|
85
|
-
},
|
|
86
|
-
// Mongo cannot wrap DDL ops in a session transaction (createCollection,
|
|
87
|
-
// createIndex, collMod, setValidation all bypass transactions even on
|
|
88
|
-
// replica sets), so the cross-space envelope is *resumable* rather than
|
|
89
|
-
// transactional. Per-space-internal verify-gated marker atomicity
|
|
90
|
-
// already lives in `runner.execute`: ops apply, schema is introspected
|
|
91
|
-
// and verified, and the marker advances only on verify-pass. This loop
|
|
92
|
-
// composes that guarantee across spaces — earlier-advanced markers are
|
|
93
|
-
// not rolled back when a later space fails. Re-running reads each
|
|
94
|
-
// marker, finds spaces 1..N−1 at-head (no-op skip), retries N onward.
|
|
95
|
-
//
|
|
96
|
-
// Per-space verify is sliced via `projectSchemaToSpace`: the live DB
|
|
97
|
-
// holds collections owned by sibling spaces, but each space's verify
|
|
98
|
-
// only sees the slice that space's contract actually claims. Without
|
|
99
|
-
// the projection an aggregate of two spaces could not pass strict
|
|
100
|
-
// verify (every other-space collection would look like an extra).
|
|
101
|
-
//
|
|
102
|
-
// See `docs/architecture docs/subsystems/10. MongoDB Family.md` §
|
|
103
|
-
// Contract spaces and ADR 212 — Contract spaces.
|
|
104
|
-
async executeAcrossSpaces({ driver, perSpaceOptions }): Promise<MultiSpaceRunnerResult> {
|
|
105
|
-
const members = perSpaceOptions.map(toSpaceMember);
|
|
106
|
-
const perSpaceResults: Array<{
|
|
107
|
-
space: string;
|
|
108
|
-
value: MigrationRunnerSuccessValue;
|
|
109
|
-
}> = [];
|
|
110
|
-
for (let i = 0; i < perSpaceOptions.length; i++) {
|
|
111
|
-
const spaceOptions = perSpaceOptions[i];
|
|
112
|
-
if (!spaceOptions) continue;
|
|
113
|
-
const member = members[i];
|
|
114
|
-
if (!member) continue;
|
|
115
|
-
const others = members.filter((_, j) => j !== i);
|
|
116
|
-
const projectSchema = (schema: MongoSchemaIR): MongoSchemaIR => {
|
|
117
|
-
// `projectSchemaToSpace` returns a plain object
|
|
118
|
-
// `{...schemaIR, collections: prunedArray}` (not a
|
|
119
|
-
// `MongoSchemaIR` instance), so the descriptor rewraps
|
|
120
|
-
// the pruned collections into a fresh `MongoSchemaIR`
|
|
121
|
-
// before handing it to `verifyMongoSchema` (which
|
|
122
|
-
// depends on the class's `collectionNames` /
|
|
123
|
-
// `collection(name)` accessors).
|
|
124
|
-
const projected = projectSchemaToSpace(schema, member, others) as {
|
|
125
|
-
readonly collections: ReadonlyArray<MongoSchemaCollection>;
|
|
126
|
-
};
|
|
127
|
-
return new MongoSchemaIR(projected.collections);
|
|
128
|
-
};
|
|
129
|
-
const result = await runMongo(driver, { ...spaceOptions, projectSchema });
|
|
130
|
-
if (!result.ok) {
|
|
131
|
-
return notOk<MultiSpaceRunnerFailure>({
|
|
132
|
-
...result.failure,
|
|
133
|
-
failingSpace: spaceOptions.space,
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
perSpaceResults.push({ space: spaceOptions.space, value: result.value });
|
|
137
|
-
}
|
|
138
|
-
return ok({ perSpaceResults });
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
return runner;
|
|
142
|
-
},
|
|
143
|
-
contractToSchema(contract: Contract | null) {
|
|
144
|
-
return contractToMongoSchemaIR(contract as MongoContract | null);
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
create() {
|
|
148
|
-
return { familyId: 'mongo' as const, targetId: 'mongo' as const };
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Synthesise the minimum {@link projectSchemaToSpace}-compatible
|
|
154
|
-
* `ContractSpaceMember` shape from a per-space option entry. The
|
|
155
|
-
* projector only reads `spaceId` and `contract.storage`; the rest of
|
|
156
|
-
* `ContractSpaceMember` (head ref invariants, hydrated migration
|
|
157
|
-
* graph) is irrelevant at runner time and stubbed with sentinels.
|
|
158
|
-
*
|
|
159
|
-
* The `as unknown as ContractSpaceMember` cast is the load-bearing bit
|
|
160
|
-
* — the projector duck-types its members so a sentinel-shaped graph
|
|
161
|
-
* never gets read, but the framework type carries a richer shape.
|
|
162
|
-
*/
|
|
163
|
-
function toSpaceMember(
|
|
164
|
-
opts: MultiSpaceRunnerPerSpaceOptions<'mongo', 'mongo'>,
|
|
165
|
-
): ContractSpaceMember {
|
|
166
|
-
return {
|
|
167
|
-
spaceId: opts.space,
|
|
168
|
-
contract: opts.destinationContract as Contract,
|
|
169
|
-
headRef: { hash: '', invariants: [] },
|
|
170
|
-
migrations: {
|
|
171
|
-
graph: {
|
|
172
|
-
nodes: new Set<string>(),
|
|
173
|
-
forwardChain: new Map(),
|
|
174
|
-
reverseChain: new Map(),
|
|
175
|
-
migrationByHash: new Map(),
|
|
176
|
-
},
|
|
177
|
-
packagesByMigrationHash: new Map(),
|
|
178
|
-
},
|
|
179
|
-
} as unknown as ContractSpaceMember;
|
|
180
|
-
}
|