Imported from Imtiajrex/appflare-skills (
skills/appflare-querying/SKILL.md). Install upstream withnpx skills add Imtiajrex/appflare-skills --skill appflare-querying. Copyright stays with the author.
Appflare data access (ctx.db)
Default patterns
// list with filters, relations, sort and limit
const tasks = await ctx.db.tasks.findMany({
where: {
projectId: args.projectId,
...(args.search ? { title: { contains: args.search, options: "i" } } : {}),
done: false,
},
with: { assignee: true, labels: true },
orderBy: { column: "id", direction: "desc" },
limit: args.limit,
});
// single row (or undefined)
const task = await ctx.db.tasks.findFirst({ where: { id: args.id } });
// create
const [created] = await ctx.db.tasks.insert({ values: { title, projectId } });
// update (always scope with where, ideally including ownership)
const [updated] = await ctx.db.tasks.update({ where: { id, assigneeId: ctx.user.id }, set: { done: true } });
// delete
const removed = await ctx.db.tasks.delete({ where: { id } });
// counts
const open = await ctx.db.tasks.count({ where: { projectId, done: false } });
Procedure
- Check the schema for the table name (the key in
schema({...})) and the field names. Relation FKs are<relation>Id. - Put every filter in
where. For optional filters, spread them in conditionally and never passundefinedvalues. - Load related rows with
with. To filter parents by related rows, use the relation name insidewhere. - For lists, add
orderByandlimit. For pages, return{ rows, nextCursor, hasMore }. For full scans, useiterate. - For writes, scope
updateanddeletewithwhere, and check the returned array length to detect "not found". - When several writes must all land or none, wrap them in
ctx.db.batch(writes known up front) orctx.db.transaction(writes that depend on a read).
Counters, money and other concurrent writes
Never read a value, compute in JS, then write it back. Let SQL do it:
import { decrement, increment } from "appflare";
await ctx.db.accounts.update({
where: { id: accountId, balance: { gte: amount } },
set: { balance: decrement(amount) },
expectRows: 1, // no matching row → AppflareConflictError, nothing written
});
await ctx.db.posts.update({ where: { id }, set: { views: increment() } });
Atomic multi-table writes
// every write known up front
const [entries, [debited]] = await ctx.db.batch((tx) => [
tx.ledgerEntries.insert({ values: [{ accountId: from, amount: -cents }, { accountId: to, amount: cents }] }),
tx.accounts.update({ where: { id: from }, set: { balance: decrement(cents) }, expectRows: 1 }),
]);
// a write that depends on a read
await ctx.db.transaction(async (tx) => {
const account = await tx.accounts.findFirst({ where: { id: args.id } });
if (!account) ctx.error(404, "Not found");
const handle = tx.accounts.update({ where: { id: args.id }, set: { status: "closed" } });
await ctx.scheduler.enqueue("jobs/notify", { id: args.id }); // sent only after commit
return handle;
});
Cursor pagination template
args: {
cursor: z.coerce.number().int().optional(),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
},
handler: async (ctx, args) => {
const rows = await ctx.db.posts.findMany({
where: args.cursor ? { id: { lt: args.cursor } } : {},
orderBy: { column: "id", direction: "desc" },
limit: args.pageSize,
});
return { rows, nextCursor: rows.at(-1)?.id, hasMore: rows.length === args.pageSize };
},
Gotchas
findManyreturns 100 rows by default and rejectslimitabove 1000. Passlimit, or useiterateto walk everything.updateanddeletethrow without a filter. PassallowAll: truewhen you really mean every row. Awherewhose values are allundefinedcounts as empty.- Unknown
wherekeys throw. Check spelling and use FK fields (ownerId), not relation names, for id equality. contains/startsWith/endsWithare substring matches with%and_escaped;regexis an alias ofcontains, not a real regex. Addoptions: "i"for case-insensitive.- Use
or/and/notfor boolean logic.or: []matches nothing. - Every write method returns an array. Destructure (
const [row] = …) and check forundefined. expectRowsfailing throwsAppflareConflictErrorand writes nothing, including the rest of a batch.- Inside
ctx.db.transaction, writes return handles, not promises: readhandle.rowsafter the transaction resolves, and do reads before queuing writes because reads don't see them. upsertdefaultstargetto"id"; withoutset, each conflicting row updates with its own values.ctx.$dbis still available for raw Drizzle, but it skips runtime defaults, JSON handling and realtime events. Preferctx.db.v.date()filters acceptDateor epoch milliseconds._count/_avginsidewithadd<relation>Aggregate(e.g.row.commentsAggregate.count) and are computed in JS; prefergroupByfor large relations.
References
- Read references/where-operators.md when building non-trivial filters: all operators, combinators, relation filters, JSON array and object filters, dates, geo distance, ordering, iterate.
- Read references/writes.md when inserting nested relations, using expressions or guards, batching, transacting, updating many-to-many links (
items/mode) or upserting. - Read references/aggregates.md when computing counts, sums, averages, min/max or grouped results.