@prisma-next/mongo-query-builder 0.0.1

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/src/builder.ts ADDED
@@ -0,0 +1,958 @@
1
+ import type { PlanMeta } from '@prisma-next/contract/types';
2
+ import type {
3
+ ExtractMongoCodecTypes,
4
+ MongoContract,
5
+ MongoContractWithTypeMaps,
6
+ MongoTypeMaps,
7
+ } from '@prisma-next/mongo-contract';
8
+ import type {
9
+ MongoAggAccumulator,
10
+ MongoAggExpr,
11
+ MongoDensifyRange,
12
+ MongoFillOutput,
13
+ MongoFilterExpr,
14
+ MongoPipelineStage,
15
+ MongoProjectionValue,
16
+ MongoQueryPlan,
17
+ MongoUpdatePipelineStage,
18
+ MongoWindowField,
19
+ UpdateResult,
20
+ } from '@prisma-next/mongo-query-ast/execution';
21
+ import {
22
+ AggregateCommand,
23
+ FindOneAndDeleteCommand,
24
+ FindOneAndUpdateCommand,
25
+ MongoAddFieldsStage,
26
+ MongoAndExpr,
27
+ MongoBucketAutoStage,
28
+ MongoBucketStage,
29
+ MongoCountStage,
30
+ MongoDensifyStage,
31
+ MongoFacetStage,
32
+ MongoFillStage,
33
+ MongoGeoNearStage,
34
+ MongoGraphLookupStage,
35
+ MongoGroupStage,
36
+ MongoLimitStage,
37
+ MongoLookupStage,
38
+ MongoMatchStage,
39
+ MongoMergeStage,
40
+ MongoOutStage,
41
+ MongoProjectStage,
42
+ MongoRedactStage,
43
+ MongoReplaceRootStage,
44
+ MongoSampleStage,
45
+ MongoSearchMetaStage,
46
+ MongoSearchStage,
47
+ MongoSetWindowFieldsStage,
48
+ MongoSkipStage,
49
+ MongoSortByCountStage,
50
+ MongoSortStage,
51
+ MongoUnionWithStage,
52
+ MongoUnwindStage,
53
+ MongoVectorSearchStage,
54
+ UpdateManyCommand,
55
+ UpdateOneCommand,
56
+ } from '@prisma-next/mongo-query-ast/execution';
57
+ import { createFieldAccessor, type Expression, type FieldAccessor } from './field-accessor';
58
+ import type { FindAndModifyEnabled, LeadingMatch, UpdateEnabled } from './markers';
59
+ import type { NestedDocShape } from './resolve-path';
60
+ import type {
61
+ DocField,
62
+ DocShape,
63
+ ExtractDocShape,
64
+ GroupedDocShape,
65
+ GroupSpec,
66
+ ProjectedShape,
67
+ ResolveRow,
68
+ SortSpec,
69
+ TypedAggExpr,
70
+ UnwoundShape,
71
+ } from './types';
72
+ import { resolveUpdaterResult, type UpdaterResult } from './update-ops';
73
+
74
+ interface PipelineChainState {
75
+ readonly collection: string;
76
+ readonly stages: ReadonlyArray<MongoPipelineStage>;
77
+ readonly storageHash: string;
78
+ }
79
+
80
+ /**
81
+ * The pipeline state in the query-builder state machine.
82
+ *
83
+ * Reached from `CollectionHandle` or `FilteredCollection` after the first
84
+ * pipeline-stage method call (or directly via `aggregate()` shortcuts). Holds
85
+ * the accumulated `MongoPipelineStage[]` and exposes pipeline-stage methods,
86
+ * the `merge`/`out` write terminals, and the `build`/`aggregate` read
87
+ * terminals.
88
+ *
89
+ * Two phantom type parameters gate the conditional terminals:
90
+ *
91
+ * - `U extends UpdateEnabled` — when `'update-ok'`, the no-arg `updateMany()` /
92
+ * `updateOne()` form is available (consume the chain as an
93
+ * update-with-pipeline spec). Cleared by stages that produce content the
94
+ * `update` AST cannot represent (e.g. `$group`, `$lookup`, `$limit`).
95
+ * - `F extends FindAndModifyEnabled` — when `'fam-ok'`, the
96
+ * `findOneAndUpdate(...)` / `findOneAndDelete(...)` terminals are
97
+ * available. Cleared by stages incompatible with their wire-command slots
98
+ * (`$limit`, `$group`, mutating stages, …).
99
+ *
100
+ * The marker semantics are encoded in the per-method return types — see the
101
+ * marker table (and rationale per row) in
102
+ * `docs/architecture docs/adrs/ADR 201 - State-machine pattern for typed DSL builders.md`.
103
+ */
104
+ export class PipelineChain<
105
+ TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>,
106
+ Shape extends DocShape,
107
+ U extends UpdateEnabled = 'update-ok',
108
+ F extends FindAndModifyEnabled = 'fam-ok',
109
+ L extends LeadingMatch = 'leading',
110
+ N extends NestedDocShape = Record<string, never>,
111
+ > {
112
+ declare readonly __updateCompat: U;
113
+ declare readonly __findAndModifyCompat: F;
114
+ declare readonly __leadingMatch: L;
115
+
116
+ readonly #contract: TContract;
117
+ readonly #state: PipelineChainState;
118
+
119
+ constructor(contract: TContract, state: PipelineChainState) {
120
+ this.#contract = contract;
121
+ this.#state = state;
122
+ }
123
+
124
+ /**
125
+ * Internal helper that appends a pipeline stage and branches into a new
126
+ * state-type. The fifth type parameter `NewN` carries the nested-path
127
+ * shape forward. It defaults to `Record<string, never>` so stages that
128
+ * fundamentally rewrite the document (`$group`, `$project`,
129
+ * `$replaceRoot`, …) automatically disable the callable form of
130
+ * `FieldAccessor` downstream. Additive stages (`match`, `addFields`,
131
+ * `sort`, `lookup`, …) explicitly re-thread the current `N`.
132
+ */
133
+ #withStage<
134
+ NewShape extends DocShape,
135
+ NewU extends UpdateEnabled,
136
+ NewF extends FindAndModifyEnabled,
137
+ NewL extends LeadingMatch = 'past-leading',
138
+ NewN extends NestedDocShape = Record<string, never>,
139
+ >(stage: MongoPipelineStage): PipelineChain<TContract, NewShape, NewU, NewF, NewL, NewN> {
140
+ return new PipelineChain<TContract, NewShape, NewU, NewF, NewL, NewN>(this.#contract, {
141
+ ...this.#state,
142
+ stages: [...this.#state.stages, stage],
143
+ });
144
+ }
145
+
146
+ #writeMeta(): PlanMeta {
147
+ return {
148
+ target: 'mongo',
149
+ storageHash: this.#state.storageHash,
150
+ lane: 'mongo-query',
151
+ paramDescriptors: [],
152
+ };
153
+ }
154
+
155
+ // --- Identity stages ---
156
+
157
+ /**
158
+ * `$match`. `FindAndModifyEnabled` is always preserved. `UpdateEnabled` is
159
+ * preserved only while the chain is still in the leading-`$match` prefix
160
+ * (`L = 'leading'`); a `$match` that follows any non-`$match` stage
161
+ * transitions to `L = 'past-leading'` and clears `UpdateEnabled`, since
162
+ * `deconstructUpdateChain` can only peel leading `$match` stages into the
163
+ * wire-command filter.
164
+ */
165
+ match(
166
+ filter: MongoFilterExpr,
167
+ ): PipelineChain<TContract, Shape, L extends 'leading' ? U : 'update-cleared', F, L, N>;
168
+ match(
169
+ fn: (fields: FieldAccessor<Shape, N>) => MongoFilterExpr,
170
+ ): PipelineChain<TContract, Shape, L extends 'leading' ? U : 'update-cleared', F, L, N>;
171
+ match(
172
+ filterOrFn: MongoFilterExpr | ((fields: FieldAccessor<Shape, N>) => MongoFilterExpr),
173
+ ): PipelineChain<TContract, Shape, L extends 'leading' ? U : 'update-cleared', F, L, N> {
174
+ const filter =
175
+ typeof filterOrFn === 'function' ? filterOrFn(createFieldAccessor<Shape, N>()) : filterOrFn;
176
+ return this.#withStage<Shape, L extends 'leading' ? U : 'update-cleared', F, L, N>(
177
+ new MongoMatchStage(filter),
178
+ );
179
+ }
180
+
181
+ /**
182
+ * `$sort`. Clears `UpdateEnabled` (`update` has no per-document sort) but
183
+ * preserves `FindAndModifyEnabled` (`findAndModify` has a `sort` slot).
184
+ */
185
+ sort(
186
+ spec: SortSpec<Shape>,
187
+ ): PipelineChain<TContract, Shape, 'update-cleared', F, 'past-leading', N> {
188
+ return this.#withStage<Shape, 'update-cleared', F, 'past-leading', N>(
189
+ new MongoSortStage(spec as Record<string, 1 | -1>),
190
+ );
191
+ }
192
+
193
+ /**
194
+ * `$limit`. Clears both markers — `limit` is incompatible with the `update`
195
+ * wire command, and `findAndModify` already implies single-document
196
+ * semantics (so `.limit(...)` adds no meaning, only ambiguity).
197
+ */
198
+ limit(
199
+ n: number,
200
+ ): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading', N> {
201
+ return this.#withStage<Shape, 'update-cleared', 'fam-cleared', 'past-leading', N>(
202
+ new MongoLimitStage(n),
203
+ );
204
+ }
205
+
206
+ /**
207
+ * `$skip`. Clears both markers — MongoDB's `findAndModify` wire command
208
+ * has no `skip` slot, so `deconstructFindAndModifyChain` rejects any
209
+ * `$skip` at runtime; keeping the marker `fam-cleared` makes the type
210
+ * system reflect the same constraint (see ADR 201 marker table).
211
+ */
212
+ skip(
213
+ n: number,
214
+ ): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading', N> {
215
+ return this.#withStage<Shape, 'update-cleared', 'fam-cleared', 'past-leading', N>(
216
+ new MongoSkipStage(n),
217
+ );
218
+ }
219
+
220
+ sample(
221
+ n: number,
222
+ ): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading', N> {
223
+ return this.#withStage<Shape, 'update-cleared', 'fam-cleared', 'past-leading', N>(
224
+ new MongoSampleStage(n),
225
+ );
226
+ }
227
+
228
+ // --- Additive stages ---
229
+
230
+ /**
231
+ * `$addFields`. Preserves `UpdateEnabled` (representable as
232
+ * update-with-pipeline `$set`); clears `FindAndModifyEnabled` (no analogue
233
+ * in the find-and-modify wire commands). The nested-path shape `N` is
234
+ * preserved — newly added flat fields are reachable via property access
235
+ * (`f.newField`) but do not themselves carry nested structure.
236
+ */
237
+ addFields<NewFields extends Record<string, TypedAggExpr<DocField>>>(
238
+ fn: (fields: FieldAccessor<Shape, N>) => NewFields,
239
+ ): PipelineChain<
240
+ TContract,
241
+ Shape & ExtractDocShape<NewFields>,
242
+ U,
243
+ 'fam-cleared',
244
+ 'past-leading',
245
+ N
246
+ > {
247
+ const accessor = createFieldAccessor<Shape, N>();
248
+ const newFields = fn(accessor);
249
+ const exprRecord: Record<string, MongoAggExpr> = {};
250
+ for (const [key, typed] of Object.entries(newFields)) {
251
+ exprRecord[key] = typed.node;
252
+ }
253
+ return this.#withStage<Shape & ExtractDocShape<NewFields>, U, 'fam-cleared', 'past-leading', N>(
254
+ new MongoAddFieldsStage(exprRecord),
255
+ );
256
+ }
257
+
258
+ /**
259
+ * `$lookup`. Clears both markers — joins are not representable in either
260
+ * the `update` or `findAndModify` wire commands. The original document's
261
+ * nested-path shape `N` is preserved (the lookup adds a sidecar array
262
+ * field; existing keys are untouched).
263
+ */
264
+ lookup<ForeignRoot extends keyof TContract['roots'] & string, As extends string>(options: {
265
+ from: ForeignRoot;
266
+ localField: keyof Shape & string;
267
+ foreignField: string;
268
+ as: As;
269
+ }): PipelineChain<
270
+ TContract,
271
+ Shape & Record<As, { readonly codecId: 'mongo/array@1'; readonly nullable: false }>,
272
+ 'update-cleared',
273
+ 'fam-cleared',
274
+ 'past-leading',
275
+ N
276
+ > {
277
+ const contract: MongoContract = this.#contract;
278
+ const modelName = contract.roots[options.from];
279
+ if (!modelName) {
280
+ const validRoots = Object.keys(contract.roots).join(', ');
281
+ throw new Error(`lookup() unknown root: "${options.from}". Valid roots: ${validRoots}`);
282
+ }
283
+ const model = contract.models[modelName];
284
+ const collectionName = model?.storage?.collection ?? options.from;
285
+ return this.#withStage<
286
+ Shape & Record<As, { readonly codecId: 'mongo/array@1'; readonly nullable: false }>,
287
+ 'update-cleared',
288
+ 'fam-cleared',
289
+ 'past-leading',
290
+ N
291
+ >(
292
+ new MongoLookupStage({
293
+ from: collectionName,
294
+ localField: options.localField,
295
+ foreignField: options.foreignField,
296
+ as: options.as,
297
+ }),
298
+ );
299
+ }
300
+
301
+ // --- Narrowing stages ---
302
+
303
+ /**
304
+ * `$project`. Preserves `UpdateEnabled` (representable as update-with-pipeline
305
+ * `$project` / `$unset`); clears `FindAndModifyEnabled` (use `.project()` on
306
+ * the result of `.build()` if both projection and find-and-modify are
307
+ * needed — see spec).
308
+ *
309
+ * Resets the nested-path shape to `Record<string, never>` — projection
310
+ * fundamentally rewrites the document, so dot-paths into the *source*
311
+ * document are no longer meaningful downstream.
312
+ */
313
+ project<K extends keyof Shape & string>(
314
+ ...keys: K[]
315
+ ): PipelineChain<
316
+ TContract,
317
+ Pick<Shape, K | ('_id' extends keyof Shape ? '_id' : never)>,
318
+ U,
319
+ 'fam-cleared',
320
+ 'past-leading'
321
+ >;
322
+ project<Spec extends Record<string, 1 | TypedAggExpr<DocField>>>(
323
+ fn: (fields: FieldAccessor<Shape, N>) => Spec,
324
+ ): PipelineChain<TContract, ProjectedShape<Shape, Spec>, U, 'fam-cleared', 'past-leading'>;
325
+ project(
326
+ ...args: unknown[]
327
+ ): PipelineChain<TContract, DocShape, U, 'fam-cleared', 'past-leading'> {
328
+ if (args.length === 1 && typeof args[0] === 'function') {
329
+ const fn = args[0] as (
330
+ fields: FieldAccessor<Shape, N>,
331
+ ) => Record<string, 1 | TypedAggExpr<DocField>>;
332
+ const accessor = createFieldAccessor<Shape, N>();
333
+ const spec = fn(accessor);
334
+ const projection: Record<string, MongoProjectionValue> = {};
335
+ for (const [key, val] of Object.entries(spec)) {
336
+ projection[key] = val === 1 ? 1 : (val as TypedAggExpr<DocField>).node;
337
+ }
338
+ return this.#withStage(new MongoProjectStage(projection));
339
+ }
340
+ const keys = args as string[];
341
+ const projection: Record<string, 1> = {};
342
+ for (const key of keys) {
343
+ projection[key] = 1;
344
+ }
345
+ return this.#withStage(new MongoProjectStage(projection));
346
+ }
347
+
348
+ /**
349
+ * `$unwind`. Clears both markers — array unrolling produces multiple output
350
+ * documents per input, incompatible with both single-document update and
351
+ * find-and-modify wire commands. The original `N` is preserved: unwind
352
+ * replaces the unwound array slot with its element but leaves the rest
353
+ * of the document structurally intact.
354
+ */
355
+ unwind<K extends keyof Shape & string>(
356
+ field: K,
357
+ options?: { preserveNullAndEmptyArrays?: boolean },
358
+ ): PipelineChain<
359
+ TContract,
360
+ UnwoundShape<Shape, K>,
361
+ 'update-cleared',
362
+ 'fam-cleared',
363
+ 'past-leading',
364
+ N
365
+ > {
366
+ return this.#withStage<
367
+ UnwoundShape<Shape, K>,
368
+ 'update-cleared',
369
+ 'fam-cleared',
370
+ 'past-leading',
371
+ N
372
+ >(new MongoUnwindStage(`$${field}`, options?.preserveNullAndEmptyArrays ?? false));
373
+ }
374
+
375
+ // --- Replacement stages ---
376
+
377
+ /**
378
+ * `$group`. Clears both markers — group output bears no relation to source
379
+ * documents; neither `update` nor `findAndModify` can consume it. Nested
380
+ * path shape is reset (the source document's path tree is gone).
381
+ */
382
+ group<Spec extends GroupSpec>(
383
+ fn: (fields: FieldAccessor<Shape, N>) => Spec,
384
+ ): PipelineChain<
385
+ TContract,
386
+ GroupedDocShape<Spec>,
387
+ 'update-cleared',
388
+ 'fam-cleared',
389
+ 'past-leading'
390
+ > {
391
+ const accessor = createFieldAccessor<Shape, N>();
392
+ const spec = fn(accessor);
393
+ const { _id: groupIdExpr, ...rest } = spec;
394
+ const groupId = groupIdExpr === null ? null : groupIdExpr.node;
395
+ const accumulators: Record<string, MongoAggAccumulator> = {};
396
+ for (const [key, typed] of Object.entries(rest)) {
397
+ if (typed === null) {
398
+ throw new Error(`group() field "${key}" must not be null. Only _id can be null.`);
399
+ }
400
+ if (typed.node.kind !== 'accumulator') {
401
+ throw new Error(
402
+ `group() field "${key}" must use an accumulator (e.g. acc.sum(), acc.count()). Got "${typed.node.kind}" expression.`,
403
+ );
404
+ }
405
+ accumulators[key] = typed.node as MongoAggAccumulator;
406
+ }
407
+ return this.#withStage<GroupedDocShape<Spec>, 'update-cleared', 'fam-cleared'>(
408
+ new MongoGroupStage(groupId, accumulators),
409
+ );
410
+ }
411
+
412
+ /**
413
+ * `$replaceRoot`. Preserves `UpdateEnabled` (representable as
414
+ * update-with-pipeline `$replaceRoot`); clears `FindAndModifyEnabled`.
415
+ * Nested path shape is reset — the replaced root has no relation to
416
+ * the original document structure.
417
+ */
418
+ replaceRoot<NewShape extends DocShape>(
419
+ fn: (fields: FieldAccessor<Shape, N>) => Expression<DocField> | TypedAggExpr<DocField>,
420
+ ): PipelineChain<TContract, NewShape, U, 'fam-cleared', 'past-leading'> {
421
+ const accessor = createFieldAccessor<Shape, N>();
422
+ const expr = fn(accessor);
423
+ return this.#withStage<NewShape, U, 'fam-cleared'>(new MongoReplaceRootStage(expr.node));
424
+ }
425
+
426
+ count<Field extends string>(
427
+ field: Field,
428
+ ): PipelineChain<
429
+ TContract,
430
+ Record<Field, { readonly codecId: 'mongo/double@1'; readonly nullable: false }>,
431
+ 'update-cleared',
432
+ 'fam-cleared',
433
+ 'past-leading'
434
+ > {
435
+ return this.#withStage(new MongoCountStage(field));
436
+ }
437
+
438
+ sortByCount<F2 extends DocField>(
439
+ fn: (fields: FieldAccessor<Shape, N>) => Expression<F2> | TypedAggExpr<F2>,
440
+ ): PipelineChain<
441
+ TContract,
442
+ {
443
+ _id: F2;
444
+ count: { readonly codecId: 'mongo/double@1'; readonly nullable: false };
445
+ },
446
+ 'update-cleared',
447
+ 'fam-cleared',
448
+ 'past-leading'
449
+ > {
450
+ const accessor = createFieldAccessor<Shape, N>();
451
+ const expr = fn(accessor);
452
+ return this.#withStage(new MongoSortByCountStage(expr.node));
453
+ }
454
+
455
+ // --- Filter stages ---
456
+
457
+ /**
458
+ * `$redact`. Preserves `UpdateEnabled`; clears `FindAndModifyEnabled`.
459
+ * Shape- and nested-path-preserving (the document tree is unchanged).
460
+ */
461
+ redact(
462
+ fn: (fields: FieldAccessor<Shape, N>) => Expression<DocField> | TypedAggExpr<DocField>,
463
+ ): PipelineChain<TContract, Shape, U, 'fam-cleared', 'past-leading', N> {
464
+ const accessor = createFieldAccessor<Shape, N>();
465
+ const expr = fn(accessor);
466
+ return this.#withStage<Shape, U, 'fam-cleared', 'past-leading', N>(
467
+ new MongoRedactStage(expr.node),
468
+ );
469
+ }
470
+
471
+ // --- Write terminals (output stages) ---
472
+
473
+ /**
474
+ * `$out` write terminal. Materialises the pipeline output into
475
+ * `collection` (optionally in `db`), replacing any prior contents. Unlike
476
+ * the other pipeline-stage methods, this **terminates** the chain — it
477
+ * returns a `MongoQueryPlan` rather than another `PipelineChain`, since
478
+ * `$out` must be the final stage and there is nothing further to chain.
479
+ *
480
+ * Lane is `mongo-query` (matching all other terminals in this package) so
481
+ * middleware can dispatch on intent without inspecting the command.
482
+ *
483
+ * The result row stream is empty (`unknown` row type) — the data lives
484
+ * in the destination collection, not the response.
485
+ */
486
+ out(collection: string, db?: string): MongoQueryPlan<unknown, AggregateCommand> {
487
+ return this.#writeTerminal(new MongoOutStage(collection, db));
488
+ }
489
+
490
+ /**
491
+ * `$merge` write terminal. Streams the pipeline output into the target
492
+ * collection per the supplied merge semantics (`whenMatched` /
493
+ * `whenNotMatched`). Like `out()`, terminates the chain — `$merge` must
494
+ * be the final stage.
495
+ */
496
+ merge(options: {
497
+ into: string | { db: string; coll: string };
498
+ on?: string | ReadonlyArray<string>;
499
+ whenMatched?: string | ReadonlyArray<MongoUpdatePipelineStage>;
500
+ whenNotMatched?: string;
501
+ }): MongoQueryPlan<unknown, AggregateCommand> {
502
+ return this.#writeTerminal(new MongoMergeStage(options));
503
+ }
504
+
505
+ #writeTerminal(stage: MongoPipelineStage): MongoQueryPlan<unknown, AggregateCommand> {
506
+ const pipeline = [...this.#state.stages, stage];
507
+ const command = new AggregateCommand(this.#state.collection, pipeline);
508
+ const meta: PlanMeta = {
509
+ target: 'mongo',
510
+ storageHash: this.#state.storageHash,
511
+ lane: 'mongo-query',
512
+ paramDescriptors: [],
513
+ };
514
+ return { collection: this.#state.collection, command, meta };
515
+ }
516
+
517
+ // --- Union stages ---
518
+
519
+ unionWith(
520
+ collection: string,
521
+ pipeline?: ReadonlyArray<MongoPipelineStage>,
522
+ ): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading', N> {
523
+ return this.#withStage<Shape, 'update-cleared', 'fam-cleared', 'past-leading', N>(
524
+ new MongoUnionWithStage(collection, pipeline),
525
+ );
526
+ }
527
+
528
+ // --- Bucketing stages ---
529
+
530
+ bucket(options: {
531
+ groupBy: MongoAggExpr;
532
+ boundaries: ReadonlyArray<unknown>;
533
+ default_?: unknown;
534
+ output?: Record<string, MongoAggAccumulator>;
535
+ }): PipelineChain<TContract, DocShape, 'update-cleared', 'fam-cleared', 'past-leading'> {
536
+ return this.#withStage<DocShape, 'update-cleared', 'fam-cleared'>(
537
+ new MongoBucketStage(options),
538
+ );
539
+ }
540
+
541
+ bucketAuto(options: {
542
+ groupBy: MongoAggExpr;
543
+ buckets: number;
544
+ output?: Record<string, MongoAggAccumulator>;
545
+ granularity?: string;
546
+ }): PipelineChain<TContract, DocShape, 'update-cleared', 'fam-cleared', 'past-leading'> {
547
+ return this.#withStage<DocShape, 'update-cleared', 'fam-cleared'>(
548
+ new MongoBucketAutoStage(options),
549
+ );
550
+ }
551
+
552
+ // --- Geo stages ---
553
+
554
+ geoNear(options: {
555
+ near: unknown;
556
+ distanceField: string;
557
+ spherical?: boolean;
558
+ maxDistance?: number;
559
+ minDistance?: number;
560
+ query?: MongoFilterExpr;
561
+ key?: string;
562
+ distanceMultiplier?: number;
563
+ includeLocs?: string;
564
+ }): PipelineChain<TContract, DocShape, 'update-cleared', 'fam-cleared', 'past-leading'> {
565
+ return this.#withStage<DocShape, 'update-cleared', 'fam-cleared'>(
566
+ new MongoGeoNearStage(options),
567
+ );
568
+ }
569
+
570
+ // --- Multi-facet stages ---
571
+
572
+ facet(
573
+ facets: Record<string, ReadonlyArray<MongoPipelineStage>>,
574
+ ): PipelineChain<TContract, DocShape, 'update-cleared', 'fam-cleared', 'past-leading'> {
575
+ return this.#withStage<DocShape, 'update-cleared', 'fam-cleared'>(new MongoFacetStage(facets));
576
+ }
577
+
578
+ // --- Graph stages ---
579
+
580
+ graphLookup(options: {
581
+ from: string;
582
+ startWith: MongoAggExpr;
583
+ connectFromField: string;
584
+ connectToField: string;
585
+ as: string;
586
+ maxDepth?: number;
587
+ depthField?: string;
588
+ restrictSearchWithMatch?: MongoFilterExpr;
589
+ }): PipelineChain<TContract, DocShape, 'update-cleared', 'fam-cleared', 'past-leading'> {
590
+ return this.#withStage<DocShape, 'update-cleared', 'fam-cleared'>(
591
+ new MongoGraphLookupStage(options),
592
+ );
593
+ }
594
+
595
+ // --- Window stages ---
596
+
597
+ setWindowFields(options: {
598
+ partitionBy?: MongoAggExpr;
599
+ sortBy?: Record<string, 1 | -1>;
600
+ output: Record<string, MongoWindowField>;
601
+ }): PipelineChain<TContract, DocShape, 'update-cleared', 'fam-cleared', 'past-leading'> {
602
+ return this.#withStage<DocShape, 'update-cleared', 'fam-cleared'>(
603
+ new MongoSetWindowFieldsStage(options),
604
+ );
605
+ }
606
+
607
+ densify(options: {
608
+ field: string;
609
+ partitionByFields?: ReadonlyArray<string>;
610
+ range: MongoDensifyRange;
611
+ }): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading', N> {
612
+ return this.#withStage<Shape, 'update-cleared', 'fam-cleared', 'past-leading', N>(
613
+ new MongoDensifyStage(options),
614
+ );
615
+ }
616
+
617
+ fill(options: {
618
+ partitionBy?: MongoAggExpr;
619
+ partitionByFields?: ReadonlyArray<string>;
620
+ sortBy?: Record<string, 1 | -1>;
621
+ output: Record<string, MongoFillOutput>;
622
+ }): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading', N> {
623
+ return this.#withStage<Shape, 'update-cleared', 'fam-cleared', 'past-leading', N>(
624
+ new MongoFillStage(options),
625
+ );
626
+ }
627
+
628
+ // --- Search stages ---
629
+
630
+ search(
631
+ config: Record<string, unknown>,
632
+ index?: string,
633
+ ): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading', N> {
634
+ return this.#withStage<Shape, 'update-cleared', 'fam-cleared', 'past-leading', N>(
635
+ new MongoSearchStage(config, index),
636
+ );
637
+ }
638
+
639
+ searchMeta(
640
+ config: Record<string, unknown>,
641
+ index?: string,
642
+ ): PipelineChain<TContract, DocShape, 'update-cleared', 'fam-cleared', 'past-leading'> {
643
+ return this.#withStage<DocShape, 'update-cleared', 'fam-cleared'>(
644
+ new MongoSearchMetaStage(config, index),
645
+ );
646
+ }
647
+
648
+ vectorSearch(options: {
649
+ index: string;
650
+ path: string;
651
+ queryVector: ReadonlyArray<number>;
652
+ numCandidates: number;
653
+ limit: number;
654
+ filter?: Record<string, unknown>;
655
+ }): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading', N> {
656
+ return this.#withStage<Shape, 'update-cleared', 'fam-cleared', 'past-leading', N>(
657
+ new MongoVectorSearchStage(options),
658
+ );
659
+ }
660
+
661
+ // --- Escape hatch ---
662
+
663
+ pipe(
664
+ stage: MongoPipelineStage,
665
+ ): PipelineChain<TContract, Shape, 'update-cleared', 'fam-cleared', 'past-leading'>;
666
+ pipe<NewShape extends DocShape>(
667
+ stage: MongoPipelineStage,
668
+ ): PipelineChain<TContract, NewShape, 'update-cleared', 'fam-cleared', 'past-leading'>;
669
+ pipe<NewShape extends DocShape = Shape>(
670
+ stage: MongoPipelineStage,
671
+ ): PipelineChain<TContract, NewShape, 'update-cleared', 'fam-cleared', 'past-leading'> {
672
+ return this.#withStage<NewShape, 'update-cleared', 'fam-cleared'>(stage);
673
+ }
674
+
675
+ // --- Pipeline-style write terminals (UpdateEnabled-gated) ---
676
+
677
+ /**
678
+ * No-arg `updateMany()`: deconstruct the chain into leading `$match`
679
+ * stages (folded into the filter) and remaining stages (which must all
680
+ * be valid pipeline-update stages). Available only when `U = 'update-ok'`.
681
+ *
682
+ * The optional callback parameter exists for subclass-override
683
+ * compatibility with `FilteredCollection.updateMany(updaterFn)` — TS's
684
+ * strict override check requires the parent's parameter to accept at
685
+ * least what the child's signature does. A runtime guard throws if a
686
+ * callback is actually passed on a bare `PipelineChain`. Note that
687
+ * because nothing in the public surface transitions `U` from
688
+ * `'update-cleared'` (the initial state on `CollectionHandle` /
689
+ * `FilteredCollection`) back to `'update-ok'`, the no-arg form is
690
+ * reachable only via explicit type casts in internal tests — the
691
+ * callback-form "type hole" is therefore not reachable from user
692
+ * code. See `docs/architecture docs/adrs/ADR 201 - State-machine
693
+ * pattern for typed DSL builders.md` for the marker-transition table.
694
+ */
695
+ updateMany(
696
+ this: PipelineChain<TContract, Shape, 'update-ok', F, L, N>,
697
+ updaterFn?: (fields: FieldAccessor<Shape, N>) => UpdaterResult,
698
+ ): MongoQueryPlan<UpdateResult, UpdateManyCommand> {
699
+ if (updaterFn !== undefined) {
700
+ throw new Error(
701
+ 'updateMany() on a PipelineChain expects no arguments — the chain itself is the update pipeline. ' +
702
+ 'To update with an operator callback, call .updateMany(fn) on a FilteredCollection (i.e. after .match()).',
703
+ );
704
+ }
705
+ const { filter, updatePipeline } = deconstructUpdateChain(this.#state.stages);
706
+ const command = new UpdateManyCommand(this.#state.collection, filter, updatePipeline);
707
+ return { collection: this.#state.collection, command, meta: this.#writeMeta() };
708
+ }
709
+
710
+ /**
711
+ * No-arg `updateOne()`: same as `updateMany()` but maps to a single-doc
712
+ * update. Carries the same optional-callback/subclass-compat caveat
713
+ * documented above — the callback form is reachable only via forced
714
+ * casts in internal tests.
715
+ */
716
+ updateOne(
717
+ this: PipelineChain<TContract, Shape, 'update-ok', F, L, N>,
718
+ updaterFn?: (fields: FieldAccessor<Shape, N>) => UpdaterResult,
719
+ ): MongoQueryPlan<UpdateResult, UpdateOneCommand> {
720
+ if (updaterFn !== undefined) {
721
+ throw new Error(
722
+ 'updateOne() on a PipelineChain expects no arguments — the chain itself is the update pipeline. ' +
723
+ 'To update with an operator callback, call .updateOne(fn) on a FilteredCollection (i.e. after .match()).',
724
+ );
725
+ }
726
+ const { filter, updatePipeline } = deconstructUpdateChain(this.#state.stages);
727
+ const command = new UpdateOneCommand(this.#state.collection, filter, updatePipeline);
728
+ return { collection: this.#state.collection, command, meta: this.#writeMeta() };
729
+ }
730
+
731
+ // --- Find-and-modify terminals (marker-gated) ---
732
+
733
+ /**
734
+ * Find a single document matching the accumulated pipeline (which must
735
+ * consist solely of leading `$match` stages followed by at most one
736
+ * `$sort`) and apply `updaterFn`. Available only when
737
+ * `FindAndModifyEnabled` is `'fam-ok'` — stages that clear the marker
738
+ * (including `$skip`, which MongoDB's `findAndModify` has no slot for)
739
+ * make this method invisible at the type level.
740
+ *
741
+ * The pipeline stages are deconstructed into the wire command's `filter`
742
+ * and `sort` slots. If any non-deconstructable stage is present, a
743
+ * runtime error is thrown as a defensive check (the type system should
744
+ * prevent this).
745
+ */
746
+ findOneAndUpdate(
747
+ this: PipelineChain<TContract, Shape, U, 'fam-ok', L, N>,
748
+ updaterFn: (fields: FieldAccessor<Shape, N>) => UpdaterResult,
749
+ opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {},
750
+ ): MongoQueryPlan<
751
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>> | null,
752
+ FindOneAndUpdateCommand
753
+ > {
754
+ const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
755
+ const accessor = createFieldAccessor<Shape, N>();
756
+ const items = updaterFn(accessor);
757
+ const update = resolveUpdaterResult(items);
758
+ const command = new FindOneAndUpdateCommand(
759
+ this.#state.collection,
760
+ filter,
761
+ update,
762
+ opts.upsert ?? false,
763
+ sort,
764
+ opts.returnDocument ?? 'after',
765
+ );
766
+ const meta: PlanMeta = {
767
+ target: 'mongo',
768
+ storageHash: this.#state.storageHash,
769
+ lane: 'mongo-query',
770
+ paramDescriptors: [],
771
+ };
772
+ return { collection: this.#state.collection, command, meta };
773
+ }
774
+
775
+ /**
776
+ * Find a single document matching the accumulated pipeline and delete it.
777
+ * Same marker gating and deconstruction as `findOneAndUpdate`.
778
+ */
779
+ findOneAndDelete(
780
+ this: PipelineChain<TContract, Shape, U, 'fam-ok', L, N>,
781
+ ): MongoQueryPlan<
782
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>> | null,
783
+ FindOneAndDeleteCommand
784
+ > {
785
+ const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
786
+ const command = new FindOneAndDeleteCommand(this.#state.collection, filter, sort);
787
+ const meta: PlanMeta = {
788
+ target: 'mongo',
789
+ storageHash: this.#state.storageHash,
790
+ lane: 'mongo-query',
791
+ paramDescriptors: [],
792
+ };
793
+ return { collection: this.#state.collection, command, meta };
794
+ }
795
+
796
+ // --- Read terminals ---
797
+
798
+ /**
799
+ * Materialise the chain as a `MongoQueryPlan` wrapping an `AggregateCommand`.
800
+ */
801
+ build(): MongoQueryPlan<ResolveRow<Shape, ExtractMongoCodecTypes<TContract>>, AggregateCommand> {
802
+ const command = new AggregateCommand(this.#state.collection, this.#state.stages);
803
+ const meta: PlanMeta = {
804
+ target: 'mongo',
805
+ storageHash: this.#state.storageHash,
806
+ lane: 'mongo-query',
807
+ paramDescriptors: [],
808
+ };
809
+ return { collection: this.#state.collection, command, meta };
810
+ }
811
+
812
+ /**
813
+ * Alias for `build()` — surfaces the read intent at the call site.
814
+ */
815
+ aggregate(): MongoQueryPlan<
816
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>>,
817
+ AggregateCommand
818
+ > {
819
+ return this.build();
820
+ }
821
+ }
822
+
823
+ interface DeconstructedFindAndModify {
824
+ filter: MongoFilterExpr;
825
+ sort: Record<string, 1 | -1> | undefined;
826
+ }
827
+
828
+ /**
829
+ * Walk the accumulated pipeline stages and extract the `filter` and `sort`
830
+ * slots for a `findOneAndUpdate` / `findOneAndDelete` wire command.
831
+ *
832
+ * The helper accepts exactly the canonical shape `match+ -> sort?` and
833
+ * nothing else:
834
+ *
835
+ * - one or more `$match` stages (AND-folded into a single filter),
836
+ * - optionally followed by a single `$sort` stage.
837
+ *
838
+ * Anything else — a `$sort` before `$match`, multiple `$sort` stages, a
839
+ * `$skip` stage, or any non-`$match`/`$sort` stage — is rejected with a
840
+ * clear error. The type system already prevents most of these at compile
841
+ * time via the `FindAndModifyEnabled` marker, but the runtime check
842
+ * guards the escape hatches (e.g. `.pipe(...)`) and future marker gaps.
843
+ *
844
+ * `$skip` is rejected outright because MongoDB's `findAndModify` command
845
+ * has no skip slot; a silently-dropped skip is a latent correctness bug
846
+ * waiting to happen. (A02 removed skip from the typed AST for the same
847
+ * reason.)
848
+ */
849
+ function deconstructFindAndModifyChain(
850
+ stages: ReadonlyArray<MongoPipelineStage>,
851
+ ): DeconstructedFindAndModify {
852
+ const matchFilters: MongoFilterExpr[] = [];
853
+ let sort: Record<string, 1 | -1> | undefined;
854
+ let seenNonMatch = false;
855
+
856
+ for (const stage of stages) {
857
+ if (stage instanceof MongoMatchStage) {
858
+ if (seenNonMatch) {
859
+ throw new Error(
860
+ 'findOneAndUpdate/findOneAndDelete requires the canonical $match+ -> $sort? shape, ' +
861
+ 'but a $match stage was found after a $sort. Re-order the chain so every .match() ' +
862
+ 'call precedes the .sort() call.',
863
+ );
864
+ }
865
+ matchFilters.push(stage.filter);
866
+ } else if (stage instanceof MongoSortStage) {
867
+ if (sort !== undefined) {
868
+ throw new Error(
869
+ 'findOneAndUpdate/findOneAndDelete accepts at most one $sort stage; drop the extra ' +
870
+ '.sort() call or combine the keys into a single sort spec.',
871
+ );
872
+ }
873
+ sort = { ...stage.sort };
874
+ seenNonMatch = true;
875
+ } else if (stage instanceof MongoSkipStage) {
876
+ throw new Error(
877
+ 'findOneAndUpdate/findOneAndDelete does not support .skip() — MongoDB findAndModify ' +
878
+ 'has no skip slot. Remove the .skip() call, or use .aggregate()/.build() if the ' +
879
+ 'chain needs skip semantics.',
880
+ );
881
+ } else {
882
+ throw new Error(
883
+ 'findOneAndUpdate/findOneAndDelete requires the canonical $match+ -> $sort? shape, ' +
884
+ `but encountered a '${stage.constructor.name}' stage. ` +
885
+ 'This is likely a bug — the type system should have prevented this chain.',
886
+ );
887
+ }
888
+ }
889
+
890
+ if (matchFilters.length === 0) {
891
+ throw new Error('findOneAndUpdate/findOneAndDelete requires at least one .match() call.');
892
+ }
893
+ const first = matchFilters[0];
894
+ if (first === undefined) {
895
+ throw new Error('Unreachable: matchFilters.length > 0 but first is undefined');
896
+ }
897
+ const filter: MongoFilterExpr = matchFilters.length === 1 ? first : MongoAndExpr.of(matchFilters);
898
+
899
+ return { filter, sort };
900
+ }
901
+
902
+ interface DeconstructedUpdate {
903
+ filter: MongoFilterExpr;
904
+ updatePipeline: ReadonlyArray<MongoUpdatePipelineStage>;
905
+ }
906
+
907
+ /**
908
+ * Walk the accumulated pipeline stages: leading `$match` stages become the
909
+ * filter, remaining stages must all be valid `MongoUpdatePipelineStage`
910
+ * members (currently `$addFields`, `$project`, `$replaceRoot`).
911
+ */
912
+ function deconstructUpdateChain(stages: ReadonlyArray<MongoPipelineStage>): DeconstructedUpdate {
913
+ const matchFilters: MongoFilterExpr[] = [];
914
+ let boundary = 0;
915
+
916
+ for (const stage of stages) {
917
+ if (!(stage instanceof MongoMatchStage)) break;
918
+ matchFilters.push(stage.filter);
919
+ boundary++;
920
+ }
921
+
922
+ if (matchFilters.length === 0) {
923
+ throw new Error('No-arg updateMany/updateOne requires at least one .match() call.');
924
+ }
925
+
926
+ const remaining = stages.slice(boundary);
927
+ if (remaining.length === 0) {
928
+ throw new Error(
929
+ 'No-arg updateMany/updateOne requires at least one pipeline-update stage ' +
930
+ '(e.g. .addFields(), .project(), .replaceRoot()) after the .match() stages.',
931
+ );
932
+ }
933
+
934
+ const updatePipeline: MongoUpdatePipelineStage[] = [];
935
+ for (const stage of remaining) {
936
+ if (
937
+ stage instanceof MongoAddFieldsStage ||
938
+ stage instanceof MongoProjectStage ||
939
+ stage instanceof MongoReplaceRootStage
940
+ ) {
941
+ updatePipeline.push(stage);
942
+ } else {
943
+ throw new Error(
944
+ `No-arg updateMany/updateOne: encountered non-update stage '${stage.constructor.name}' ` +
945
+ 'after the leading $match stages. Only $addFields/$set, $project/$unset, ' +
946
+ 'and $replaceRoot/$replaceWith stages are valid in an update pipeline.',
947
+ );
948
+ }
949
+ }
950
+
951
+ const first = matchFilters[0];
952
+ if (first === undefined) {
953
+ throw new Error('Unreachable: matchFilters.length > 0 but first is undefined');
954
+ }
955
+ const filter: MongoFilterExpr = matchFilters.length === 1 ? first : MongoAndExpr.of(matchFilters);
956
+
957
+ return { filter, updatePipeline };
958
+ }