Skip to main content

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

OperatorExampleMeaning
$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

OperatorExample
$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

  • apps
  • users
  • categories
  • competitions
  • payments
  • newsletter
  • sidebar_content
  • backlinks
  • analytics
  • external_webhooks
  • partners
  • bookmarks
  • ratings
  • comments
  • promotions

Not in db (access directly via Supabase client): changelog, email_notifications, site_settings, link_type_changes.

When NOT to use db

  • From client componentsdb uses the service role and must not reach the browser. Use a server API route instead.
  • High-contention counters$inc isn't atomic. Use rpc() with a PostgreSQL function.
  • Complex joins — do it with direct Supabase .from().select('...') for full SQL power.

See also