Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6474238
docs(retail-store): add framework limitations doc and PR #327 review …
wmadden Apr 13, 2026
cf3db5a
Remove code review artifacts from branch, keep locally
wmadden Apr 13, 2026
7b48e49
Reframe FL-06 as codec-generic, not ObjectId-specific
wmadden Apr 13, 2026
76ca857
Add spec for ORM query and mutation ergonomics (TML-2246)
wmadden Apr 13, 2026
701f3c8
Add execution plan for ORM query and mutation ergonomics
wmadden Apr 13, 2026
8d054b3
feat(mongo-orm): add 1:N reference relation test coverage (FL-08)
wmadden Apr 13, 2026
a42a352
feat(mongo-orm): add codec-aware object-based where() overload (FL-06)
wmadden Apr 13, 2026
ebb37d9
feat(mongo-orm): add field accessor types and fixture for FL-04
wmadden Apr 13, 2026
3f28f69
feat(mongo-orm): add callback-based update overloads (FL-04)
wmadden Apr 13, 2026
a3212c1
feat(mongo-orm): export field accessor types and createFieldAccessor
wmadden Apr 13, 2026
5c75872
refactor(retail-store): replace objectIdEq and db.raw with ORM ergono…
wmadden Apr 13, 2026
c270191
fix: update InferModelRow type test for new User fields after rebase
wmadden Apr 14, 2026
b1d9a21
fix(retail-store): use ReadonlyArray for statusHistory cast after cod…
wmadden Apr 14, 2026
6c30e80
fix(mongo-orm): skip codec on $unset, fix compileFieldOperations retu…
wmadden Apr 14, 2026
4b3f2a5
test(mongo-orm): add e2e integration tests for FL-04, FL-06, FL-08
wmadden Apr 14, 2026
cb0b871
brand MongoFilterExpr with symbol to prevent misrouting plain objects…
wmadden Apr 14, 2026
14bb643
constrain inc()/mul() to numeric types in FieldExpression
wmadden Apr 14, 2026
7385b54
reject dot-path field operations in upsert() callbacks
wmadden Apr 14, 2026
e1f26b3
fix biome noNonNullAssertion warnings in ORM collection and field-acc…
wmadden Apr 14, 2026
5c72d3b
reject _id mutations in callback-based updates
wmadden Apr 14, 2026
7f09682
fix: add explicit type annotation for upsert callback parameter
wmadden Apr 15, 2026
00fdc93
normalize empty callback updates and wrap value-object payloads in fi…
wmadden Apr 15, 2026
37ce2e8
replace mongoFilterBrand symbol with non-enumerable string property
wmadden Apr 15, 2026
8d66cca
Use named schema input type
wmadden Apr 15, 2026
638ea91
fix(mongo-orm): resolve value-object field types in FieldAccessor via…
wmadden Apr 15, 2026
74d13e9
refactor(retail-store): replace hand-written types with emitted contr…
wmadden Apr 15, 2026
1f1e66f
refactor(mongo-query-ast): co-locate filter brand predicate with bran…
wmadden Apr 15, 2026
daca03c
refactor(mongo-query-ast): move isMongoFilterExpr to internal entry p…
wmadden Apr 15, 2026
69c9100
fix(mongo-query-ast): export isMongoFilterExpr from execution barrel
wmadden Apr 15, 2026
8190ed2
refactor(mongo-orm): inline isMongoFilterExpr, remove redundant wrapper
wmadden Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/planning/mongo-target/next-steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Full details in [projects/mongo-example-apps/framework-limitations.md](../../../
| FL-03 | Timestamp codec type incompatible with `Date`/`string` | Type ergonomics |
| FL-04 | No typed `$push`/`$pull`/`$inc` | ORM mutations |
| FL-05 | Pipeline/raw results untyped | Query results |
| FL-06 | ObjectId filter requires manual `MongoParamRef` wrapping | ORM queries |
| FL-06 | `where()` does not encode filter values through codecs | ORM queries |
| FL-07 | No `$vectorSearch` in pipeline builder | Extension (deferred) |
| FL-08 | 1:N back-relation loading not available/tested | ORM queries |
| FL-09 | Migration planner creates separate collections for variants | Migration bugs |
Expand Down Expand Up @@ -110,10 +110,10 @@ Full details in [projects/mongo-example-apps/framework-limitations.md](../../../
**Scope**:

- **FL-04**: Implement dot-path field accessor mutations — `$push`, `$pull`, `$inc`, `$set` on nested paths via `u("field.path")` (deferred Phase 1.5 M4). Maps to [ADR 180](../../architecture%20docs/adrs/ADR%20180%20-%20Dot-path%20field%20accessor.md).
- **FL-06**: ORM `where()` should auto-encode ObjectId-typed fields. When a contract field has `codecId: 'mongo/objectId@1'`, the ORM should wrap the value in `MongoParamRef` automatically instead of requiring the user to construct it manually.
- **FL-06**: ORM `where()` should encode filter values through codecs, the same way mutations already do. When a contract field has a `codecId`, the ORM should wrap the value in `MongoParamRef` with that codec automatically. Most visible with ObjectId (string → BSON ObjectId), but applies to any codec with a non-identity `encode`.
- **FL-08**: Validate and test 1:N back-relation loading via `include()`. If it works, add test coverage. If it doesn't, implement it.

**Proof**: The retail store's `mongoRaw` calls for cart add/remove and order status update are replaced with ORM `update()` calls. ObjectId filter helpers (`objectIdEq()`) are removed.
**Proof**: The retail store's `mongoRaw` calls for cart add/remove and order status update are replaced with ORM `update()` calls. Manual filter helpers (`objectIdEq()`, `rawObjectIdFilter()`) are removed — `where({ userId })` encodes values through codecs automatically.

**Depends on**: Area 1 (type fixes reduce noise, but not a hard blocker).

Expand Down
58 changes: 13 additions & 45 deletions examples/retail-store/src/data/carts.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,32 @@
import type { CartItemInput } from '../contract';
import type { Db } from '../db';
import { executeRaw } from './execute-raw';
import { objectIdEq, rawObjectIdFilter } from './object-id-filter';

export function getCartByUserId(db: Db, userId: string) {
return db.orm.carts.where(objectIdEq('userId', userId)).first();
return db.orm.carts.where({ userId }).first();
}

export function getCartWithUser(db: Db, userId: string) {
return db.orm.carts.include('user').where(objectIdEq('userId', userId)).first();
return db.orm.carts.include('user').where({ userId }).first();
}

export function upsertCart(
db: Db,
userId: string,
items: ReadonlyArray<{
productId: string;
name: string;
brand: string;
amount: number;
price: { amount: number; currency: string };
image: { url: string };
}>,
) {
return db.orm.carts.where(objectIdEq('userId', userId)).upsert({
export function upsertCart(db: Db, userId: string, items: ReadonlyArray<CartItemInput>) {
return db.orm.carts.where({ userId }).upsert({
create: { userId, items: [...items] },
update: { items: [...items] },
});
}

export function clearCart(db: Db, userId: string) {
return db.orm.carts.where(objectIdEq('userId', userId)).update({ items: [] });
return db.orm.carts.where({ userId }).update({ items: [] });
}

export async function addToCart(
db: Db,
userId: string,
item: {
productId: string;
name: string;
brand: string;
amount: number;
price: { amount: number; currency: string };
image: { url: string };
},
) {
const plan = db.raw
.collection('carts')
.findOneAndUpdate(
rawObjectIdFilter('userId', userId),
{ $push: { items: item }, $setOnInsert: rawObjectIdFilter('userId', userId) },
{ upsert: true },
)
.build();
await executeRaw(db, plan);
export function addToCart(db: Db, userId: string, item: CartItemInput) {
return db.orm.carts.where({ userId }).upsert({
create: { userId, items: [item] },
update: (u) => [u.items.push(item)],
});
}

export async function removeFromCart(db: Db, userId: string, productId: string) {
const plan = db.raw
.collection('carts')
.updateOne(rawObjectIdFilter('userId', userId), { $pull: { items: { productId } } })
.build();
await executeRaw(db, plan);
export function removeFromCart(db: Db, userId: string, productId: string) {
return db.orm.carts.where({ userId }).update((u) => [u.items.pull({ productId })]);
}
36 changes: 7 additions & 29 deletions examples/retail-store/src/data/events.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { acc } from '@prisma-next/mongo-pipeline-builder';
import { MongoFieldFilter } from '@prisma-next/mongo-query-ast/execution';
import type { FieldInputTypes } from '../contract';
import type { Db } from '../db';
import { collectResults } from './execute-raw';

type EventBase = Omit<FieldInputTypes['Event'], '_id' | 'type'>;

export function createViewProductEvent(
db: Db,
event: {
userId: string;
sessionId: string;
timestamp: Date;
productId: string;
subCategory: string;
brand: string;
exitMethod?: string | null;
},
event: EventBase & FieldInputTypes['ViewProductEvent'],
) {
return db.orm.events.variant('ViewProductEvent').create({
userId: event.userId,
Expand All @@ -22,19 +17,11 @@ export function createViewProductEvent(
productId: event.productId,
subCategory: event.subCategory,
brand: event.brand,
exitMethod: event.exitMethod ?? null,
exitMethod: event.exitMethod,
});
}

export function createSearchEvent(
db: Db,
event: {
userId: string;
sessionId: string;
timestamp: Date;
query: string;
},
) {
export function createSearchEvent(db: Db, event: EventBase & FieldInputTypes['SearchEvent']) {
return db.orm.events.variant('SearchEvent').create({
userId: event.userId,
sessionId: event.sessionId,
Expand All @@ -43,16 +30,7 @@ export function createSearchEvent(
});
}

export function createAddToCartEvent(
db: Db,
event: {
userId: string;
sessionId: string;
timestamp: Date;
productId: string;
brand: string;
},
) {
export function createAddToCartEvent(db: Db, event: EventBase & FieldInputTypes['AddToCartEvent']) {
return db.orm.events.variant('AddToCartEvent').create({
userId: event.userId,
sessionId: event.sessionId,
Expand Down
13 changes: 4 additions & 9 deletions examples/retail-store/src/data/invoices.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import type { InvoiceLineItemInput } from '../contract';
import type { Db } from '../db';
import { objectIdEq } from './object-id-filter';

export function findInvoiceById(db: Db, id: string) {
return db.orm.invoices.where(objectIdEq('_id', id)).first();
return db.orm.invoices.where({ _id: id }).first();
}

export function findInvoiceWithOrder(db: Db, id: string) {
return db.orm.invoices.include('order').where(objectIdEq('_id', id)).first();
return db.orm.invoices.include('order').where({ _id: id }).first();
}

export function createInvoice(
db: Db,
invoice: {
orderId: string;
items: ReadonlyArray<{
name: string;
amount: number;
unitPrice: number;
lineTotal: number;
}>;
items: ReadonlyArray<InvoiceLineItemInput>;
subtotal: number;
tax: number;
total: number;
Expand Down
17 changes: 0 additions & 17 deletions examples/retail-store/src/data/object-id-filter.ts

This file was deleted.

34 changes: 9 additions & 25 deletions examples/retail-store/src/data/orders.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,26 @@
import type { OrderLineItemInput, StatusEntryInput } from '../contract';
import type { Db } from '../db';
import { collectFirstResult } from './execute-raw';
import { objectIdEq, rawObjectIdFilter } from './object-id-filter';

export function getUserOrders(db: Db, userId: string) {
return db.orm.orders.where(objectIdEq('userId', userId)).all();
return db.orm.orders.where({ userId }).all();
}

export function getOrderById(db: Db, id: string) {
return db.orm.orders.where(objectIdEq('_id', id)).first();
return db.orm.orders.where({ _id: id }).first();
}

export function getOrderWithUser(db: Db, id: string) {
return db.orm.orders.include('user').where(objectIdEq('_id', id)).first();
return db.orm.orders.include('user').where({ _id: id }).first();
}

export function createOrder(
db: Db,
order: {
userId: string;
items: ReadonlyArray<{
productId: string;
name: string;
brand: string;
amount: number;
price: { amount: number; currency: string };
image: { url: string };
}>;
items: ReadonlyArray<OrderLineItemInput>;
shippingAddress: string;
type: string;
statusHistory: ReadonlyArray<{ status: string; timestamp: Date }>;
statusHistory: ReadonlyArray<StatusEntryInput>;
},
) {
return db.orm.orders.create({
Expand All @@ -41,17 +33,9 @@ export function createOrder(
}

export function deleteOrder(db: Db, id: string) {
return db.orm.orders.where(objectIdEq('_id', id)).delete();
return db.orm.orders.where({ _id: id }).delete();
}

export async function updateOrderStatus(
db: Db,
orderId: string,
entry: { status: string; timestamp: Date },
) {
const plan = db.raw
.collection('orders')
.findOneAndUpdate(rawObjectIdFilter('_id', orderId), { $push: { statusHistory: entry } })
.build();
return collectFirstResult<Record<string, unknown>>(db, plan);
export function updateOrderStatus(db: Db, orderId: string, entry: StatusEntryInput) {
return db.orm.orders.where({ _id: orderId }).update((u) => [u.statusHistory.push(entry)]);
}
3 changes: 1 addition & 2 deletions examples/retail-store/src/data/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { MongoParamRef } from '@prisma-next/mongo-value';
import type { FieldOutputTypes } from '../contract';
import type { Db } from '../db';
import { collectResults } from './execute-raw';
import { objectIdEq } from './object-id-filter';

type Product = FieldOutputTypes['Product'];

Expand All @@ -21,7 +20,7 @@ export async function findProductsPaginated(
}

export function findProductById(db: Db, id: string) {
return db.orm.products.where(objectIdEq('_id', id)).first();
return db.orm.products.where({ _id: id }).first();
}

function escapeRegex(str: string) {
Expand Down
6 changes: 3 additions & 3 deletions examples/retail-store/src/data/users.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { FieldInputTypes } from '../contract';
import type { Db } from '../db';
import { objectIdEq } from './object-id-filter';

export function findUsers(db: Db) {
return db.orm.users.all();
}

export function findUserById(db: Db, id: string) {
return db.orm.users.where(objectIdEq('_id', id)).first();
return db.orm.users.where({ _id: id }).first();
}

export function createUser(db: Db, data: { name: string; email: string; address: null }) {
export function createUser(db: Db, data: Omit<FieldInputTypes['User'], '_id'>) {
return db.orm.users.create(data);
}
1 change: 1 addition & 0 deletions examples/retail-store/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ export async function seed(db: Db) {
productId: p0._id,
subCategory: 'Topwear',
brand: 'Heritage',
exitMethod: null,
});

await createAddToCartEvent(db, {
Expand Down
1 change: 1 addition & 0 deletions examples/retail-store/test/aggregation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('aggregation pipelines', { timeout: timeouts.spinUpMongoMemoryServer },
productId: `prod-${i}`,
subCategory: 'Topwear',
brand: 'TestBrand',
exitMethod: null,
});
}
for (let i = 0; i < 2; i++) {
Expand Down
1 change: 1 addition & 0 deletions examples/retail-store/test/crud-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ describe('CRUD lifecycle', { timeout: timeouts.spinUpMongoMemoryServer }, () =>
productId: 'prod-1',
subCategory: 'Topwear',
brand: 'TestBrand',
exitMethod: null,
});

const events = await findEventsByUser(ctx.db, 'user-1');
Expand Down
2 changes: 1 addition & 1 deletion examples/retail-store/test/order-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe('order lifecycle (integration)', { timeout: timeouts.spinUpMongoMemoryS
expect(delivered).not.toBeNull();
expect(delivered!['statusHistory']).toHaveLength(3);

const statuses = (delivered!['statusHistory'] as Array<{ status: string }>).map(
const statuses = (delivered!['statusHistory'] as ReadonlyArray<{ status: string }>).map(
(s) => s.status,
);
expect(statuses).toEqual(['placed', 'shipped', 'delivered']);
Expand Down
Loading
Loading