@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.
@@ -0,0 +1,651 @@
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
+ DeleteResult,
10
+ InsertManyResult,
11
+ InsertOneResult,
12
+ MongoFilterExpr,
13
+ MongoQueryPlan,
14
+ MongoUpdateSpec,
15
+ UpdateResult,
16
+ } from '@prisma-next/mongo-query-ast/execution';
17
+ import {
18
+ DeleteManyCommand,
19
+ DeleteOneCommand,
20
+ FindOneAndDeleteCommand,
21
+ FindOneAndUpdateCommand,
22
+ InsertManyCommand,
23
+ InsertOneCommand,
24
+ MongoAndExpr,
25
+ MongoExistsExpr,
26
+ MongoMatchStage,
27
+ UpdateManyCommand,
28
+ UpdateOneCommand,
29
+ } from '@prisma-next/mongo-query-ast/execution';
30
+ import type { MongoValue } from '@prisma-next/mongo-value';
31
+ import { PipelineChain } from './builder';
32
+ import { createFieldAccessor, type FieldAccessor } from './field-accessor';
33
+ import type { ModelNestedShape, NestedDocShape } from './resolve-path';
34
+ import type { ModelToDocShape, ResolveRow } from './types';
35
+ import { resolveUpdaterResult, type UpdaterResult } from './update-ops';
36
+
37
+ /**
38
+ * "Match-all" filter used by the unqualified-write terminals
39
+ * (`updateAll`/`deleteAll`). The canonical representation is still
40
+ * undecided — `MongoAndExpr` with an empty conjunction and a dedicated
41
+ * `MongoMatchAllExpr` node are both candidates. For now we use
42
+ * `_id $exists: true`, which is trivially true on every document and
43
+ * avoids introducing a new AST node before the wider question is
44
+ * resolved. Centralised so the eventual switch is a one-line change.
45
+ */
46
+ function matchAllFilter(): MongoFilterExpr {
47
+ return MongoExistsExpr.exists('_id');
48
+ }
49
+
50
+ /**
51
+ * Resolve an updater callback into a `MongoUpdateSpec` (either the folded
52
+ * operator object or a pipeline-stage array). Centralised so all write
53
+ * terminals share the same fold / dispatch semantics.
54
+ */
55
+ function resolveUpdaterCallback<
56
+ Shape extends ModelToDocShape<MongoContract, string>,
57
+ Nested extends NestedDocShape,
58
+ >(updaterFn: (fields: FieldAccessor<Shape, Nested>) => UpdaterResult): MongoUpdateSpec {
59
+ const accessor = createFieldAccessor<Shape, Nested>();
60
+ const items = updaterFn(accessor);
61
+ return resolveUpdaterResult(items);
62
+ }
63
+
64
+ /**
65
+ * Build the `PlanMeta` envelope shared by every write terminal in this
66
+ * package. Lane is `mongo-query` (single lane for all query-builder terminals)
67
+ * so middleware can dispatch on intent without inspecting the command.
68
+ */
69
+ function writeMeta(storageHash: string): PlanMeta {
70
+ return {
71
+ target: 'mongo',
72
+ storageHash,
73
+ lane: 'mongo-query',
74
+ paramDescriptors: [],
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Root state of the query-builder state machine. Returned from
80
+ * `mongoQuery(...).from(name)` and bound to a single collection.
81
+ *
82
+ * Inherits the entire pipeline-stage surface from `PipelineChain` (since an
83
+ * empty `CollectionHandle` is observably an empty pipeline). Adds:
84
+ *
85
+ * - `match(...)` — overridden to transition to `FilteredCollection`, which
86
+ * accumulates filters for eventual splatting into write/find-and-modify
87
+ * wire commands.
88
+ * - **Insert / unqualified-write methods** (M2): `insertOne`, `insertMany`,
89
+ * `updateAll`, `deleteAll`. These live *only* here — the corresponding
90
+ * methods are absent from `FilteredCollection`, so a caller cannot
91
+ * accidentally produce an unqualified write by forgetting to `.match(...)`
92
+ * later in the chain. Bodies land in M2.
93
+ */
94
+ export class CollectionHandle<
95
+ TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>,
96
+ ModelName extends keyof TContract['models'] & string,
97
+ > extends PipelineChain<
98
+ TContract,
99
+ ModelToDocShape<TContract, ModelName>,
100
+ 'update-cleared',
101
+ 'fam-cleared',
102
+ 'leading',
103
+ ModelNestedShape<TContract, ModelName>
104
+ > {
105
+ readonly #ctx: BindingContext<TContract>;
106
+ readonly #modelName: ModelName;
107
+
108
+ constructor(ctx: BindingContext<TContract>, modelName: ModelName) {
109
+ super(ctx.contract, {
110
+ collection: ctx.collection,
111
+ stages: [],
112
+ storageHash: ctx.storageHash,
113
+ });
114
+ this.#ctx = ctx;
115
+ this.#modelName = modelName;
116
+ }
117
+
118
+ /**
119
+ * Bound model name. Exposed so type tests can assert the binding without
120
+ * flipping into a pipeline. Not part of the public-API contract.
121
+ */
122
+ get _modelName(): ModelName {
123
+ return this.#modelName;
124
+ }
125
+
126
+ /**
127
+ * Begin accumulating a filter. Transitions to `FilteredCollection`.
128
+ *
129
+ * Overrides `PipelineChain.match` (which appends another `$match` stage
130
+ * and stays in the chain). The two implementations are semantically
131
+ * equivalent for the read terminal — multiple `$match` stages AND-fold in
132
+ * Mongo — but `FilteredCollection` makes the accumulated filter
133
+ * addressable for the write/find-and-modify terminals landing in M2/M3.
134
+ */
135
+ override match(filter: MongoFilterExpr): FilteredCollection<TContract, ModelName>;
136
+ override match(
137
+ fn: (
138
+ fields: FieldAccessor<
139
+ ModelToDocShape<TContract, ModelName>,
140
+ ModelNestedShape<TContract, ModelName>
141
+ >,
142
+ ) => MongoFilterExpr,
143
+ ): FilteredCollection<TContract, ModelName>;
144
+ override match(
145
+ filterOrFn:
146
+ | MongoFilterExpr
147
+ | ((
148
+ fields: FieldAccessor<
149
+ ModelToDocShape<TContract, ModelName>,
150
+ ModelNestedShape<TContract, ModelName>
151
+ >,
152
+ ) => MongoFilterExpr),
153
+ ): FilteredCollection<TContract, ModelName> {
154
+ const resolved =
155
+ typeof filterOrFn === 'function'
156
+ ? filterOrFn(
157
+ createFieldAccessor<
158
+ ModelToDocShape<TContract, ModelName>,
159
+ ModelNestedShape<TContract, ModelName>
160
+ >(),
161
+ )
162
+ : filterOrFn;
163
+ return new FilteredCollection<TContract, ModelName>(this.#ctx, this.#modelName, [resolved]);
164
+ }
165
+
166
+ // --- Inserts ---
167
+
168
+ /**
169
+ * Insert a single document. Document fields are passed straight through to
170
+ * the wire `InsertOneCommand` — codec normalisation happens at the
171
+ * adapter/driver boundary, identically to the SQL builder (see Open Item
172
+ * #14 confirmation in the design conversation).
173
+ *
174
+ * Returns a `MongoQueryPlan<InsertOneResult>` whose row stream yields a
175
+ * single result document with the server-assigned `insertedId`.
176
+ */
177
+ insertOne(
178
+ document: Record<string, MongoValue>,
179
+ ): MongoQueryPlan<InsertOneResult, InsertOneCommand> {
180
+ const command = new InsertOneCommand(this.#ctx.collection, document);
181
+ return {
182
+ collection: this.#ctx.collection,
183
+ command,
184
+ meta: writeMeta(this.#ctx.storageHash),
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Insert a batch of documents. Order is preserved in the returned
190
+ * `insertedIds` array.
191
+ */
192
+ insertMany(
193
+ documents: ReadonlyArray<Record<string, MongoValue>>,
194
+ ): MongoQueryPlan<InsertManyResult, InsertManyCommand> {
195
+ if (documents.length === 0) {
196
+ throw new Error('insertMany() requires at least one document.');
197
+ }
198
+ const command = new InsertManyCommand(this.#ctx.collection, documents);
199
+ return {
200
+ collection: this.#ctx.collection,
201
+ command,
202
+ meta: writeMeta(this.#ctx.storageHash),
203
+ };
204
+ }
205
+
206
+ // --- Unqualified writes ---
207
+
208
+ /**
209
+ * Update *every* document in the collection. Lives only on
210
+ * `CollectionHandle` — the corresponding method is intentionally absent
211
+ * from `FilteredCollection` so a caller cannot accidentally produce an
212
+ * unqualified write by forgetting to `.match(...)` first. Pair with
213
+ * `.match(...).updateMany(...)` for the filtered case.
214
+ */
215
+ updateAll(
216
+ updaterFn: (
217
+ fields: FieldAccessor<
218
+ ModelToDocShape<TContract, ModelName>,
219
+ ModelNestedShape<TContract, ModelName>
220
+ >,
221
+ ) => UpdaterResult,
222
+ ): MongoQueryPlan<UpdateResult, UpdateManyCommand> {
223
+ const update = resolveUpdaterCallback<
224
+ ModelToDocShape<TContract, ModelName>,
225
+ ModelNestedShape<TContract, ModelName>
226
+ >(updaterFn);
227
+ const command = new UpdateManyCommand(this.#ctx.collection, matchAllFilter(), update);
228
+ return {
229
+ collection: this.#ctx.collection,
230
+ command,
231
+ meta: writeMeta(this.#ctx.storageHash),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Delete *every* document in the collection. See `updateAll` for the
237
+ * rationale around the unqualified-write surface being limited to this
238
+ * state class.
239
+ */
240
+ deleteAll(): MongoQueryPlan<DeleteResult, DeleteManyCommand> {
241
+ const command = new DeleteManyCommand(this.#ctx.collection, matchAllFilter());
242
+ return {
243
+ collection: this.#ctx.collection,
244
+ command,
245
+ meta: writeMeta(this.#ctx.storageHash),
246
+ };
247
+ }
248
+
249
+ // --- Upserts ---
250
+
251
+ /**
252
+ * Insert-or-update the document matching `filterFn`. The filter is
253
+ * mandatory (vs. `updateAll`'s tautological match) because an upsert
254
+ * without a discriminating predicate would either match every existing
255
+ * document or insert an indistinguishable new one.
256
+ *
257
+ * Maps to `UpdateOneCommand` with `upsert: true`. The driver inserts a
258
+ * new document derived from the filter equality fields plus the update
259
+ * spec when no match is found; otherwise updates the matched document.
260
+ */
261
+ upsertOne(
262
+ filterFn: (
263
+ fields: FieldAccessor<
264
+ ModelToDocShape<TContract, ModelName>,
265
+ ModelNestedShape<TContract, ModelName>
266
+ >,
267
+ ) => MongoFilterExpr,
268
+ updaterFn: (
269
+ fields: FieldAccessor<
270
+ ModelToDocShape<TContract, ModelName>,
271
+ ModelNestedShape<TContract, ModelName>
272
+ >,
273
+ ) => UpdaterResult,
274
+ ): MongoQueryPlan<UpdateResult, UpdateOneCommand> {
275
+ const accessor = createFieldAccessor<
276
+ ModelToDocShape<TContract, ModelName>,
277
+ ModelNestedShape<TContract, ModelName>
278
+ >();
279
+ const filter = filterFn(accessor);
280
+ const update = resolveUpdaterCallback<
281
+ ModelToDocShape<TContract, ModelName>,
282
+ ModelNestedShape<TContract, ModelName>
283
+ >(updaterFn);
284
+ const command = new UpdateOneCommand(this.#ctx.collection, filter, update, true);
285
+ return {
286
+ collection: this.#ctx.collection,
287
+ command,
288
+ meta: writeMeta(this.#ctx.storageHash),
289
+ };
290
+ }
291
+ }
292
+
293
+ /**
294
+ * State reached after one or more `.match(...)` calls on `CollectionHandle`.
295
+ *
296
+ * Inherits the pipeline-stage surface from `PipelineChain`, with the
297
+ * accumulated filters baked in as a leading `$match` stage on the underlying
298
+ * pipeline state. This means read-terminal output (`.aggregate()` /
299
+ * `.build()`) and any subsequent pipeline-stage chain see the filtered
300
+ * collection as input — the read story works through pure inheritance.
301
+ *
302
+ * Adds:
303
+ *
304
+ * - `match(...)` — pushes another `$match` stage *and* records the filter in
305
+ * the accumulator, so the eventual write/find-and-modify terminal can
306
+ * splat the AND-folded filter into the wire command's `filter` slot.
307
+ * - **Filtered writes** (M2): `updateMany`, `updateOne`, `deleteMany`,
308
+ * `deleteOne`, `upsertOne`. Stubbed in M1. (Upsert-many is an open
309
+ * question in the spec — see TML-2267 — and is intentionally absent.)
310
+ * - **Find-and-modify** (M3): `findOneAndUpdate`, `findOneAndDelete`.
311
+ * Stubbed in M1.
312
+ *
313
+ * Notably *does not* expose `insertOne`/`insertMany`/`updateAll`/`deleteAll`
314
+ * — those are insert or unqualified-write operations that are nonsense
315
+ * after a filter has been applied.
316
+ */
317
+ export class FilteredCollection<
318
+ TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>,
319
+ ModelName extends keyof TContract['models'] & string,
320
+ > extends PipelineChain<
321
+ TContract,
322
+ ModelToDocShape<TContract, ModelName>,
323
+ 'update-cleared',
324
+ 'fam-cleared',
325
+ 'leading',
326
+ ModelNestedShape<TContract, ModelName>
327
+ > {
328
+ readonly #ctx: BindingContext<TContract>;
329
+ readonly #modelName: ModelName;
330
+ readonly #filters: ReadonlyArray<MongoFilterExpr>;
331
+
332
+ constructor(
333
+ ctx: BindingContext<TContract>,
334
+ modelName: ModelName,
335
+ filters: ReadonlyArray<MongoFilterExpr>,
336
+ ) {
337
+ if (filters.length === 0) {
338
+ throw new Error('FilteredCollection requires at least one accumulated filter');
339
+ }
340
+ const first = filters[0];
341
+ if (first === undefined) {
342
+ throw new Error('FilteredCollection: unreachable empty-filters branch');
343
+ }
344
+ const leading = filters.length === 1 ? first : foldAnd(filters);
345
+ super(ctx.contract, {
346
+ collection: ctx.collection,
347
+ stages: [new MongoMatchStage(leading)],
348
+ storageHash: ctx.storageHash,
349
+ });
350
+ this.#ctx = ctx;
351
+ this.#modelName = modelName;
352
+ this.#filters = filters;
353
+ }
354
+
355
+ get _modelName(): ModelName {
356
+ return this.#modelName;
357
+ }
358
+
359
+ /**
360
+ * Accumulated filter list. Exposed for the M2/M3 write/find-and-modify
361
+ * terminals to splat into wire-command `filter` slots; not part of the
362
+ * public-API contract.
363
+ */
364
+ get _filters(): ReadonlyArray<MongoFilterExpr> {
365
+ return this.#filters;
366
+ }
367
+
368
+ /**
369
+ * Append another filter to the accumulator. Returns a new
370
+ * `FilteredCollection` whose underlying pipeline rebuilds the leading
371
+ * `$match` from the AND-folded accumulator (rather than appending a
372
+ * second `$match` stage), so the write/find-and-modify terminals see a
373
+ * single authoritative filter expression.
374
+ */
375
+ override match(filter: MongoFilterExpr): FilteredCollection<TContract, ModelName>;
376
+ override match(
377
+ fn: (
378
+ fields: FieldAccessor<
379
+ ModelToDocShape<TContract, ModelName>,
380
+ ModelNestedShape<TContract, ModelName>
381
+ >,
382
+ ) => MongoFilterExpr,
383
+ ): FilteredCollection<TContract, ModelName>;
384
+ override match(
385
+ filterOrFn:
386
+ | MongoFilterExpr
387
+ | ((
388
+ fields: FieldAccessor<
389
+ ModelToDocShape<TContract, ModelName>,
390
+ ModelNestedShape<TContract, ModelName>
391
+ >,
392
+ ) => MongoFilterExpr),
393
+ ): FilteredCollection<TContract, ModelName> {
394
+ const resolved =
395
+ typeof filterOrFn === 'function'
396
+ ? filterOrFn(
397
+ createFieldAccessor<
398
+ ModelToDocShape<TContract, ModelName>,
399
+ ModelNestedShape<TContract, ModelName>
400
+ >(),
401
+ )
402
+ : filterOrFn;
403
+ return new FilteredCollection<TContract, ModelName>(this.#ctx, this.#modelName, [
404
+ ...this.#filters,
405
+ resolved,
406
+ ]);
407
+ }
408
+
409
+ // --- Filtered writes ---
410
+
411
+ /**
412
+ * AND-fold the accumulated filters into a single `MongoFilterExpr` for
413
+ * splatting into a write/find-and-modify wire command's `filter` slot.
414
+ * Length-1 short-circuits to avoid a redundant `$and` wrapper.
415
+ */
416
+ #foldedFilter(): MongoFilterExpr {
417
+ const first = this.#filters[0];
418
+ if (first === undefined) {
419
+ throw new Error('FilteredCollection: invariant violated — empty filter accumulator');
420
+ }
421
+ return this.#filters.length === 1 ? first : foldAnd(this.#filters);
422
+ }
423
+
424
+ /**
425
+ * Update every matching document. `updaterFn` receives a `FieldAccessor`
426
+ * and returns an array of `TypedUpdateOp` (e.g. `[f.amount.inc(1),
427
+ * f.status.set('done')]`). Operators are folded into the wire-format
428
+ * update spec by `foldUpdateOps`, which throws on operator+path
429
+ * collisions.
430
+ */
431
+ override updateMany(
432
+ updaterFn: (
433
+ fields: FieldAccessor<
434
+ ModelToDocShape<TContract, ModelName>,
435
+ ModelNestedShape<TContract, ModelName>
436
+ >,
437
+ ) => UpdaterResult,
438
+ ): MongoQueryPlan<UpdateResult, UpdateManyCommand> {
439
+ const update = resolveUpdaterCallback<
440
+ ModelToDocShape<TContract, ModelName>,
441
+ ModelNestedShape<TContract, ModelName>
442
+ >(updaterFn);
443
+ const command = new UpdateManyCommand(this.#ctx.collection, this.#foldedFilter(), update);
444
+ return {
445
+ collection: this.#ctx.collection,
446
+ command,
447
+ meta: writeMeta(this.#ctx.storageHash),
448
+ };
449
+ }
450
+
451
+ /**
452
+ * Update at most one matching document. The driver picks the document
453
+ * (typically the first one matched by the underlying scan); no ordering
454
+ * guarantee is implied — chain `.sort(...)` and use the M3
455
+ * `.findOneAndUpdate(...)` terminal when ordering matters.
456
+ */
457
+ override updateOne(
458
+ updaterFn: (
459
+ fields: FieldAccessor<
460
+ ModelToDocShape<TContract, ModelName>,
461
+ ModelNestedShape<TContract, ModelName>
462
+ >,
463
+ ) => UpdaterResult,
464
+ ): MongoQueryPlan<UpdateResult, UpdateOneCommand> {
465
+ const update = resolveUpdaterCallback<
466
+ ModelToDocShape<TContract, ModelName>,
467
+ ModelNestedShape<TContract, ModelName>
468
+ >(updaterFn);
469
+ const command = new UpdateOneCommand(this.#ctx.collection, this.#foldedFilter(), update);
470
+ return {
471
+ collection: this.#ctx.collection,
472
+ command,
473
+ meta: writeMeta(this.#ctx.storageHash),
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Delete every matching document.
479
+ */
480
+ deleteMany(): MongoQueryPlan<DeleteResult, DeleteManyCommand> {
481
+ const command = new DeleteManyCommand(this.#ctx.collection, this.#foldedFilter());
482
+ return {
483
+ collection: this.#ctx.collection,
484
+ command,
485
+ meta: writeMeta(this.#ctx.storageHash),
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Delete at most one matching document. See the `updateOne` note about
491
+ * driver-chosen victim selection.
492
+ */
493
+ deleteOne(): MongoQueryPlan<DeleteResult, DeleteOneCommand> {
494
+ const command = new DeleteOneCommand(this.#ctx.collection, this.#foldedFilter());
495
+ return {
496
+ collection: this.#ctx.collection,
497
+ command,
498
+ meta: writeMeta(this.#ctx.storageHash),
499
+ };
500
+ }
501
+
502
+ // --- Upserts ---
503
+
504
+ /**
505
+ * Insert-or-update against the accumulated filter. Maps to
506
+ * `UpdateOneCommand` with `upsert: true`. Equivalent to
507
+ * `CollectionHandle.upsertOne(f => filter, updaterFn)` but reuses the
508
+ * already-accumulated `.match(...)` filter chain.
509
+ */
510
+ upsertOne(
511
+ updaterFn: (
512
+ fields: FieldAccessor<
513
+ ModelToDocShape<TContract, ModelName>,
514
+ ModelNestedShape<TContract, ModelName>
515
+ >,
516
+ ) => UpdaterResult,
517
+ ): MongoQueryPlan<UpdateResult, UpdateOneCommand> {
518
+ const update = resolveUpdaterCallback<
519
+ ModelToDocShape<TContract, ModelName>,
520
+ ModelNestedShape<TContract, ModelName>
521
+ >(updaterFn);
522
+ const command = new UpdateOneCommand(this.#ctx.collection, this.#foldedFilter(), update, true);
523
+ return {
524
+ collection: this.#ctx.collection,
525
+ command,
526
+ meta: writeMeta(this.#ctx.storageHash),
527
+ };
528
+ }
529
+
530
+ // --- Find-and-modify ---
531
+
532
+ /**
533
+ * Find a single matching document and apply `updaterFn` to it.
534
+ *
535
+ * `opts.upsert` (default `false`) toggles insert-on-miss behaviour.
536
+ * `opts.returnDocument` (default `'after'`) controls whether the row
537
+ * stream yields the document as it was before or after the update.
538
+ */
539
+ override findOneAndUpdate(
540
+ updaterFn: (
541
+ fields: FieldAccessor<
542
+ ModelToDocShape<TContract, ModelName>,
543
+ ModelNestedShape<TContract, ModelName>
544
+ >,
545
+ ) => UpdaterResult,
546
+ opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {},
547
+ ): MongoQueryPlan<
548
+ ResolveRow<ModelToDocShape<TContract, ModelName>, ExtractMongoCodecTypes<TContract>> | null,
549
+ FindOneAndUpdateCommand
550
+ > {
551
+ const update = resolveUpdaterCallback<
552
+ ModelToDocShape<TContract, ModelName>,
553
+ ModelNestedShape<TContract, ModelName>
554
+ >(updaterFn);
555
+ const command = new FindOneAndUpdateCommand(
556
+ this.#ctx.collection,
557
+ this.#foldedFilter(),
558
+ update,
559
+ opts.upsert ?? false,
560
+ undefined,
561
+ opts.returnDocument ?? 'after',
562
+ );
563
+ return {
564
+ collection: this.#ctx.collection,
565
+ command,
566
+ meta: writeMeta(this.#ctx.storageHash),
567
+ };
568
+ }
569
+
570
+ /**
571
+ * Find a single matching document and delete it. Returns the deleted
572
+ * document via the row stream.
573
+ */
574
+ override findOneAndDelete(): MongoQueryPlan<
575
+ ResolveRow<ModelToDocShape<TContract, ModelName>, ExtractMongoCodecTypes<TContract>> | null,
576
+ FindOneAndDeleteCommand
577
+ > {
578
+ const command = new FindOneAndDeleteCommand(this.#ctx.collection, this.#foldedFilter());
579
+ return {
580
+ collection: this.#ctx.collection,
581
+ command,
582
+ meta: writeMeta(this.#ctx.storageHash),
583
+ };
584
+ }
585
+ }
586
+
587
+ function foldAnd(filters: ReadonlyArray<MongoFilterExpr>): MongoFilterExpr {
588
+ return MongoAndExpr.of(filters);
589
+ }
590
+
591
+ /**
592
+ * Narrow a `MongoContractWithTypeMaps`-shaped value down to its underlying
593
+ * `MongoContract` view. `MongoContractWithTypeMaps<C, ...>` is defined as
594
+ * `C & { readonly [phantom]?: TTypeMaps }`, so every contract we accept is
595
+ * structurally a `MongoContract` — the phantom is type-only. This helper
596
+ * centralises that narrowing so callers don't reach for `as unknown as
597
+ * MongoContract` double-casts.
598
+ */
599
+ export function asMongoContract(
600
+ contract: MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>,
601
+ ): MongoContract {
602
+ return contract;
603
+ }
604
+
605
+ /**
606
+ * Bound execution context shared across the three state classes.
607
+ */
608
+ export interface BindingContext<
609
+ TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>,
610
+ > {
611
+ readonly contract: TContract;
612
+ readonly collection: string;
613
+ readonly storageHash: string;
614
+ }
615
+
616
+ /**
617
+ * Construct a `CollectionHandle` from a validated contract + root name.
618
+ * Used by `mongoQuery(...).from(name)` to enter the state machine.
619
+ */
620
+ export function createCollectionHandle<
621
+ TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>,
622
+ RootName extends keyof TContract['roots'] & string,
623
+ >(
624
+ contract: TContract,
625
+ rootName: RootName,
626
+ ): CollectionHandle<TContract, TContract['roots'][RootName] & string & keyof TContract['models']> {
627
+ const c = asMongoContract(contract);
628
+ const modelName = c.roots[rootName];
629
+ if (!modelName) {
630
+ const validRoots = Object.keys(c.roots).join(', ');
631
+ throw new Error(`Unknown root: "${rootName}". Valid roots: ${validRoots}`);
632
+ }
633
+ const model = c.models[modelName];
634
+ if (!model) {
635
+ throw new Error(`Unknown model: "${modelName}" referenced by root "${rootName}".`);
636
+ }
637
+ const collectionName = model.storage?.collection ?? rootName;
638
+ if (!c.storage?.storageHash) {
639
+ throw new Error(
640
+ 'Contract is missing storage.storageHash. Pass a validated contract to mongoQuery().',
641
+ );
642
+ }
643
+ return new CollectionHandle(
644
+ {
645
+ contract,
646
+ collection: collectionName,
647
+ storageHash: String(c.storage.storageHash),
648
+ },
649
+ modelName as TContract['roots'][RootName] & string & keyof TContract['models'],
650
+ );
651
+ }