Database Layer API
The MongoDB-style wrapper around Supabase.
All data access in DirectoryKit goes through db — a singleton exported from lib/supabase/database.ts. It wraps Supabase with a MongoDB-inspired API, so queries look familiar if you've used Mongo before.
import { db } from '@/lib/supabase/database';Under the hood it uses the service role client — RLS is bypassed. Only call it from server code (API routes, server components, scripts). Never import into client components.
Methods
db.find(table, query, options) — multiple rows
const apps = await db.find('apps', { status: 'live' }, {
sort: { upvotes: -1, created_at: -1 },
skip: 0,
limit: 20,
projection: { slug: 1, name: 1, upvotes: 1 },
});db.findOne(table, query, options) — single row
const app = await db.findOne('apps', { slug: 'my-app' });
if (!app) return notFound();db.insertOne(table, doc) — insert one
const { insertedId } = await db.insertOne('apps', {
name: 'My App',
slug: 'my-app',
status: 'pending',
user_id: user.id,
});db.insertMany(table, docs)
const { insertedIds, insertedCount } = await db.insertMany('categories', seedCategories);db.updateOne(table, filter, update) — update one
Use $set and/or $inc:
await db.updateOne('apps', { id }, {
$set: { status: 'live', approved_at: new Date().toISOString() },
});
await db.updateOne('apps', { id }, {
$inc: { views: 1 },
});db.updateMany(table, filter, update)
await db.updateMany('apps',
{ status: 'pending', created_at: { $lt: cutoff } },
{ $set: { status: 'expired' } }
);db.deleteOne(table, filter) / db.deleteMany(table, filter)
await db.deleteOne('comments', { id: commentId });
await db.deleteMany('analytics', { created_at: { $lt: aMonthAgo } });db.count(table, query) — fast row count
const total = await db.count('apps', { status: 'live' });db.rpc(fn, params) — call a PostgreSQL function
Use this when you need transactional or concurrency-safe operations:
await db.rpc('increment_upvotes', { app_id: id });db.incrementField(table, id, field, delta) — quick increment
Read-modify-write. Not atomic — for hot counters, use rpc with a DB function.
await db.incrementField('apps', id, 'upvotes', 1);Values are clamped to >= 0.
Query operators
| Operator | Example | Meaning |
|---|---|---|
$and | { $and: [{ a: 1 }, { b: 2 }] } | All must match |
$or | { $or: [{ a: 1 }, { b: 2 }] } | Any can match |
$in | { status: { $in: ['live', 'featured'] } } | Value is one of |
$ne | { status: { $ne: 'rejected' } } | Not equal |
$gt, $gte | { upvotes: { $gt: 10 } } | Greater than / ≥ |
$lt, $lte | { upvotes: { $lt: 100 } } | Less than / ≤ |
$regex | { name: { $regex: 'search' } } | Substring / regex |
$exists | { email: { $exists: true } } | Field is present |
$not | { status: { $not: { $eq: 'rejected' } } } | Negation |
$overlaps | { categories: { $overlaps: ['ai', 'dev'] } } | Array intersection |
$contains | { tags: { $contains: ['featured'] } } | Array contains all |
JSONB gotcha
Columns like categories and tags are JSONB arrays. On these columns, $in internally becomes .overlaps() — it matches if any value in the array overlaps, not strict equality. For scalar columns $in means strict "value is one of."
Update operators
| Operator | Example |
|---|---|
$set | { $set: { status: 'live' } } |
$inc | { $inc: { upvotes: 1, views: 1 } } |
Common recipes
"Get top 10 live apps"
const top = await db.find('apps',
{ status: 'live' },
{ sort: { upvotes: -1 }, limit: 10 }
);"Get this user's projects"
const mine = await db.find('apps',
{ user_id: user.id },
{ sort: { created_at: -1 } }
);"Projects in a category"
const inCategory = await db.find('apps',
{ status: 'live', categories: { $overlaps: [categorySlug] } },
{ sort: { upvotes: -1 }, limit: 40 }
);"Paginate"
const page = 3;
const pageSize = 40;
const items = await db.find('apps',
{ status: 'live' },
{ sort: { created_at: -1 }, skip: (page - 1) * pageSize, limit: pageSize }
);
const total = await db.count('apps', { status: 'live' });Tables available via db
appsuserscategoriescompetitionspaymentsnewslettersidebar_contentbacklinksanalyticsexternal_webhookspartnersbookmarksratingscommentspromotions
Not in db (access directly via Supabase client): changelog, email_notifications, site_settings, link_type_changes.
When NOT to use db
- From client components —
dbuses the service role and must not reach the browser. Use a server API route instead. - High-contention counters —
$incisn't atomic. Userpc()with a PostgreSQL function. - Complex joins — do it with direct Supabase
.from().select('...')for full SQL power.
See also
- Schema Overview — what tables exist
- API Reference Overview — how API routes use
db