@prisma-next/mongo-query-builder 0.5.0-dev.9 → 0.6.0-dev.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/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@prisma-next/mongo-query-builder",
3
- "version": "0.5.0-dev.9",
3
+ "version": "0.6.0-dev.1",
4
+ "license": "Apache-2.0",
4
5
  "type": "module",
5
6
  "sideEffects": false,
6
7
  "description": "Type-safe MongoDB query builder (reads, writes, find-and-modify, pipeline-terminal writes) with document shape tracking",
7
8
  "dependencies": {
8
- "@prisma-next/contract": "0.5.0-dev.9",
9
- "@prisma-next/mongo-contract": "0.5.0-dev.9",
10
- "@prisma-next/mongo-query-ast": "0.5.0-dev.9",
11
- "@prisma-next/mongo-value": "0.5.0-dev.9"
9
+ "@prisma-next/contract": "0.6.0-dev.1",
10
+ "@prisma-next/mongo-value": "0.6.0-dev.1",
11
+ "@prisma-next/mongo-contract": "0.6.0-dev.1",
12
+ "@prisma-next/utils": "0.6.0-dev.1",
13
+ "@prisma-next/mongo-query-ast": "0.6.0-dev.1"
12
14
  },
13
15
  "devDependencies": {
14
- "tsdown": "0.18.4",
16
+ "tsdown": "0.22.0",
15
17
  "typescript": "5.9.3",
16
- "vitest": "4.0.17",
17
- "@prisma-next/tsdown": "0.0.0",
18
- "@prisma-next/tsconfig": "0.0.0"
18
+ "vitest": "4.1.5",
19
+ "@prisma-next/tsconfig": "0.0.0",
20
+ "@prisma-next/tsdown": "0.0.0"
19
21
  },
20
22
  "files": [
21
23
  "dist",
package/src/builder.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ExtractMongoCodecTypes,
4
4
  MongoContract,
5
5
  MongoContractWithTypeMaps,
6
+ MongoModelDefinition,
6
7
  MongoTypeMaps,
7
8
  } from '@prisma-next/mongo-contract';
8
9
  import type {
@@ -14,6 +15,7 @@ import type {
14
15
  MongoPipelineStage,
15
16
  MongoProjectionValue,
16
17
  MongoQueryPlan,
18
+ MongoResultShape,
17
19
  MongoUpdatePipelineStage,
18
20
  MongoWindowField,
19
21
  UpdateResult,
@@ -54,9 +56,18 @@ import {
54
56
  UpdateManyCommand,
55
57
  UpdateOneCommand,
56
58
  } from '@prisma-next/mongo-query-ast/execution';
59
+ import { ifDefined } from '@prisma-next/utils/defined';
57
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';
58
67
  import type { FindAndModifyEnabled, LeadingMatch, UpdateEnabled } from './markers';
59
- import type { NestedDocShape } from './resolve-path';
68
+ import { pipelineSupportsFlatResultShape } from './pipeline-result-shape';
69
+ import type { ModelArrayField, NestedDocShape } from './resolve-path';
70
+ import { contractModelToMongoResultShape } from './result-shape';
60
71
  import type {
61
72
  DocField,
62
73
  DocShape,
@@ -75,6 +86,7 @@ interface PipelineChainState {
75
86
  readonly collection: string;
76
87
  readonly stages: ReadonlyArray<MongoPipelineStage>;
77
88
  readonly storageHash: string;
89
+ readonly modelName?: string;
78
90
  }
79
91
 
80
92
  /**
@@ -148,7 +160,6 @@ export class PipelineChain<
148
160
  target: 'mongo',
149
161
  storageHash: this.#state.storageHash,
150
162
  lane: 'mongo-query',
151
- paramDescriptors: [],
152
163
  };
153
164
  }
154
165
 
@@ -260,40 +271,39 @@ export class PipelineChain<
260
271
  * the `update` or `findAndModify` wire commands. The original document's
261
272
  * nested-path shape `N` is preserved (the lookup adds a sidecar array
262
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[]`.
263
281
  */
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<
282
+ lookup<RootName extends string, ModelName extends string, As extends string>(
283
+ fn: (from: LookupFrom<TContract, Shape, N>) => LookupResult<RootName, ModelName, As>,
284
+ ): PipelineChain<
270
285
  TContract,
271
- Shape & Record<As, { readonly codecId: 'mongo/array@1'; readonly nullable: false }>,
286
+ Shape & Record<As, ModelArrayField<ModelName>>,
272
287
  'update-cleared',
273
288
  'fam-cleared',
274
289
  'past-leading',
275
290
  N
276
291
  > {
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;
292
+ const fromCallable = createLookupFrom<TContract, Shape, N>(this.#contract);
293
+ const result = fn(fromCallable);
294
+ const extracted = extractLookupResult(result, this.#contract);
285
295
  return this.#withStage<
286
- Shape & Record<As, { readonly codecId: 'mongo/array@1'; readonly nullable: false }>,
296
+ Shape & Record<As, ModelArrayField<ModelName>>,
287
297
  'update-cleared',
288
298
  'fam-cleared',
289
299
  'past-leading',
290
300
  N
291
301
  >(
292
302
  new MongoLookupStage({
293
- from: collectionName,
294
- localField: options.localField,
295
- foreignField: options.foreignField,
296
- as: options.as,
303
+ from: extracted.foreignCollection,
304
+ localField: extracted.localField,
305
+ foreignField: extracted.foreignField,
306
+ as: extracted.as,
297
307
  }),
298
308
  );
299
309
  }
@@ -509,7 +519,6 @@ export class PipelineChain<
509
519
  target: 'mongo',
510
520
  storageHash: this.#state.storageHash,
511
521
  lane: 'mongo-query',
512
- paramDescriptors: [],
513
522
  };
514
523
  return { collection: this.#state.collection, command, meta };
515
524
  }
@@ -748,7 +757,7 @@ export class PipelineChain<
748
757
  updaterFn: (fields: FieldAccessor<Shape, N>) => UpdaterResult,
749
758
  opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {},
750
759
  ): MongoQueryPlan<
751
- ResolveRow<Shape, ExtractMongoCodecTypes<TContract>> | null,
760
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract> | null,
752
761
  FindOneAndUpdateCommand
753
762
  > {
754
763
  const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
@@ -767,7 +776,6 @@ export class PipelineChain<
767
776
  target: 'mongo',
768
777
  storageHash: this.#state.storageHash,
769
778
  lane: 'mongo-query',
770
- paramDescriptors: [],
771
779
  };
772
780
  return { collection: this.#state.collection, command, meta };
773
781
  }
@@ -779,7 +787,7 @@ export class PipelineChain<
779
787
  findOneAndDelete(
780
788
  this: PipelineChain<TContract, Shape, U, 'fam-ok', L, N>,
781
789
  ): MongoQueryPlan<
782
- ResolveRow<Shape, ExtractMongoCodecTypes<TContract>> | null,
790
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract> | null,
783
791
  FindOneAndDeleteCommand
784
792
  > {
785
793
  const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
@@ -788,7 +796,6 @@ export class PipelineChain<
788
796
  target: 'mongo',
789
797
  storageHash: this.#state.storageHash,
790
798
  lane: 'mongo-query',
791
- paramDescriptors: [],
792
799
  };
793
800
  return { collection: this.#state.collection, command, meta };
794
801
  }
@@ -798,22 +805,40 @@ export class PipelineChain<
798
805
  /**
799
806
  * Materialise the chain as a `MongoQueryPlan` wrapping an `AggregateCommand`.
800
807
  */
801
- build(): MongoQueryPlan<ResolveRow<Shape, ExtractMongoCodecTypes<TContract>>, AggregateCommand> {
808
+ build(): MongoQueryPlan<
809
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract>,
810
+ AggregateCommand
811
+ > {
802
812
  const command = new AggregateCommand(this.#state.collection, this.#state.stages);
803
813
  const meta: PlanMeta = {
804
814
  target: 'mongo',
805
815
  storageHash: this.#state.storageHash,
806
816
  lane: 'mongo-query',
807
- paramDescriptors: [],
808
817
  };
809
- return { collection: this.#state.collection, command, meta };
818
+ const modelName = this.#state.modelName;
819
+ const contractNarrow = this.#contract as MongoContract;
820
+ let resultShape: MongoResultShape | undefined;
821
+ if (modelName !== undefined) {
822
+ if (pipelineSupportsFlatResultShape(this.#state.stages)) {
823
+ const model = contractNarrow.models[modelName] as MongoModelDefinition | undefined;
824
+ resultShape = model ? contractModelToMongoResultShape(model) : { kind: 'unknown' as const };
825
+ } else {
826
+ resultShape = { kind: 'unknown' as const };
827
+ }
828
+ }
829
+ return {
830
+ collection: this.#state.collection,
831
+ command,
832
+ meta,
833
+ ...ifDefined('resultShape', resultShape),
834
+ };
810
835
  }
811
836
 
812
837
  /**
813
838
  * Alias for `build()` — surfaces the read intent at the call site.
814
839
  */
815
840
  aggregate(): MongoQueryPlan<
816
- ResolveRow<Shape, ExtractMongoCodecTypes<TContract>>,
841
+ ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract>,
817
842
  AggregateCommand
818
843
  > {
819
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,
@@ -25,6 +34,10 @@ export type {
25
34
  ResolvePath,
26
35
  ValidPaths,
27
36
  } from '../resolve-path';
37
+ export {
38
+ contractFieldToMongoFieldShape,
39
+ contractModelToMongoResultShape,
40
+ } from '../result-shape';
28
41
  export { CollectionHandle, FilteredCollection } from '../state-classes';
29
42
  export type {
30
43
  ArrayField,
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ import type { MongoPipelineStage } from '@prisma-next/mongo-query-ast/execution';
2
+
3
+ const identityStageKinds = new Set(['match', 'sort', 'limit', 'skip', 'sample']);
4
+
5
+ export function pipelineSupportsFlatResultShape(
6
+ stages: ReadonlyArray<MongoPipelineStage>,
7
+ ): boolean {
8
+ for (const stage of stages) {
9
+ const k = stage.kind;
10
+ if (!identityStageKinds.has(k)) {
11
+ return false;
12
+ }
13
+ }
14
+ return true;
15
+ }
package/src/query.ts CHANGED
@@ -47,7 +47,6 @@ export function mongoQuery<
47
47
  target: 'mongo',
48
48
  storageHash: String(storageHash),
49
49
  lane: 'mongo-query',
50
- paramDescriptors: [],
51
50
  };
52
51
  return { collection: command.collection, command, meta };
53
52
  },
@@ -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
  *
@@ -0,0 +1,63 @@
1
+ import type { ContractField } from '@prisma-next/contract/types';
2
+ import type { MongoModelDefinition } from '@prisma-next/mongo-contract';
3
+ import type { MongoFieldShape, MongoResultShape } from '@prisma-next/mongo-query-ast/execution';
4
+ import {
5
+ freezeMongoFieldShape,
6
+ freezeMongoResultShape,
7
+ } from '@prisma-next/mongo-query-ast/execution';
8
+
9
+ export function contractFieldToMongoFieldShape(field: ContractField): MongoFieldShape {
10
+ const { type, nullable, many } = field;
11
+ if (type.kind === 'valueObject' || type.kind === 'union') {
12
+ return Object.freeze({ kind: 'unknown' as const });
13
+ }
14
+ if (type.kind !== 'scalar') {
15
+ return Object.freeze({ kind: 'unknown' as const });
16
+ }
17
+ if (field.dict === true) {
18
+ return Object.freeze({ kind: 'unknown' as const });
19
+ }
20
+ if (many === true) {
21
+ return freezeMongoFieldShape({
22
+ kind: 'array',
23
+ nullable,
24
+ element: { kind: 'leaf', codecId: type.codecId, nullable: false },
25
+ });
26
+ }
27
+ return freezeMongoFieldShape({
28
+ kind: 'leaf',
29
+ codecId: type.codecId,
30
+ nullable,
31
+ });
32
+ }
33
+
34
+ export function contractModelToMongoResultShape(
35
+ model: MongoModelDefinition,
36
+ options?: {
37
+ readonly selection?: readonly string[];
38
+ readonly includeRelationNames?: readonly string[];
39
+ },
40
+ ): MongoResultShape {
41
+ const fields: Record<string, MongoFieldShape> = {};
42
+ for (const rel of options?.includeRelationNames ?? []) {
43
+ fields[rel] = Object.freeze({ kind: 'unknown' as const });
44
+ }
45
+ const modelFields = model.fields;
46
+ // An explicit empty selection is honored as-is (returns a document shape
47
+ // with no fields). Only the absence of a selection falls back to the model's
48
+ // full field set.
49
+ const keys = options?.selection !== undefined ? options.selection : Object.keys(modelFields);
50
+
51
+ for (const key of keys) {
52
+ if (Object.hasOwn(fields, key)) {
53
+ continue;
54
+ }
55
+ const cf = modelFields[key];
56
+ if (!cf) {
57
+ fields[key] = Object.freeze({ kind: 'unknown' as const });
58
+ continue;
59
+ }
60
+ fields[key] = contractFieldToMongoFieldShape(cf);
61
+ }
62
+ return freezeMongoResultShape({ kind: 'document', fields });
63
+ }