@alexify/migronaut 2.2.0 → 2.4.0
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/CHANGELOG.md +190 -0
- package/README.md +41 -3
- package/bullmq.d.ts +484 -8
- package/index.d.ts +1264 -9
- package/migronaut.schema.json +93 -1
- package/package.json +9 -2
- package/src/bullmq/background-processor.js +541 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +348 -21
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +484 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/core/audit.js +11 -1
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +375 -0
- package/src/core/background-engine.js +849 -0
- package/src/core/background-kit.js +432 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +610 -0
- package/src/core/background.js +1127 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +78 -8
- package/src/core/config.js +102 -12
- package/src/core/converge-plan.js +86 -7
- package/src/core/converge.js +88 -0
- package/src/core/lock.js +48 -21
- package/src/core/migration-logger.js +279 -0
- package/src/core/migrator.js +1027 -22
- package/src/core/options.js +36 -0
- package/src/core/run-recorder.js +6 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +34 -8
- package/src/core/server-info.js +9 -2
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +88 -0
- package/src/index.js +16 -0
- package/src/utils/error.js +11 -2
- package/src/utils/job-ref.js +44 -0
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/redact.js +140 -3
- package/src/utils/telemetry.js +110 -0
- package/src/utils/template.js +62 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
package/versioning.d.ts
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@alexify/migronaut/versioning` — document versioning and optimistic
|
|
3
|
+
* concurrency for an application's repository layer.
|
|
4
|
+
*
|
|
5
|
+
* Structural like the rest of migronaut's types: nothing here imports the
|
|
6
|
+
* MongoDB driver or mongoose. A driver `Collection` (or a Mongoose model's
|
|
7
|
+
* `collection`) satisfies {@link RevisionedCollectionLike} as it is.
|
|
8
|
+
*
|
|
9
|
+
* @experimental New in 2.3 — the shape may still change in a minor release
|
|
10
|
+
* (named in the CHANGELOG).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
Body,
|
|
15
|
+
CollectionDefinition,
|
|
16
|
+
CollectionDefinitionFile,
|
|
17
|
+
CollectionVersioning,
|
|
18
|
+
DeclarativeBackgroundMigration,
|
|
19
|
+
DefaultShapeFieldNames,
|
|
20
|
+
ShapeFieldNames,
|
|
21
|
+
} from './index.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
ConfigInvalidError,
|
|
25
|
+
MigronautError,
|
|
26
|
+
RevisionConflictError,
|
|
27
|
+
ShapeVersionError,
|
|
28
|
+
} from './index.js';
|
|
29
|
+
export type { RevisionConflictContext } from './index.js';
|
|
30
|
+
|
|
31
|
+
// ─── Documents and updates ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** A query filter, as the driver takes it */
|
|
34
|
+
export type FilterLike = Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
/** An operator update (`{ $set: … }`) or an aggregation pipeline */
|
|
37
|
+
export type UpdateLike = Record<string, unknown> | readonly Record<string, unknown>[];
|
|
38
|
+
|
|
39
|
+
/** A versioning block with every default filled in, as `defineShapes().get()` returns it */
|
|
40
|
+
export interface ResolvedVersioning {
|
|
41
|
+
readonly current: number;
|
|
42
|
+
readonly min: number;
|
|
43
|
+
readonly field: string;
|
|
44
|
+
readonly revision: boolean;
|
|
45
|
+
/** `null` when `revision: false` */
|
|
46
|
+
readonly revisionField: string | null;
|
|
47
|
+
readonly index: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Optimistic concurrency ───────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** What a driver `updateOne` / `replaceOne` resolves to — the fields migronaut reads */
|
|
53
|
+
export interface RevisionWriteResultLike {
|
|
54
|
+
acknowledged?: boolean;
|
|
55
|
+
matchedCount: number;
|
|
56
|
+
modifiedCount: number;
|
|
57
|
+
upsertedCount?: number;
|
|
58
|
+
upsertedId?: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The collection methods the revision helpers call. A driver `Collection`
|
|
63
|
+
* satisfies it; so does anything else with the same methods.
|
|
64
|
+
*/
|
|
65
|
+
export interface RevisionedCollectionLike {
|
|
66
|
+
readonly collectionName?: string;
|
|
67
|
+
updateOne(filter: any, update: any, options?: any): Promise<RevisionWriteResultLike>;
|
|
68
|
+
replaceOne(filter: any, replacement: any, options?: any): Promise<RevisionWriteResultLike>;
|
|
69
|
+
findOneAndUpdate(filter: any, update: any, options: any): Promise<any>;
|
|
70
|
+
findOne(filter: any, options?: any): Promise<unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Options of a revision-guarded write. Everything not listed here goes to the
|
|
75
|
+
* driver (`session`, `hint`, `collation`, `arrayFilters`, …). `upsert` is
|
|
76
|
+
* refused: a miss would insert a second document instead of reporting the
|
|
77
|
+
* conflict — and so is an unacknowledged write (`w: 0`). The filter must
|
|
78
|
+
* name one document: an `_id` given as an operator is refused (`$eq` of a
|
|
79
|
+
* value aside), and a filter on other fields must be unique — every legacy
|
|
80
|
+
* document is at revision 0.
|
|
81
|
+
* @experimental New in 2.3
|
|
82
|
+
*/
|
|
83
|
+
export interface RevisionWriteOptions {
|
|
84
|
+
/** The version field — needed only with `version`. Default `'__v'` */
|
|
85
|
+
field?: string;
|
|
86
|
+
/** The revision field. Default `'__rev'` */
|
|
87
|
+
revisionField?: string;
|
|
88
|
+
/** Also set the version field to this (an upgrade written on the way) */
|
|
89
|
+
version?: number;
|
|
90
|
+
/**
|
|
91
|
+
* On a miss, read the document once more to say why (`conflict` with the
|
|
92
|
+
* revision found, or `not-found`). Default `true`; `false` reports `unknown`.
|
|
93
|
+
*/
|
|
94
|
+
verify?: boolean;
|
|
95
|
+
upsert?: false;
|
|
96
|
+
session?: unknown;
|
|
97
|
+
[driverOption: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A revision as the driver may hand it back: a number, or — read with
|
|
102
|
+
* `promoteValues: false` or `useBigInt64` — an `Int32`, a `Long` or a bigint.
|
|
103
|
+
*/
|
|
104
|
+
export type RevisionValue = number | bigint | { toNumber(): number } | { valueOf(): number };
|
|
105
|
+
|
|
106
|
+
/** The driver's result plus the document's new revision */
|
|
107
|
+
export type RevisionWriteResult<R extends RevisionWriteResultLike = RevisionWriteResultLike> =
|
|
108
|
+
R & { revision: number };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `updateOne` that only lands if the document is still at `expectedRevision`
|
|
112
|
+
* (the `__rev` the caller read — 0 for a document without one), bumping it.
|
|
113
|
+
*
|
|
114
|
+
* @throws {RevisionConflictError} when nothing matched — `context.reason` is
|
|
115
|
+
* `'conflict'` (with `actual`), `'not-found'` or `'unknown'`
|
|
116
|
+
* @experimental New in 2.3
|
|
117
|
+
*/
|
|
118
|
+
export function updateWithRevision(
|
|
119
|
+
collection: RevisionedCollectionLike | Pick<RevisionedCollectionLike, 'updateOne' | 'findOne'>,
|
|
120
|
+
filter: FilterLike,
|
|
121
|
+
expectedRevision: RevisionValue,
|
|
122
|
+
update: UpdateLike,
|
|
123
|
+
options?: RevisionWriteOptions,
|
|
124
|
+
): Promise<RevisionWriteResult>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `replaceOne` that only lands if the document is still at `expectedRevision`.
|
|
128
|
+
* The replacement's revision field is overwritten with the next revision.
|
|
129
|
+
*
|
|
130
|
+
* @throws {RevisionConflictError} when nothing matched
|
|
131
|
+
* @experimental New in 2.3
|
|
132
|
+
*/
|
|
133
|
+
export function replaceWithRevision(
|
|
134
|
+
collection: RevisionedCollectionLike | Pick<RevisionedCollectionLike, 'replaceOne' | 'findOne'>,
|
|
135
|
+
filter: FilterLike,
|
|
136
|
+
expectedRevision: RevisionValue,
|
|
137
|
+
replacement: Record<string, unknown>,
|
|
138
|
+
options?: RevisionWriteOptions,
|
|
139
|
+
): Promise<RevisionWriteResult>;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `findOneAndUpdate` that only lands if the document is still at
|
|
143
|
+
* `expectedRevision`. Resolves to the document — after the update unless
|
|
144
|
+
* `returnDocument: 'before'` — on every driver version alike.
|
|
145
|
+
*
|
|
146
|
+
* @throws {RevisionConflictError} when nothing matched
|
|
147
|
+
* @experimental New in 2.3
|
|
148
|
+
*/
|
|
149
|
+
export function findOneAndUpdateWithRevision<TDocument = Record<string, unknown>>(
|
|
150
|
+
collection:
|
|
151
|
+
| RevisionedCollectionLike
|
|
152
|
+
| Pick<RevisionedCollectionLike, 'findOneAndUpdate' | 'findOne'>,
|
|
153
|
+
filter: FilterLike,
|
|
154
|
+
expectedRevision: RevisionValue,
|
|
155
|
+
update: UpdateLike,
|
|
156
|
+
options?: RevisionWriteOptions & { returnDocument?: 'before' | 'after' },
|
|
157
|
+
): Promise<TDocument>;
|
|
158
|
+
|
|
159
|
+
/** Full-jitter backoff bounds for {@link retryOnConflict} */
|
|
160
|
+
export interface RetryBackoff {
|
|
161
|
+
/** Default 10 */
|
|
162
|
+
baseMs?: number;
|
|
163
|
+
/** Default 1000 */
|
|
164
|
+
maxMs?: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface RetryOnConflictOptions {
|
|
168
|
+
/** Tries in all, the first included. Default 3 */
|
|
169
|
+
attempts?: number;
|
|
170
|
+
/** `{ baseMs, maxMs }`, or the wait (ms) before the next try after attempt n */
|
|
171
|
+
backoff?: RetryBackoff | ((attempt: number) => number);
|
|
172
|
+
/** Cuts a wait short (the promise rejects with the signal's reason) */
|
|
173
|
+
signal?: AbortSignal;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Run `fn` — read, decide, write with a revision guard — again after a
|
|
178
|
+
* revision conflict. A `not-found` is never retried; any other error is
|
|
179
|
+
* thrown at once.
|
|
180
|
+
* @experimental New in 2.3
|
|
181
|
+
*/
|
|
182
|
+
export function retryOnConflict<T>(
|
|
183
|
+
fn: (attempt: number) => T | Promise<T>,
|
|
184
|
+
options?: RetryOnConflictOptions,
|
|
185
|
+
): Promise<T>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `update` with the revision bumped — for a write that is not guarded but
|
|
189
|
+
* must still move the revision (every write to a collection with revisions
|
|
190
|
+
* must, or an optimistic filter cannot see it).
|
|
191
|
+
* @experimental New in 2.3
|
|
192
|
+
*/
|
|
193
|
+
export function bumpRevision<U extends UpdateLike>(
|
|
194
|
+
update: U,
|
|
195
|
+
options?: { revisionField?: string },
|
|
196
|
+
): U;
|
|
197
|
+
|
|
198
|
+
// ─── Shapes by version (type level) ───────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The document shapes of each collection by version, declared once:
|
|
202
|
+
*
|
|
203
|
+
* type Shapes = { orders: { 1: OrderV1; 2: OrderV2 } };
|
|
204
|
+
*
|
|
205
|
+
* Bodies are declared **without** the version field — the literal
|
|
206
|
+
* discriminant comes from the key, so the two can never disagree (a body that
|
|
207
|
+
* declares it anyway has it replaced). Keys may be numbers or numeric strings.
|
|
208
|
+
*/
|
|
209
|
+
export type ShapeMap = { [collection: string]: { [version: number]: object } };
|
|
210
|
+
|
|
211
|
+
/** `'2'` → `2`; numbers stay */
|
|
212
|
+
type ToVersion<K> = K extends number ? K : K extends `${infer N extends number}` ? N : never;
|
|
213
|
+
|
|
214
|
+
/** A tuple `n` long — for comparing small version numbers */
|
|
215
|
+
type Tuple<N extends number, T extends unknown[] = []> = T['length'] extends N
|
|
216
|
+
? T
|
|
217
|
+
: Tuple<N, [...T, unknown]>;
|
|
218
|
+
|
|
219
|
+
type GreaterThan<A extends number, B extends number> = number extends A | B
|
|
220
|
+
? false
|
|
221
|
+
: Tuple<A> extends [...Tuple<B>, unknown, ...unknown[]]
|
|
222
|
+
? true
|
|
223
|
+
: false;
|
|
224
|
+
|
|
225
|
+
type AnyGreater<Others extends number, P extends number> = Others extends number
|
|
226
|
+
? GreaterThan<Others, P>
|
|
227
|
+
: never;
|
|
228
|
+
|
|
229
|
+
/** The largest of a union of version numbers */
|
|
230
|
+
type MaxOf<K extends number> = { [P in K]: true extends AnyGreater<Exclude<K, P>, P> ? never : P }[K];
|
|
231
|
+
|
|
232
|
+
/** `n + 1` */
|
|
233
|
+
export type NextVersion<N extends number> = Extract<[...Tuple<N>, unknown]['length'], number>;
|
|
234
|
+
|
|
235
|
+
/** The versions declared for a collection */
|
|
236
|
+
export type Versions<S extends ShapeMap, C extends keyof S> = ToVersion<keyof S[C]>;
|
|
237
|
+
|
|
238
|
+
/** The highest declared version — the current one */
|
|
239
|
+
export type CurrentVersion<S extends ShapeMap, C extends keyof S> = MaxOf<Versions<S, C>>;
|
|
240
|
+
|
|
241
|
+
/** The body declared for a version (by number or numeric-string key) */
|
|
242
|
+
type BodyAt<S extends ShapeMap, C extends keyof S, V extends number> = V extends keyof S[C]
|
|
243
|
+
? S[C][V]
|
|
244
|
+
: `${V}` extends keyof S[C]
|
|
245
|
+
? S[C][`${V}` & keyof S[C]]
|
|
246
|
+
: never;
|
|
247
|
+
|
|
248
|
+
type Simplify<T> = { [K in keyof T]: T[K] } & {};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `T`, kept out of type-argument inference: a parameter typed with it is
|
|
252
|
+
* contextually typed from the other arguments instead of inferring from them
|
|
253
|
+
* (the built-in `NoInfer` needs TypeScript 5.4; this works from 5.0).
|
|
254
|
+
*/
|
|
255
|
+
type Hold<T> = [T][T extends unknown ? 0 : never];
|
|
256
|
+
|
|
257
|
+
/** The version field of a document at `V` — optional and `0 | null` for version 0 */
|
|
258
|
+
type VersionPart<V extends number, F extends string> = V extends 0
|
|
259
|
+
? { [K in F]?: 0 | null }
|
|
260
|
+
: { [K in F]: V };
|
|
261
|
+
|
|
262
|
+
/** The revision field — optional at version 0 (a document that predates versioning) */
|
|
263
|
+
type RevisionPart<V extends number, R extends string | null> = R extends string
|
|
264
|
+
? V extends 0
|
|
265
|
+
? { [K in R]?: number }
|
|
266
|
+
: { [K in R]: number }
|
|
267
|
+
: unknown;
|
|
268
|
+
|
|
269
|
+
/** A body at version `V`, with its system fields */
|
|
270
|
+
export type VersionMember<
|
|
271
|
+
V extends number,
|
|
272
|
+
B,
|
|
273
|
+
N extends ShapeFieldNames = DefaultShapeFieldNames,
|
|
274
|
+
> = B extends unknown
|
|
275
|
+
? Simplify<Body<B, N> & VersionPart<V, N['field']> & RevisionPart<V, N['revisionField']>>
|
|
276
|
+
: never;
|
|
277
|
+
|
|
278
|
+
/** A stored document of `C` at version `V` */
|
|
279
|
+
export type ShapeAt<
|
|
280
|
+
S extends ShapeMap,
|
|
281
|
+
C extends keyof S,
|
|
282
|
+
V extends number,
|
|
283
|
+
N extends ShapeFieldNames = DefaultShapeFieldNames,
|
|
284
|
+
> = VersionMember<V, BodyAt<S, C, V>, N>;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Any stored document of `C` — a union discriminated by the version field, so
|
|
288
|
+
* `switch (doc.__v)` (or `isVersion`) narrows it. A driver collection typed
|
|
289
|
+
* with it (`db.collection<AnyShape<Shapes, 'orders'>>('orders')`) narrows
|
|
290
|
+
* after `find`/`findOne` too.
|
|
291
|
+
*/
|
|
292
|
+
export type AnyShape<
|
|
293
|
+
S extends ShapeMap,
|
|
294
|
+
C extends keyof S,
|
|
295
|
+
N extends ShapeFieldNames = DefaultShapeFieldNames,
|
|
296
|
+
> = { [V in Versions<S, C>]: ShapeAt<S, C, V, N> }[Versions<S, C>];
|
|
297
|
+
|
|
298
|
+
/** A stored document of `C` at the current (highest) version */
|
|
299
|
+
export type CurrentShape<
|
|
300
|
+
S extends ShapeMap,
|
|
301
|
+
C extends keyof S,
|
|
302
|
+
N extends ShapeFieldNames = DefaultShapeFieldNames,
|
|
303
|
+
> = ShapeAt<S, C, CurrentVersion<S, C>, N>;
|
|
304
|
+
|
|
305
|
+
/** `T` with system fields at version `V` — what `stamp` returns */
|
|
306
|
+
export type Stamped<
|
|
307
|
+
T,
|
|
308
|
+
V extends number = number,
|
|
309
|
+
N extends ShapeFieldNames = DefaultShapeFieldNames,
|
|
310
|
+
> = VersionMember<V, T, N>;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A declarative background migration of `C` from version `F` to `T`, typed by
|
|
314
|
+
* the shape map: `migrate` takes the body at `F` and returns the body at `T`
|
|
315
|
+
* (the engine owns the system fields); `revert` the other way. An upcaster's
|
|
316
|
+
* `step(F)` fits as `migrate`.
|
|
317
|
+
*/
|
|
318
|
+
export type BackgroundMigrationFor<
|
|
319
|
+
S extends ShapeMap,
|
|
320
|
+
C extends keyof S & string,
|
|
321
|
+
F extends Versions<S, C>,
|
|
322
|
+
T extends Versions<S, C>,
|
|
323
|
+
N extends ShapeFieldNames = DefaultShapeFieldNames,
|
|
324
|
+
> = DeclarativeBackgroundMigration<Body<BodyAt<S, C, F>, N>, Body<BodyAt<S, C, T>, N>> & {
|
|
325
|
+
collection: C;
|
|
326
|
+
from: F;
|
|
327
|
+
to: T;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Whether `doc` is at version `version` (a missing field is version 0) — a
|
|
332
|
+
* type guard over a union of shapes. A document that is not a plain object
|
|
333
|
+
* (a hydrated Mongoose document) is refused: read it with `.lean()`.
|
|
334
|
+
* @experimental New in 2.3
|
|
335
|
+
*/
|
|
336
|
+
export function isVersion<D extends object, V extends number, F extends string = '__v'>(
|
|
337
|
+
doc: D,
|
|
338
|
+
version: V,
|
|
339
|
+
options?: { field?: F },
|
|
340
|
+
): doc is Extract<D, VersionPart<V, F>>;
|
|
341
|
+
|
|
342
|
+
// ─── Upcasting ────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
/** One shape change: the document at version n in, the document at n + 1 out */
|
|
345
|
+
export type UpcastStep = (doc: any) => Record<string, unknown>;
|
|
346
|
+
|
|
347
|
+
/** `{ [fromVersion]: step }` — one step for every version from `min` to `current - 1` */
|
|
348
|
+
export type UpcastSteps = Record<number, UpcastStep>;
|
|
349
|
+
|
|
350
|
+
export interface UpcasterOptions {
|
|
351
|
+
/**
|
|
352
|
+
* What `upcast` does with a document newer than `current` (written by a
|
|
353
|
+
* newer release): `'throw'` a {@link ShapeVersionError} (default) or
|
|
354
|
+
* `'keep'` it as is.
|
|
355
|
+
*/
|
|
356
|
+
newer?: 'throw' | 'keep';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The shape changes of one collection, usable as the `migrate` of a
|
|
361
|
+
* background migration (`step`) and — the exception — to lift a document in
|
|
362
|
+
* memory on read (`upcast`).
|
|
363
|
+
* @experimental New in 2.3
|
|
364
|
+
*/
|
|
365
|
+
export interface Upcaster {
|
|
366
|
+
readonly current: number;
|
|
367
|
+
readonly min: number;
|
|
368
|
+
readonly field: string;
|
|
369
|
+
/**
|
|
370
|
+
* The document in the current shape — a current one as is, an older one
|
|
371
|
+
* lifted on a copy (the version field set by the helper).
|
|
372
|
+
* @throws {ShapeVersionError} `'newer'`, `'below-min'` or `'invalid'`
|
|
373
|
+
*/
|
|
374
|
+
upcast(doc: object): Record<string, unknown>;
|
|
375
|
+
/** Whether `upcast` would change the document */
|
|
376
|
+
needsUpcast(doc: object): boolean;
|
|
377
|
+
/**
|
|
378
|
+
* The steps from `from` to `to` (default `from + 1`) as one function — the
|
|
379
|
+
* `migrate` of a background migration
|
|
380
|
+
*/
|
|
381
|
+
step(from: number, to?: number): (doc: any) => Record<string, unknown>;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* An upcaster for a collection definition (`{ versioning }`) or a bare
|
|
386
|
+
* versioning block.
|
|
387
|
+
* @throws {ConfigInvalidError} when a step between `min` and `current` is
|
|
388
|
+
* missing, goes past `current`, is async or is not a function
|
|
389
|
+
* @experimental New in 2.3
|
|
390
|
+
*/
|
|
391
|
+
export function upcaster(
|
|
392
|
+
definition: CollectionDefinitionFile | CollectionVersioning | { default: CollectionDefinitionFile },
|
|
393
|
+
steps: UpcastSteps,
|
|
394
|
+
options?: UpcasterOptions,
|
|
395
|
+
): Upcaster;
|
|
396
|
+
|
|
397
|
+
// ─── Mongoose ─────────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The schema methods the plugin uses — a Mongoose `Schema` satisfies it.
|
|
401
|
+
* Structural, so this file never imports mongoose.
|
|
402
|
+
*/
|
|
403
|
+
export interface MongooseSchemaLike {
|
|
404
|
+
set(key: any, value: any): unknown;
|
|
405
|
+
get(key: any): unknown;
|
|
406
|
+
add(definition: any): unknown;
|
|
407
|
+
path(name: any): unknown;
|
|
408
|
+
pre(hook: any, fn: any): unknown;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The Mongoose plugin for a versioned collection:
|
|
413
|
+
* `schema.plugin(versioningPlugin, require('./collections/orders'))`.
|
|
414
|
+
* The revision becomes the schema's `versionKey` with `optimisticConcurrency`
|
|
415
|
+
* (a stale `save()` throws Mongoose's `VersionError`); a new document is
|
|
416
|
+
* stamped with the current version (the path has no default, so a loaded
|
|
417
|
+
* legacy document never is); `updateOne`/`updateMany`/`findOneAndUpdate` bump
|
|
418
|
+
* the revision and stamp an upserted document. Lean `insertMany`, `bulkWrite`,
|
|
419
|
+
* replacements and pipeline updates are not covered.
|
|
420
|
+
* @experimental New in 2.3
|
|
421
|
+
*/
|
|
422
|
+
export function versioningPlugin(
|
|
423
|
+
schema: MongooseSchemaLike,
|
|
424
|
+
definition: CollectionDefinitionFile | CollectionVersioning | { default: CollectionDefinitionFile },
|
|
425
|
+
): void;
|
|
426
|
+
|
|
427
|
+
// ─── The registry ─────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The fields `stamp` adds, by their default names. With custom names
|
|
431
|
+
* (`field`, `revisionField`, `revision: false`) the typed registry —
|
|
432
|
+
* `defineShapes<Shapes>()(…)` — types the stamped document by them.
|
|
433
|
+
*/
|
|
434
|
+
export interface VersionStamp {
|
|
435
|
+
__v: number;
|
|
436
|
+
__rev: number;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The application's view of its versioned collections
|
|
441
|
+
* @experimental New in 2.3
|
|
442
|
+
*/
|
|
443
|
+
export interface ShapeRegistry<Name extends string = string> {
|
|
444
|
+
/** Every versioned collection, in declaration order */
|
|
445
|
+
readonly names: readonly Name[];
|
|
446
|
+
has(name: string): name is Name;
|
|
447
|
+
/** The collection's versioning with every default filled in */
|
|
448
|
+
get(name: Name): ResolvedVersioning;
|
|
449
|
+
/** The version new documents are written at */
|
|
450
|
+
current(name: Name): number;
|
|
451
|
+
/**
|
|
452
|
+
* A document's version — 0 when the field is missing.
|
|
453
|
+
* @throws {ShapeVersionError} (`reason: 'invalid'`) when it is not a count
|
|
454
|
+
*/
|
|
455
|
+
versionOf(name: Name, doc: object): number;
|
|
456
|
+
/** Whether a document is at the current version */
|
|
457
|
+
isCurrent(name: Name, doc: object): boolean;
|
|
458
|
+
/** Whether a document is at `version` (a missing field is 0) */
|
|
459
|
+
isVersion(name: Name, doc: object, version: number): boolean;
|
|
460
|
+
/** The document with the current version and revision 0 — each only when missing */
|
|
461
|
+
stamp<D extends object>(name: Name, doc: D): D & VersionStamp;
|
|
462
|
+
/** An upcaster over the collection's versioning */
|
|
463
|
+
upcaster(name: Name, steps: UpcastSteps, options?: UpcasterOptions): Upcaster;
|
|
464
|
+
/** The Mongoose plugin for the collection: `schema.plugin(shapes.plugin('orders'))` */
|
|
465
|
+
plugin(name: Name): (schema: MongooseSchemaLike) => void;
|
|
466
|
+
/** `stamp` for one document or each of an array — for `insertOne` / `insertMany` */
|
|
467
|
+
onInsert<D extends object>(name: Name, docs: readonly D[]): (D & VersionStamp)[];
|
|
468
|
+
onInsert<D extends object>(name: Name, docs: D): D & VersionStamp;
|
|
469
|
+
/**
|
|
470
|
+
* An upsert's operator update with `$setOnInsert` of the version (unless
|
|
471
|
+
* the update sets it) and the revision bumped.
|
|
472
|
+
*/
|
|
473
|
+
stampUpsert<U extends Record<string, unknown>>(name: Name, update: U): U;
|
|
474
|
+
/**
|
|
475
|
+
* The revision guards bound to the collection's field names — no call can
|
|
476
|
+
* forget them. Refused for a collection declared with `revision: false`.
|
|
477
|
+
*/
|
|
478
|
+
occ(name: Name): BoundRevisionGuards;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** A bound guard's options: the field names are the collection's, never the call's */
|
|
482
|
+
export type BoundRevisionWriteOptions = Omit<RevisionWriteOptions, 'field' | 'revisionField'> & {
|
|
483
|
+
field?: never;
|
|
484
|
+
revisionField?: never;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* {@link ShapeRegistry.occ}: the revision guards with one collection's field names
|
|
489
|
+
* @experimental New in 2.3
|
|
490
|
+
*/
|
|
491
|
+
export interface BoundRevisionGuards {
|
|
492
|
+
updateWithRevision(
|
|
493
|
+
collection: RevisionedCollectionLike | Pick<RevisionedCollectionLike, 'updateOne' | 'findOne'>,
|
|
494
|
+
filter: FilterLike,
|
|
495
|
+
expectedRevision: RevisionValue,
|
|
496
|
+
update: UpdateLike,
|
|
497
|
+
options?: BoundRevisionWriteOptions,
|
|
498
|
+
): Promise<RevisionWriteResult>;
|
|
499
|
+
replaceWithRevision(
|
|
500
|
+
collection: RevisionedCollectionLike | Pick<RevisionedCollectionLike, 'replaceOne' | 'findOne'>,
|
|
501
|
+
filter: FilterLike,
|
|
502
|
+
expectedRevision: RevisionValue,
|
|
503
|
+
replacement: Record<string, unknown>,
|
|
504
|
+
options?: BoundRevisionWriteOptions,
|
|
505
|
+
): Promise<RevisionWriteResult>;
|
|
506
|
+
findOneAndUpdateWithRevision<TDocument = Record<string, unknown>>(
|
|
507
|
+
collection:
|
|
508
|
+
| RevisionedCollectionLike
|
|
509
|
+
| Pick<RevisionedCollectionLike, 'findOneAndUpdate' | 'findOne'>,
|
|
510
|
+
filter: FilterLike,
|
|
511
|
+
expectedRevision: RevisionValue,
|
|
512
|
+
update: UpdateLike,
|
|
513
|
+
options?: BoundRevisionWriteOptions & { returnDocument?: 'before' | 'after' },
|
|
514
|
+
): Promise<TDocument>;
|
|
515
|
+
bumpRevision<U extends UpdateLike>(update: U): U;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─── The typed registry ───────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
/** The definitions a shape map asks for — one versioned definition per collection */
|
|
521
|
+
export type ShapeDefinitions<S extends ShapeMap> = {
|
|
522
|
+
[C in keyof S]: CollectionDefinitionFile & { versioning: CollectionVersioning };
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
type VersioningIn<D, C> = C extends keyof D
|
|
526
|
+
? D[C] extends { versioning: infer V }
|
|
527
|
+
? V
|
|
528
|
+
: never
|
|
529
|
+
: never;
|
|
530
|
+
|
|
531
|
+
/** The system field names a definition declares */
|
|
532
|
+
export type FieldNamesOf<D, C> = {
|
|
533
|
+
field: VersioningIn<D, C> extends { field: infer F extends string } ? F : '__v';
|
|
534
|
+
revisionField: VersioningIn<D, C> extends { revision: false }
|
|
535
|
+
? null
|
|
536
|
+
: VersioningIn<D, C> extends { revisionField: infer R extends string }
|
|
537
|
+
? R
|
|
538
|
+
: '__rev';
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* A literal `current` must be the highest version of the shape map (the
|
|
543
|
+
* error reads "Type '3' is not assignable to type '2'"); a widened `number`
|
|
544
|
+
* cannot be checked and is accepted.
|
|
545
|
+
*/
|
|
546
|
+
type CurrentCheck<S extends ShapeMap, D> = {
|
|
547
|
+
[C in keyof S]: {
|
|
548
|
+
versioning: {
|
|
549
|
+
current: number extends VersioningIn<D, C>['current' & keyof VersioningIn<D, C>]
|
|
550
|
+
? number
|
|
551
|
+
: CurrentVersion<S, C>;
|
|
552
|
+
};
|
|
553
|
+
};
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
/** No collection the shape map does not know */
|
|
557
|
+
type NoExtraCollections<S extends ShapeMap, D> = { [K in Exclude<keyof D, keyof S>]: never };
|
|
558
|
+
|
|
559
|
+
/** The versions an upcaster needs a step from: every declared one but the current */
|
|
560
|
+
type StepVersions<S extends ShapeMap, C extends keyof S> = Exclude<
|
|
561
|
+
Versions<S, C>,
|
|
562
|
+
CurrentVersion<S, C>
|
|
563
|
+
>;
|
|
564
|
+
|
|
565
|
+
/** Steps typed by the shape map: the document at `v` in, the body at `v + 1` out */
|
|
566
|
+
export type UpcastStepsFor<
|
|
567
|
+
S extends ShapeMap,
|
|
568
|
+
C extends keyof S,
|
|
569
|
+
N extends ShapeFieldNames = DefaultShapeFieldNames,
|
|
570
|
+
> = {
|
|
571
|
+
[V in StepVersions<S, C>]: (doc: ShapeAt<S, C, V, N>) => Body<BodyAt<S, C, NextVersion<V>>, N>;
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/** A document newer than any declared shape — what `newer: 'keep'` may hand back */
|
|
575
|
+
export type NewerShape<N extends ShapeFieldNames = DefaultShapeFieldNames> = {
|
|
576
|
+
[K in N['field']]: number;
|
|
577
|
+
} & Record<string, unknown>;
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* An upcaster typed by the shape map
|
|
581
|
+
* @experimental New in 2.3
|
|
582
|
+
*/
|
|
583
|
+
export interface TypedUpcaster<
|
|
584
|
+
S extends ShapeMap,
|
|
585
|
+
C extends keyof S,
|
|
586
|
+
N extends ShapeFieldNames = DefaultShapeFieldNames,
|
|
587
|
+
Keep extends boolean = false,
|
|
588
|
+
> extends Upcaster {
|
|
589
|
+
readonly current: CurrentVersion<S, C>;
|
|
590
|
+
readonly field: N['field'];
|
|
591
|
+
upcast(
|
|
592
|
+
doc: AnyShape<S, C, N>,
|
|
593
|
+
): Keep extends true ? CurrentShape<S, C, N> | NewerShape<N> : CurrentShape<S, C, N>;
|
|
594
|
+
upcast(doc: object): Record<string, unknown>;
|
|
595
|
+
step<F extends StepVersions<S, C>>(
|
|
596
|
+
from: F,
|
|
597
|
+
): (doc: ShapeAt<S, C, F, N>) => ShapeAt<S, C, NextVersion<F>, N>;
|
|
598
|
+
step<F extends StepVersions<S, C>, T extends Versions<S, C>>(
|
|
599
|
+
from: F,
|
|
600
|
+
to: T,
|
|
601
|
+
): (doc: ShapeAt<S, C, F, N>) => ShapeAt<S, C, T, N>;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The registry typed by a shape map — what `defineShapes<Shapes>()(definitions)` returns
|
|
606
|
+
* @experimental New in 2.3
|
|
607
|
+
*/
|
|
608
|
+
export interface TypedShapeRegistry<S extends ShapeMap, D> extends Omit<
|
|
609
|
+
ShapeRegistry<Extract<keyof S, string>>,
|
|
610
|
+
'current' | 'stamp' | 'isCurrent' | 'isVersion' | 'upcaster'
|
|
611
|
+
> {
|
|
612
|
+
current<C extends Extract<keyof S, string>>(name: C): CurrentVersion<S, C>;
|
|
613
|
+
/** The body stamped at the current version */
|
|
614
|
+
stamp<C extends Extract<keyof S, string>>(
|
|
615
|
+
name: C,
|
|
616
|
+
body: Body<BodyAt<S, C, CurrentVersion<S, C>>, FieldNamesOf<D, C>>,
|
|
617
|
+
): CurrentShape<S, C, FieldNamesOf<D, C>>;
|
|
618
|
+
/** Whether the document is at the current version — a type guard */
|
|
619
|
+
isCurrent<C extends Extract<keyof S, string>>(
|
|
620
|
+
name: C,
|
|
621
|
+
doc: AnyShape<S, C, FieldNamesOf<D, C>>,
|
|
622
|
+
): doc is CurrentShape<S, C, FieldNamesOf<D, C>>;
|
|
623
|
+
isCurrent(name: Extract<keyof S, string>, doc: object): boolean;
|
|
624
|
+
/** Whether the document is at `version` — a type guard */
|
|
625
|
+
isVersion<C extends Extract<keyof S, string>, V extends Versions<S, C>>(
|
|
626
|
+
name: C,
|
|
627
|
+
doc: AnyShape<S, C, FieldNamesOf<D, C>>,
|
|
628
|
+
version: V,
|
|
629
|
+
): doc is ShapeAt<S, C, V, FieldNamesOf<D, C>>;
|
|
630
|
+
isVersion(name: Extract<keyof S, string>, doc: object, version: number): boolean;
|
|
631
|
+
/** An upcaster whose steps the shape map types — a missing or wrong step does not compile */
|
|
632
|
+
upcaster<C extends Extract<keyof S, string>>(
|
|
633
|
+
name: C,
|
|
634
|
+
steps: Hold<UpcastStepsFor<S, C, FieldNamesOf<D, C>>>,
|
|
635
|
+
options: { newer: 'keep' },
|
|
636
|
+
): TypedUpcaster<S, C, FieldNamesOf<D, C>, true>;
|
|
637
|
+
upcaster<C extends Extract<keyof S, string>>(
|
|
638
|
+
name: C,
|
|
639
|
+
steps: Hold<UpcastStepsFor<S, C, FieldNamesOf<D, C>>>,
|
|
640
|
+
options?: { newer?: 'throw' },
|
|
641
|
+
): TypedUpcaster<S, C, FieldNamesOf<D, C>>;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The registry typed by a shape map. Curried, so the shape map is given and
|
|
646
|
+
* the definitions are inferred: `defineShapes<Shapes>()(definitions)`. The
|
|
647
|
+
* definitions must cover exactly the shape map's collections, each with a
|
|
648
|
+
* `current` that is its highest version (when literal — `as const`).
|
|
649
|
+
* @experimental New in 2.3
|
|
650
|
+
*/
|
|
651
|
+
export function defineShapes<S extends ShapeMap>(): <const D extends ShapeDefinitions<S>>(
|
|
652
|
+
definitions: D & Hold<NoExtraCollections<S, D> & CurrentCheck<S, D>>,
|
|
653
|
+
) => TypedShapeRegistry<S, D>;
|
|
654
|
+
/**
|
|
655
|
+
* The registry of the versioned collections among `definitions` — the same
|
|
656
|
+
* definition files converge declares them with, as `{ name: definition }` or
|
|
657
|
+
* a list of definitions with a `name` (unversioned ones are skipped).
|
|
658
|
+
* @experimental New in 2.3
|
|
659
|
+
*/
|
|
660
|
+
export function defineShapes<const D extends Record<string, CollectionDefinitionFile>>(
|
|
661
|
+
definitions: D,
|
|
662
|
+
): ShapeRegistry<Extract<keyof D, string>>;
|
|
663
|
+
/** @experimental New in 2.3 */
|
|
664
|
+
export function defineShapes(definitions: readonly CollectionDefinition[]): ShapeRegistry;
|
|
665
|
+
|
|
666
|
+
export type { Body, CollectionVersioning, DefaultShapeFieldNames, ShapeFieldNames };
|
package/versioning.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('./src/versioning/index.js');
|