jas0nw01
jas0nw014d ago

Convex Triggers + Aggregates Pattern Question

We're using Convex triggers with TableAggregate to maintain aggregated data. We're encountering a pattern issue with mixing direct DB operations and trigger-wrapped mutations. Setup:
// itemsTrigger.ts
const triggers = new Triggers<DataModel>()

export const aggregateItemScores = new TableAggregate<...>(
components.aggregateItemScores, {
namespace: (doc) => `${doc.groupId}-${doc.typeId}`,
sumValue: (doc) => doc.amount
}
)

triggers.register("items", aggregateItemScores.trigger())

export const itemsMutation = customMutation(
rawMutation,
customCtx(triggers.wrapDB)
)

export const itemsInternalMutation = customMutation(
rawInternalMutation,
customCtx(triggers.wrapDB)
)
// itemsTrigger.ts
const triggers = new Triggers<DataModel>()

export const aggregateItemScores = new TableAggregate<...>(
components.aggregateItemScores, {
namespace: (doc) => `${doc.groupId}-${doc.typeId}`,
sumValue: (doc) => doc.amount
}
)

triggers.register("items", aggregateItemScores.trigger())

export const itemsMutation = customMutation(
rawMutation,
customCtx(triggers.wrapDB)
)

export const itemsInternalMutation = customMutation(
rawInternalMutation,
customCtx(triggers.wrapDB)
)
Current Problem:
// items.ts
export const updateBatch = itemsMutation({ // <-- Wrapped with trigger
handler: async (ctx, args) => {
// Delete existing items
const existing = await getItems(ctx, args.groupId)
for (const item of existing) {
await ctx.db.delete(item._id) // :white_check_mark: Triggers aggregate update
}

// Add new items
for (const newItem of args.items) {
await ItemsHelper.upsertItem(ctx, { // :x: Does NOT trigger aggregate
groupId: args.groupId,
amount: newItem.amount
})
}
}
})

// Separate wrapped mutation for external calls
export const upsertItem = itemsInternalMutation({
handler: async (ctx, args) => {
return await ItemsHelper.upsertItem(ctx, args) // :white_check_mark: Triggers aggregate
}
})
// items.ts
export const updateBatch = itemsMutation({ // <-- Wrapped with trigger
handler: async (ctx, args) => {
// Delete existing items
const existing = await getItems(ctx, args.groupId)
for (const item of existing) {
await ctx.db.delete(item._id) // :white_check_mark: Triggers aggregate update
}

// Add new items
for (const newItem of args.items) {
await ItemsHelper.upsertItem(ctx, { // :x: Does NOT trigger aggregate
groupId: args.groupId,
amount: newItem.amount
})
}
}
})

// Separate wrapped mutation for external calls
export const upsertItem = itemsInternalMutation({
handler: async (ctx, args) => {
return await ItemsHelper.upsertItem(ctx, args) // :white_check_mark: Triggers aggregate
}
})
2 Replies
Convex Bot
Convex Bot4d ago
Thanks for posting in <#1088161997662724167>. Reminder: If you have a Convex Pro account, use the Convex Dashboard to file support tickets. - Provide context: What are you trying to achieve, what is the end-user interaction, what are you seeing? (full error message, command output, etc.) - Use search.convex.dev to search Docs, Stack, and Discord all at once. - Additionally, you can post your questions in the Convex Community's <#1228095053885476985> channel to receive a response from AI. - Avoid tagging staff unless specifically instructed. Thank you!
jas0nw01
jas0nw01OP4d ago
The Issue: Within updateBatch: - ctx.db.delete() triggers the aggregate ✅ - Helper function ItemsHelper.upsertItem() does NOT trigger ❌ - Can't use ctx.runMutation(internal.items.upsertItem) because we're already doing direct DB operations Proposed Solution:
// items.ts
export const updateBatch = mutation({ // <-- Regular mutation, NOT wrapped
handler: async (ctx, args) => {
// Delete via wrapped mutation
const existing = await getItems(ctx, args.groupId)
for (const item of existing) {
await ctx.runMutation(internal.items.deleteItem, {
itemId: item._id
})
}

// Add via wrapped mutation
for (const newItem of args.items) {
await ctx.runMutation(internal.items.upsertItem, {
groupId: args.groupId,
amount: newItem.amount
})
}
}
})

// Wrapped mutations for trigger updates
export const deleteItem = itemsInternalMutation({
args: { itemId: v.id("items") },
handler: async (ctx, args) => ctx.db.delete(args.itemId)
})

export const upsertItem = itemsInternalMutation({
handler: async (ctx, args) => ItemsHelper.upsertItem(ctx, args)
})
// items.ts
export const updateBatch = mutation({ // <-- Regular mutation, NOT wrapped
handler: async (ctx, args) => {
// Delete via wrapped mutation
const existing = await getItems(ctx, args.groupId)
for (const item of existing) {
await ctx.runMutation(internal.items.deleteItem, {
itemId: item._id
})
}

// Add via wrapped mutation
for (const newItem of args.items) {
await ctx.runMutation(internal.items.upsertItem, {
groupId: args.groupId,
amount: newItem.amount
})
}
}
})

// Wrapped mutations for trigger updates
export const deleteItem = itemsInternalMutation({
args: { itemId: v.id("items") },
handler: async (ctx, args) => ctx.db.delete(args.itemId)
})

export const upsertItem = itemsInternalMutation({
handler: async (ctx, args) => ItemsHelper.upsertItem(ctx, args)
})
Is this the recommended pattern? Regular mutations orchestrating wrapped mutations via ctx.runMutation to ensure consistent trigger behavior? side note: "await ctx.runMutation" is more expensive so actually status seems better?

Did you find this page helpful?