@prisma-next/mongo-query-builder 0.5.0-dev.53 → 0.5.0-dev.54

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/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "@prisma-next/mongo-query-builder",
3
- "version": "0.5.0-dev.53",
3
+ "version": "0.5.0-dev.54",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Type-safe MongoDB query builder (reads, writes, find-and-modify, pipeline-terminal writes) with document shape tracking",
7
7
  "dependencies": {
8
- "@prisma-next/mongo-contract": "0.5.0-dev.53",
9
- "@prisma-next/contract": "0.5.0-dev.53",
10
- "@prisma-next/mongo-query-ast": "0.5.0-dev.53",
11
- "@prisma-next/utils": "0.5.0-dev.53",
12
- "@prisma-next/mongo-value": "0.5.0-dev.53"
8
+ "@prisma-next/contract": "0.5.0-dev.54",
9
+ "@prisma-next/mongo-contract": "0.5.0-dev.54",
10
+ "@prisma-next/mongo-query-ast": "0.5.0-dev.54",
11
+ "@prisma-next/mongo-value": "0.5.0-dev.54",
12
+ "@prisma-next/utils": "0.5.0-dev.54"
13
13
  },
14
14
  "devDependencies": {
15
15
  "tsdown": "0.18.4",
16
16
  "typescript": "5.9.3",
17
17
  "vitest": "4.0.17",
18
- "@prisma-next/tsconfig": "0.0.0",
19
- "@prisma-next/tsdown": "0.0.0"
18
+ "@prisma-next/tsdown": "0.0.0",
19
+ "@prisma-next/tsconfig": "0.0.0"
20
20
  },
21
21
  "files": [
22
22
  "dist",
package/src/builder.ts CHANGED
@@ -58,9 +58,15 @@ import {
58
58
  } from '@prisma-next/mongo-query-ast/execution';
59
59
  import { ifDefined } from '@prisma-next/utils/defined';
60
60
  import { createFieldAccessor, type Expression, type FieldAccessor } from './field-accessor';
61
+ import {
62
+ createLookupFrom,
63
+ extractLookupResult,
64
+ type LookupFrom,
65
+ type LookupResult,
66
+ } from './lookup-builder';
61
67
  import type { FindAndModifyEnabled, LeadingMatch, UpdateEnabled } from './markers';
62
68
  import { pipelineSupportsFlatResultShape } from './pipeline-result-shape';
63
- import type { NestedDocShape } from './resolve-path';
69
+ import type { ModelArrayField, NestedDocShape } from './resolve-path';
64
70
  import { contractModelToMongoResultShape } from './result-shape';
65
71
  import type {
66
72
  DocField,
@@ -265,40 +271,39 @@ export class PipelineChain<
265
271
  * the `update` or `findAndModify` wire commands. The original document's
266
272
  * nested-path shape `N` is preserved (the lookup adds a sidecar array
267
273
  * field; existing keys are untouched).
274
+ *
275
+ * The single callback receives a `from` callable that grounds the
276
+ * foreign-root literal sequentially before the inner `on(...)`
277
+ * callback is type-checked — see `lookup-builder.ts`. The resulting
278
+ * `Shape` gains the `As` key as a `ModelArrayField<ModelName>` so
279
+ * `ResolveRow` produces `Array<ForeignRow>` (with concrete leaf
280
+ * types) instead of the legacy `unknown[]`.
268
281
  */
269
- lookup<ForeignRoot extends keyof TContract['roots'] & string, As extends string>(options: {
270
- from: ForeignRoot;
271
- localField: keyof Shape & string;
272
- foreignField: string;
273
- as: As;
274
- }): PipelineChain<
282
+ lookup<RootName extends string, ModelName extends string, As extends string>(
283
+ fn: (from: LookupFrom<TContract, Shape, N>) => LookupResult<RootName, ModelName, As>,
284
+ ): PipelineChain<
275
285
  TContract,
276
- Shape & Record<As, { readonly codecId: 'mongo/array@1'; readonly nullable: false }>,
286
+ Shape & Record<As, ModelArrayField<ModelName>>,
277
287
  'update-cleared',
278
288
  'fam-cleared',
279
289
  'past-leading',
280
290
  N
281
291
  > {
282
- const contract: MongoContract = this.#contract;
283
- const modelName = contract.roots[options.from];
284
- if (!modelName) {
285
- const validRoots = Object.keys(contract.roots).join(', ');
286
- throw new Error(`lookup() unknown root: "${options.from}". Valid roots: ${validRoots}`);
287
- }
288
- const model = contract.models[modelName];
289
- const collectionName = model?.storage?.collection ?? options.from;
292
+ const fromCallable = createLookupFrom<TContract, Shape, N>(this.#contract);
293
+ const result = fn(fromCallable);
294
+ const extracted = extractLookupResult(result, this.#contract);
290
295
  return this.#withStage<
291
- Shape & Record<As, { readonly codecId: 'mongo/array@1'; readonly nullable: false }>,
296
+ Shape & Record<As, ModelArrayField<ModelName>>,
292
297
  'update-cleared',
293
298
  'fam-cleared',
294
299
  'past-leading',
295
300
  N
296
301
  >(
297
302
  new MongoLookupStage({
298
- from: collectionName,
299
- localField: options.localField,
300
- foreignField: options.foreignField,
301
- as: options.as,
303
+ from: extracted.foreignCollection,
304
+ localField: extracted.localField,
305
+ foreignField: extracted.foreignField,
306
+ as: extracted.as,
302
307
  }),
303
308
  );
304
309
  }
@@ -752,7 +757,7 @@ export class PipelineChain<
752
757
  updaterFn: (fields: FieldAccessor<Shape, N>) => UpdaterResult,
753
758
  opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {},
754
759
  ): MongoQueryPlan<
755
- ResolveRow<Shape, ExtractMongoCodecTypes<TContract>> | null,
760
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract> | null,
756
761
  FindOneAndUpdateCommand
757
762
  > {
758
763
  const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
@@ -782,7 +787,7 @@ export class PipelineChain<
782
787
  findOneAndDelete(
783
788
  this: PipelineChain<TContract, Shape, U, 'fam-ok', L, N>,
784
789
  ): MongoQueryPlan<
785
- ResolveRow<Shape, ExtractMongoCodecTypes<TContract>> | null,
790
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract> | null,
786
791
  FindOneAndDeleteCommand
787
792
  > {
788
793
  const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
@@ -800,7 +805,10 @@ export class PipelineChain<
800
805
  /**
801
806
  * Materialise the chain as a `MongoQueryPlan` wrapping an `AggregateCommand`.
802
807
  */
803
- build(): MongoQueryPlan<ResolveRow<Shape, ExtractMongoCodecTypes<TContract>>, AggregateCommand> {
808
+ build(): MongoQueryPlan<
809
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract>,
810
+ AggregateCommand
811
+ > {
804
812
  const command = new AggregateCommand(this.#state.collection, this.#state.stages);
805
813
  const meta: PlanMeta = {
806
814
  target: 'mongo',
@@ -830,7 +838,7 @@ export class PipelineChain<
830
838
  * Alias for `build()` — surfaces the read intent at the call site.
831
839
  */
832
840
  aggregate(): MongoQueryPlan<
833
- ResolveRow<Shape, ExtractMongoCodecTypes<TContract>>,
841
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract>,
834
842
  AggregateCommand
835
843
  > {
836
844
  return this.build();
@@ -14,10 +14,19 @@ export type {
14
14
  ObjectExpression,
15
15
  } from '../field-accessor';
16
16
  export { createFieldAccessor } from '../field-accessor';
17
+ export type {
18
+ LookupBuilder,
19
+ LookupBuilderWithKey,
20
+ LookupFrom,
21
+ LookupOnResult,
22
+ LookupResult,
23
+ ModelOf,
24
+ } from '../lookup-builder';
17
25
  export type { FindAndModifyEnabled, UpdateEnabled } from '../markers';
18
26
  export type { QueryRoot } from '../query';
19
27
  export { mongoQuery } from '../query';
20
28
  export type {
29
+ ModelArrayField,
21
30
  ModelNestedShape,
22
31
  NestedDocShape,
23
32
  ObjectField,
@@ -0,0 +1,272 @@
1
+ import type { MongoContract } from '@prisma-next/mongo-contract';
2
+ import { createFieldAccessor, type FieldAccessor, type LeafExpression } from './field-accessor';
3
+ import type { ModelNestedShape } from './resolve-path';
4
+ import type { DocField, DocShape, ModelToDocShape } from './types';
5
+
6
+ /**
7
+ * Resolved foreign-model name for a contract root. Looks `RootName` up
8
+ * through `TContract['roots']` and intersects the result back with
9
+ * `keyof TContract['models']` so it can be used as a `ModelName` index
10
+ * into `models`. Resolves to `never` when the root is not present (this
11
+ * surface should never be reachable through normal use because `from()`
12
+ * constrains its `R` parameter to `keyof TContract['roots']`).
13
+ */
14
+ export type ModelOf<
15
+ TContract extends MongoContract,
16
+ RootName extends keyof TContract['roots'] & string,
17
+ > = TContract['roots'][RootName] & string & keyof TContract['models'];
18
+
19
+ /**
20
+ * Object returned by the user from the `on(...)` callback. Each side is
21
+ * a `LeafExpression` produced by property access on the corresponding
22
+ * `FieldAccessor` (`local._id`, `foreign.customerId`, etc.). Carrying
23
+ * `LeafExpression` rather than the broader `TypedAggExpr` is what makes
24
+ * non-leaf returns (e.g. `fn.toUpper(local._id)`) a compile-time error
25
+ * without per-field operator gating — `LeafExpression` carries `_path`,
26
+ * `TypedAggExpr` does not (see field-accessor.ts L47–L82).
27
+ */
28
+ export interface LookupOnResult {
29
+ readonly local: LeafExpression<DocField>;
30
+ readonly foreign: LeafExpression<DocField>;
31
+ }
32
+
33
+ /**
34
+ * Marker brand on the captured spec returned by the `lookup(...)`
35
+ * callback. The phantom `_brand` literal lets `PipelineChain.lookup`
36
+ * accept the result of `from(...).on(...).as(...)` without exposing the
37
+ * internal field shape to user code, and prevents accidental
38
+ * construction of a malformed spec by hand.
39
+ */
40
+ export type LookupResultBrand = 'mongo-query-builder/lookup-result@1';
41
+
42
+ /**
43
+ * Captured output of the inner `from(name).on(cb).as(name)` chain. The
44
+ * contract is consumed by `PipelineChain.lookup` to construct the
45
+ * `MongoLookupStage` (collection name comes from `models[ModelName]
46
+ * .storage.collection`) and to thread `ModelArrayField<ModelName>` into
47
+ * the resulting `Shape` so the resolver yields `Array<ForeignRow>`.
48
+ *
49
+ * Type parameters carry the foreign-root literal `RootName`, the
50
+ * resolved foreign model name `ModelName`, and the `As` literal so
51
+ * `PipelineChain.lookup`'s return type can encode the result-row
52
+ * promotion precisely.
53
+ */
54
+ export interface LookupResult<
55
+ RootName extends string,
56
+ ModelName extends string,
57
+ As extends string,
58
+ > {
59
+ readonly _brand: LookupResultBrand;
60
+ readonly _root: RootName;
61
+ readonly _model: ModelName;
62
+ readonly _localField: string;
63
+ readonly _foreignField: string;
64
+ readonly _as: As;
65
+ }
66
+
67
+ /**
68
+ * Builder returned by `from(name).on(cb)`. Carries the foreign root /
69
+ * model literals plus the captured local / foreign paths, and exposes
70
+ * `.as(name)` to finalise the spec with the user-chosen field name.
71
+ */
72
+ export interface LookupBuilderWithKey<RootName extends string, ModelName extends string> {
73
+ as<As extends string>(name: As): LookupResult<RootName, ModelName, As>;
74
+ }
75
+
76
+ /**
77
+ * Builder returned by `from(name)`. Carries the foreign root / model
78
+ * literals and the local pipeline's `Shape` / nested shape so the
79
+ * `on(...)` callback's `local` and `foreign` accessors are typed
80
+ * narrowly.
81
+ *
82
+ * `on(cb)` runs the user's callback to capture the leaf paths and
83
+ * returns a `LookupBuilderWithKey` that exposes `.as(name)`.
84
+ */
85
+ export interface LookupBuilder<
86
+ TContract extends MongoContract,
87
+ Shape extends DocShape,
88
+ Nested extends Record<string, DocField>,
89
+ RootName extends string,
90
+ ModelName extends string,
91
+ > {
92
+ on(
93
+ cb: (
94
+ local: FieldAccessor<Shape, Nested>,
95
+ foreign: ModelName extends keyof TContract['models'] & string
96
+ ? FieldAccessor<
97
+ ModelToDocShape<TContract, ModelName>,
98
+ ModelNestedShape<TContract, ModelName>
99
+ >
100
+ : never,
101
+ ) => LookupOnResult,
102
+ ): LookupBuilderWithKey<RootName, ModelName>;
103
+ }
104
+
105
+ /**
106
+ * Type of the `from` callable passed to `PipelineChain.lookup`'s outer
107
+ * callback. The generic argument is inferred from a string-literal
108
+ * argument (the same pattern as `mongoQuery<TC>(...).from('orders')`),
109
+ * which grounds `RootName` into the returned `LookupBuilder` *before*
110
+ * the inner `on(...)` callback is type-checked. This sequential
111
+ * inference is what makes `foreign` resolve narrowly to the foreign
112
+ * model's `FieldAccessor` (verified in the R1.5 spike — see spec § Open
113
+ * Questions / Resolved decisions).
114
+ */
115
+ export type LookupFrom<
116
+ TContract extends MongoContract,
117
+ Shape extends DocShape,
118
+ Nested extends Record<string, DocField>,
119
+ > = <RootName extends keyof TContract['roots'] & string>(
120
+ name: RootName,
121
+ ) => LookupBuilder<TContract, Shape, Nested, RootName, ModelOf<TContract, RootName>>;
122
+
123
+ /**
124
+ * Construct the `from` callable for `PipelineChain.lookup`. The contract
125
+ * is captured so `from(name)` can resolve `roots[name]` to the foreign
126
+ * model name at runtime, look up the foreign collection name from
127
+ * `models[modelName].storage.collection`, and assemble a `LookupResult`
128
+ * for the outer `lookup` to consume.
129
+ *
130
+ * The `Shape`/`Nested` generics are erased at runtime — they exist only
131
+ * to type the local accessor inside the user's `on(...)` callback. The
132
+ * contract value at runtime carries the real model lookup table.
133
+ */
134
+ export function createLookupFrom<
135
+ TContract extends MongoContract,
136
+ Shape extends DocShape,
137
+ Nested extends Record<string, DocField>,
138
+ >(contract: TContract): LookupFrom<TContract, Shape, Nested> {
139
+ const callable = ((rootName) => {
140
+ const modelName = contract.roots[rootName];
141
+ if (!modelName) {
142
+ const validRoots = Object.keys(contract.roots).join(', ');
143
+ throw new Error(`lookup() unknown root: "${rootName}". Valid roots: ${validRoots}`);
144
+ }
145
+ const model = contract.models[modelName];
146
+ const foreignCollection = model?.storage?.collection ?? rootName;
147
+ return createLookupBuilder({
148
+ rootName,
149
+ modelName,
150
+ foreignCollection,
151
+ });
152
+ // The runtime callable accepts a single `string` and returns a
153
+ // generic `LookupBuilder`; the literal `RootName` / `ModelName`
154
+ // generics on `LookupFrom` are erased at runtime and re-asserted
155
+ // here so the surface contract is what the consumer actually sees.
156
+ }) as LookupFrom<TContract, Shape, Nested>;
157
+ return callable;
158
+ }
159
+
160
+ interface LookupBuilderRuntimeState {
161
+ readonly rootName: string;
162
+ readonly modelName: string;
163
+ readonly foreignCollection: string;
164
+ }
165
+
166
+ function createLookupBuilder<
167
+ TContract extends MongoContract,
168
+ Shape extends DocShape,
169
+ Nested extends Record<string, DocField>,
170
+ RootName extends string,
171
+ ModelName extends string,
172
+ >(state: LookupBuilderRuntimeState): LookupBuilder<TContract, Shape, Nested, RootName, ModelName> {
173
+ return {
174
+ on(cb) {
175
+ const localAccessor = createFieldAccessor<Shape, Nested>();
176
+ // Foreign accessor is built unparameterised at runtime — the codec
177
+ // metadata is type-only, and `_path` (the only thing we read off
178
+ // either side) is filled by property access regardless of the
179
+ // generic parameters. The narrow generic on the callback signature
180
+ // is what gives the user the foreign model's keys at compile time.
181
+ const foreignAccessor = createFieldAccessor<DocShape, Record<string, DocField>>();
182
+ const result = cb(localAccessor, foreignAccessor as Parameters<typeof cb>[1]);
183
+ assertLeafExpression(result.local, 'local');
184
+ assertLeafExpression(result.foreign, 'foreign');
185
+ return createLookupBuilderWithKey<RootName, ModelName>({
186
+ ...state,
187
+ localField: result.local._path,
188
+ foreignField: result.foreign._path,
189
+ });
190
+ },
191
+ };
192
+ }
193
+
194
+ interface LookupBuilderWithKeyRuntimeState extends LookupBuilderRuntimeState {
195
+ readonly localField: string;
196
+ readonly foreignField: string;
197
+ }
198
+
199
+ function createLookupBuilderWithKey<RootName extends string, ModelName extends string>(
200
+ state: LookupBuilderWithKeyRuntimeState,
201
+ ): LookupBuilderWithKey<RootName, ModelName> {
202
+ return {
203
+ as<As extends string>(name: As): LookupResult<RootName, ModelName, As> {
204
+ return {
205
+ _brand: 'mongo-query-builder/lookup-result@1',
206
+ // The `RootName` / `ModelName` literal generics are erased at
207
+ // runtime; the runtime state holds the same strings as plain
208
+ // `string`. Re-brand so consumers (the lookup-stage builder)
209
+ // can read the literals back without a downstream cast.
210
+ _root: state.rootName as RootName,
211
+ _model: state.modelName as ModelName,
212
+ _localField: state.localField,
213
+ _foreignField: state.foreignField,
214
+ _as: name,
215
+ };
216
+ },
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Defensive runtime guard catching the case where a user returns a
222
+ * non-`LeafExpression` from the `on(...)` callback (e.g. by casting
223
+ * around the type system, or threading a value in from outside the
224
+ * callback). Compile-time gating via `LookupOnResult`'s `LeafExpression`
225
+ * type already rejects `fn.<op>(…)` returns at the type level — this
226
+ * guard is the runtime backstop matching the defensive style of
227
+ * `deconstructFindAndModifyChain` in builder.ts.
228
+ */
229
+ function assertLeafExpression(
230
+ value: LeafExpression<DocField>,
231
+ side: 'local' | 'foreign',
232
+ ): asserts value is LeafExpression<DocField> {
233
+ if (!value || typeof value._path !== 'string' || value._path.length === 0) {
234
+ throw new Error(
235
+ `lookup().on() ${side} side must return a leaf field reference (e.g. \`${side}.<field>\`). ` +
236
+ 'Aggregation expressions and computed values are not supported.',
237
+ );
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Extract the runtime metadata from a `LookupResult` for `PipelineChain
243
+ * .lookup` to construct the `MongoLookupStage`. The internal fields are
244
+ * intentionally underscore-prefixed and brand-checked here so user code
245
+ * cannot synthesise a fake spec; this is the single ingress point.
246
+ */
247
+ export function extractLookupResult(
248
+ result: LookupResult<string, string, string>,
249
+ contract: MongoContract,
250
+ ): {
251
+ readonly foreignCollection: string;
252
+ readonly localField: string;
253
+ readonly foreignField: string;
254
+ readonly as: string;
255
+ readonly modelName: string;
256
+ } {
257
+ if (!result || result._brand !== 'mongo-query-builder/lookup-result@1') {
258
+ throw new Error(
259
+ 'lookup() callback must return the result of `from(name).on(cb).as(name)`. ' +
260
+ 'Returning a hand-rolled options object is not supported.',
261
+ );
262
+ }
263
+ const model = contract.models[result._model];
264
+ const foreignCollection = model?.storage?.collection ?? result._root;
265
+ return {
266
+ foreignCollection,
267
+ localField: result._localField,
268
+ foreignField: result._foreignField,
269
+ as: result._as,
270
+ modelName: result._model,
271
+ };
272
+ }
@@ -23,6 +23,60 @@ export interface ObjectField<N extends NestedDocShape> extends DocField {
23
23
  readonly fields: N;
24
24
  }
25
25
 
26
+ /**
27
+ * Marker `DocField` variant representing "an array of foreign-model rows"
28
+ * — the result of a `$lookup` whose foreign side was selected via the
29
+ * typed `col` accessor. `ModelName` is the literal foreign model name
30
+ * (e.g. `'User'`), preserved in the type so `ResolveRow` can resolve the
31
+ * field to `ResolveRow<ModelToDocShape<TC, ModelName>, …>[]` rather than
32
+ * the opaque `unknown[]` produced by the legacy `mongo/array@1` sentinel.
33
+ *
34
+ * Like `ObjectField`, the codec id is a purely type-level sentinel —
35
+ * there is no runtime codec entry for `'prisma/modelArray@1'`. The
36
+ * surrounding pipeline emits the standard `MongoLookupStage` whose
37
+ * runtime shape is unchanged; the marker exists solely to thread the
38
+ * foreign element type through the result-row resolver.
39
+ */
40
+ export interface ModelArrayField<ModelName extends string> extends DocField {
41
+ readonly codecId: 'prisma/modelArray@1';
42
+ readonly nullable: false;
43
+ readonly model: ModelName;
44
+ }
45
+
46
+ /**
47
+ * Phantom-symbol brand placed on `ModelToDocShape`'s output (and inherited
48
+ * through shape-extending stages like `addFields`, `match`, `lookup`) so
49
+ * `ResolveRow` can route value-object resolution through `InferModelRow`
50
+ * — which walks the contract's `valueObjects` registry and resolves
51
+ * nested types — instead of falling through the codec-lookup branch to
52
+ * `unknown`.
53
+ *
54
+ * The brand is keyed on a `unique symbol` rather than a string so it is
55
+ * invisible to every `keyof Shape & string` walk in the package
56
+ * (`SortSpec`, `ProjectedShape`, `GroupedDocShape`, the field accessor's
57
+ * `Object.keys` iteration, etc.). Field accessors do not surface a
58
+ * `__modelOrigin` autocomplete entry; sorts cannot key on it; projections
59
+ * cannot reference it.
60
+ *
61
+ * Shape-extending stages preserve the brand because intersection types
62
+ * (`Shape & NewFields`) carry through symbol-keyed properties. Shape-
63
+ * replacing stages (`replaceRoot`, `group`, `project`) construct fresh
64
+ * `DocShape`s from scratch, naturally dropping the brand — which is the
65
+ * intended behaviour, since those stages legitimately leave model
66
+ * territory.
67
+ */
68
+ export declare const ModelOriginBrand: unique symbol;
69
+ export type ModelOriginBrand = typeof ModelOriginBrand;
70
+
71
+ /**
72
+ * Brand carrier — a Shape whose row type should be resolved via
73
+ * `InferModelRow<TContract, ModelName>` rather than the per-field codec
74
+ * walk. Use as `ModelToDocShape<TC, ModelName> & ModelOriginBranded<ModelName>`.
75
+ */
76
+ export type ModelOriginBranded<ModelName extends string> = {
77
+ readonly [ModelOriginBrand]?: ModelName;
78
+ };
79
+
26
80
  /**
27
81
  * Document shape that carries nested value-object sub-shapes.
28
82
  *
@@ -546,7 +546,11 @@ export class FilteredCollection<
546
546
  ) => UpdaterResult,
547
547
  opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {},
548
548
  ): MongoQueryPlan<
549
- ResolveRow<ModelToDocShape<TContract, ModelName>, ExtractMongoCodecTypes<TContract>> | null,
549
+ ResolveRow<
550
+ ModelToDocShape<TContract, ModelName>,
551
+ ExtractMongoCodecTypes<TContract>,
552
+ TContract
553
+ > | null,
550
554
  FindOneAndUpdateCommand
551
555
  > {
552
556
  const update = resolveUpdaterCallback<
@@ -573,7 +577,11 @@ export class FilteredCollection<
573
577
  * document via the row stream.
574
578
  */
575
579
  override findOneAndDelete(): MongoQueryPlan<
576
- ResolveRow<ModelToDocShape<TContract, ModelName>, ExtractMongoCodecTypes<TContract>> | null,
580
+ ResolveRow<
581
+ ModelToDocShape<TContract, ModelName>,
582
+ ExtractMongoCodecTypes<TContract>,
583
+ TContract
584
+ > | null,
577
585
  FindOneAndDeleteCommand
578
586
  > {
579
587
  const command = new FindOneAndDeleteCommand(this.#ctx.collection, this.#foldedFilter());
package/src/types.ts CHANGED
@@ -1,5 +1,12 @@
1
- import type { MongoContract } from '@prisma-next/mongo-contract';
1
+ import type {
2
+ ExtractMongoTypeMaps,
3
+ InferModelRow,
4
+ MongoContract,
5
+ MongoContractWithTypeMaps,
6
+ MongoTypeMaps,
7
+ } from '@prisma-next/mongo-contract';
2
8
  import type { MongoAggAccumulator, MongoAggExpr } from '@prisma-next/mongo-query-ast/execution';
9
+ import type { ModelArrayField, ModelOriginBrand, ModelOriginBranded } from './resolve-path';
3
10
 
4
11
  export interface DocField {
5
12
  readonly codecId: string;
@@ -40,19 +47,103 @@ export type ModelToDocShape<
40
47
  readonly codecId: ExtractCodecId<TContract['models'][ModelName]['fields'][K]>;
41
48
  readonly nullable: TContract['models'][ModelName]['fields'][K]['nullable'];
42
49
  };
43
- };
50
+ } & ModelOriginBranded<ModelName>;
44
51
 
45
- export type ResolveRow<
52
+ /**
53
+ * Per-field resolver. Walks `Shape`'s string keys, routing
54
+ * `ModelArrayField` (the lookup marker) through `InferModelRow` and
55
+ * everything else through the codec-lookup branch.
56
+ *
57
+ * Internal helper — public callers should use `ResolveRow`, which adds
58
+ * the model-origin brand detection on top.
59
+ */
60
+ type ResolveFields<
46
61
  Shape extends DocShape,
47
62
  CodecTypes extends Record<string, { readonly output: unknown }>,
63
+ TContract extends MongoContract,
48
64
  > = {
49
- -readonly [K in keyof Shape & string]: Shape[K]['codecId'] extends keyof CodecTypes
50
- ? Shape[K]['nullable'] extends true
51
- ? CodecTypes[Shape[K]['codecId']]['output'] | null
52
- : CodecTypes[Shape[K]['codecId']]['output']
53
- : unknown;
65
+ -readonly [K in keyof Shape & string]: Shape[K] extends ModelArrayField<infer ModelName>
66
+ ? IsConcreteContract<TContract> extends true
67
+ ? TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>
68
+ ? ModelName extends string & keyof TContract['models']
69
+ ? Array<InferModelRow<TContract, ModelName>>
70
+ : unknown[]
71
+ : unknown[]
72
+ : unknown[]
73
+ : Shape[K]['codecId'] extends keyof CodecTypes
74
+ ? Shape[K]['nullable'] extends true
75
+ ? CodecTypes[Shape[K]['codecId']]['output'] | null
76
+ : CodecTypes[Shape[K]['codecId']]['output']
77
+ : unknown;
54
78
  };
55
79
 
80
+ /**
81
+ * Resolve a `DocShape` to a concrete row object type.
82
+ *
83
+ * The optional `TContract` parameter exists so the resolver can:
84
+ *
85
+ * 1. Detect the `ModelOriginBrand` on `Shape` — the phantom symbol
86
+ * placed by `ModelToDocShape`. When present (and the contract has
87
+ * type maps), the row is resolved via `InferModelRow<TC, M>` from
88
+ * `@prisma-next/mongo-contract`, which walks scalar / valueObject /
89
+ * union field kinds (handling nested value-objects and `many: true`).
90
+ * This makes entry-point reads (`q.from('users').build()`) and
91
+ * shape-extending stages (`match`, `addFields`) resolve value-object
92
+ * fields to their concrete nested types instead of `unknown`.
93
+ *
94
+ * 2. Detect the per-field `ModelArrayField<ModelName>` marker produced
95
+ * by `lookup()` and resolve it to `Array<InferModelRow<TC, M>>` so
96
+ * lookup rows carry the same fully-typed foreign rows.
97
+ *
98
+ * When the contract is not threaded through (or lacks the type-map
99
+ * phantom), both branches fall back to `unknown` / `unknown[]` —
100
+ * preserving the legacy resolver shape for call sites that do not need
101
+ * model-row resolution.
102
+ */
103
+ /**
104
+ * Flatten an intersection `A & B` into a single object literal so callers
105
+ * (and `expectTypeOf().toEqualTypeOf<…>()`) see one homogeneous record
106
+ * rather than the intersection form. Vitest's strict equality check
107
+ * treats `A & B` as distinct from the structurally-equivalent flat
108
+ * record, even when assignability is bidirectional, so the
109
+ * `ResolveRow` brand-positive branch normalises its result through this.
110
+ */
111
+ type Flatten<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
112
+
113
+ /**
114
+ * Decide whether to route a brand-positive `ResolveRow` through
115
+ * `InferModelRow`. The default `MongoContract` (no concrete models)
116
+ * still satisfies `MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>`
117
+ * because the phantom key is optional, but `InferModelRow<MongoContract, …>`
118
+ * collapses to an empty/unknown row. Gate on the presence of the
119
+ * type-maps phantom: a concrete contract attaches concrete `TestTypeMaps`-
120
+ * shaped maps, while the default `MongoContract` has no phantom and
121
+ * `ExtractMongoTypeMaps` resolves to `never`.
122
+ */
123
+ type IsConcreteContract<TContract> = [ExtractMongoTypeMaps<TContract>] extends [never]
124
+ ? false
125
+ : true;
126
+
127
+ export type ResolveRow<
128
+ Shape extends DocShape,
129
+ CodecTypes extends Record<string, { readonly output: unknown }>,
130
+ TContract extends MongoContract = MongoContract,
131
+ > = Shape extends { readonly [ModelOriginBrand]?: infer ModelName extends string }
132
+ ? IsConcreteContract<TContract> extends true
133
+ ? TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>
134
+ ? ModelName extends string & keyof TContract['models']
135
+ ? Flatten<
136
+ InferModelRow<TContract, ModelName> &
137
+ Omit<
138
+ ResolveFields<Shape, CodecTypes, TContract>,
139
+ keyof InferModelRow<TContract, ModelName>
140
+ >
141
+ >
142
+ : ResolveFields<Shape, CodecTypes, TContract>
143
+ : ResolveFields<Shape, CodecTypes, TContract>
144
+ : ResolveFields<Shape, CodecTypes, TContract>
145
+ : ResolveFields<Shape, CodecTypes, TContract>;
146
+
56
147
  export interface TypedAggExpr<F extends DocField> {
57
148
  readonly _field: F;
58
149
  readonly node: MongoAggExpr;
@@ -108,6 +199,16 @@ export type GroupedDocShape<Spec extends GroupSpec> = {
108
199
  */
109
200
  type UnwrapArrayDocField<F extends DocField> = F;
110
201
 
202
+ /**
203
+ * `$unwind` reshapes the array slot but leaves the rest of the document
204
+ * structurally intact. The mapped iteration is keyed on `keyof S & string`,
205
+ * which discards the symbol-keyed `ModelOriginBrand` carried by
206
+ * model-rooted shapes. Preserve the brand explicitly so post-unwind
207
+ * `ResolveRow` still routes through `InferModelRow` and value-object
208
+ * fields keep their concrete nested types.
209
+ */
111
210
  export type UnwoundShape<S extends DocShape, K extends keyof S & string> = {
112
211
  [P in keyof S & string]: P extends K ? UnwrapArrayDocField<S[P]> : S[P];
113
- };
212
+ } & (S extends ModelOriginBranded<infer ModelName extends string>
213
+ ? ModelOriginBranded<ModelName>
214
+ : unknown);