Adapters
Where Sendo stores its data: Supabase, Drizzle or Prisma over your own Postgres, and what changes for access control and media.
Every read and write in Sendo goes through a SendoAdapter. Pick the one that matches your stack; all of them pass the same contract suite (runAdapterContract from @quami/sendo/adapters/contract), so the pipeline, the inbox, rules, broadcasts and metrics behave the same on each.
| Adapter | Database | Media by default | Access control |
|---|---|---|---|
supabaseAdapter (@quami/sendo/adapters/supabase) | Supabase Postgres, through PostgREST with the service role | supabaseStorage (bucket) | Package role checks, plus RLS for browser reads |
drizzleAdapter (@quami/sendo/adapters/drizzle) | Any Postgres 15+ through drizzle-orm (node-postgres/postgres-js) | none: pass a MediaStore | Package role checks only |
prismaAdapter (@quami/sendo/adapters/prisma) | Any Postgres 15+ through @prisma/client | none: pass a MediaStore | Package role checks only |
memoryAdapter (@quami/sendo/adapters/memory) | Process memory (tests, demos) | in-memory map | Package role checks only |
Supabase#
The default. Install the schema item (the SQL migrations) and pass a service role client: the sendo_* tables have RLS on and the helper functions are granted only to service_role.
import { supabaseAdapter } from "@quami/sendo/adapters/supabase";
import { createClient } from "@supabase/supabase-js";
// The SERVICE ROLE client (bypasses RLS). Server only.
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
{ auth: { persistSession: false, autoRefreshToken: false } },
);
export const adapter = supabaseAdapter(supabaseAdmin, {
storageBucket: "sendo-media", // private bucket
signedUrlTtlSec: 300,
});- Status ordering, locks, unread counts and the default-number swap run as Postgres functions from the migration (
sendo_update_message_status,sendo_mark_sent,sendo_acquire_lock,sendo_set_default_account, …). - Metrics read the
sendo_metrics_*views. - Media goes to the private
sendo-mediaStorage bucket (supabaseStorage);createSendo({ media })replaces it, e.g. withs3Storage. - The inbox gets live updates from Supabase Realtime, filtered by the RLS hooks you replace (
sendo_can_access_tenant, …). See Security.
Drizzle#
drizzleAdapter({ db, media? }) runs Sendo on your own Postgres (Neon, RDS, Cloud SQL, self-hosted) through Drizzle. db is a drizzle-orm/node-postgres or drizzle-orm/postgres-js database; drizzle-orm is an optional peer, so install it (and pg or postgres) in your app.
Set up the database#
- Install the items:
npx shadcn add @sendo/schema @sendo/drizzle-schema. - On plain Postgres (not Supabase), run
lib/db/sendo-postgres-prelude.sqlonce. The migrations mention three Supabase roles (in read-only RLS policies) andauth.uid(); the prelude creates stand-ins that grant nothing: NOLOGIN rolesanon,authenticated,service_roleand anauth.uid()that returns null. It needsCREATEROLE; on Supabase it changes nothing. - Apply
20261010000000_sendo_v1.sql,20261011000000_sendo_v2.sqland20261013000000_sendo_v3.sql, in that order, with your migration tool (for drizzle-kit, paste each into adrizzle-kit generate --custommigration). Postgres 15 or later (nulls not distinct). - Connect as the role that owns the tables (it needs to read and write every
sendo_*table).
The SQL migrations are the source of truth. lib/db/sendo-schema.ts mirrors them as pgTable definitions (a test checks table and column names, types and nullability against the SQL) so you can join Sendo's tables in your own typed queries. Keep it out of the schema list in drizzle.config.ts so drizzle-kit never generates or pushes migrations for sendo_* tables.
Create the adapter#
import { drizzleAdapter } from "@quami/sendo/adapters/drizzle";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
// Sendo's storage on your own Postgres through Drizzle. Use `adapter` in lib/sendo.ts:
// createSendo({ adapter, media, ... })
// `sendo doctor --config lib/sendo-adapter.ts` and `sendo mcp --config ...` read `adapter`
// (and `media`) from this file.
//
// Already have a Drizzle `db`? Import it instead of creating a second pool:
// export const adapter = drizzleAdapter({ db });
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
/** The Drizzle database Sendo uses (node-postgres). */
export const db = drizzle({ client: pool });
/** Sendo's `SendoAdapter` over the `sendo_*` tables of `DATABASE_URL`. */
export const adapter = drizzleAdapter({ db });
// Media (images, audio, documents) needs a MediaStore; there is no Supabase Storage here.
// For S3, R2 or any S3-compatible store (npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner):
// import { S3Client } from "@aws-sdk/client-s3";
// import { s3Storage } from "@quami/sendo/storage/s3";
// export const media = s3Storage({ client: new S3Client({}), bucket: process.env.SENDO_MEDIA_BUCKET! });Then createSendo({ adapter, media, ... }) in lib/sendo.ts. Already have a Drizzle db? Pass it instead of opening a second pool. sendo doctor --config lib/sendo-adapter.ts reads the same exports.
How it works#
- Plain SQL over the
sendo_*tables (no stored functions, so noEXECUTEgrants are needed). Rows come back as JSON, sonode-postgresandpostgres-jsgive identical results. - The Supabase functions' work is done as single statements or transactions with the same advisory locks (
sendo_meta:<meta id>,sendo_default_account:<tenant>): forward-only status updates and status placeholders (updateStatus), the placeholder merge inmarkSent, locks (insert … on conflict … where expires_at <= clock_timestamp()), unread increments, the default-number swap, and metadata and rule-state merges (jsonb ||). A Supabase app and a Drizzle worker can share one database. sendo.metrics.*runsMETRICS_SQL(the same queries as thesendo_metrics_*views) with bound parameters.- Failures the adapter doesn't map to a Sendo error throw
DrizzleAdapterError(operation, Postgrescode,constraint,detail).
Prisma#
prismaAdapter({ prisma, media? }) runs Sendo on your own Postgres through the PrismaClient your app already has (Prisma 5 or later; with Prisma 7, a client built with a driver adapter such as @prisma/adapter-pg). @prisma/client is an optional peer, so it comes from your app.
Set up the database#
- Install the items:
npx shadcn add @sendo/schema @sendo/prisma-schema. You get the SQL migrations insupabase/migrations/, plusprisma/sendo.prismaandprisma/sendo-postgres-prelude.sql. - On plain Postgres (not Supabase), run
prisma/sendo-postgres-prelude.sqlonce (the same prelude as the Drizzle item: NOLOGIN stand-ins foranon,authenticated,service_roleand anauth.uid()that returns null). - Apply the three
schemafiles in order, each as its own Prisma migration:npx prisma migrate dev --create-only --name sendo_v1, paste20261010000000_sendo_v1.sqlinto the newmigration.sql, then the same for v2 and v3, thennpx prisma migrate dev. Postgres 15 or later. - Add the models: use Prisma's multi-file schema (
schema: "prisma"inprisma.config.ts) soprisma/sendo.prismais picked up, or paste its blocks into yourschema.prisma. Thennpx prisma generate.
The SQL migrations are the source of truth. sendo.prisma mirrors them (models mapped with @@map / @map to the snake*case names, enums, relations, unique constraints and plain indexes); tests check it against the SQL (tables, columns, types, nullability) and check that prisma migrate diff from a migrated database to these models is empty, so Prisma Migrate never generates changes for sendo*\* tables. Prisma can't express the CHECK constraints, the partial and expression indexes, RLS, the functions or the metrics views; they stay in the SQL and Migrate leaves them alone.
Create the adapter#
import { prismaAdapter } from "@quami/sendo/adapters/prisma";
import { s3Storage } from "@quami/sendo/storage/s3";
import { S3Client } from "@aws-sdk/client-s3";
import { prisma } from "@/lib/prisma"; // your PrismaClient
/** Sendo's `SendoAdapter` over the `sendo_*` tables, through your Prisma client. */
export const adapter = prismaAdapter({ prisma });
/** Media (images, audio, documents): there is no Supabase Storage here. */
export const media = s3Storage({ client: new S3Client({}), bucket: process.env.SENDO_MEDIA_BUCKET! });Then createSendo({ adapter, media, ... }) in lib/sendo.ts. sendo doctor --config lib/sendo-adapter.ts reads the same exports. Options:
schema: the Postgres schema that holds thesendo_*tables (defaultpublic), e.g. when you keep them in their own schema.transaction:{ maxWait, timeout }in ms for the interactive transactions (default 10000 / 15000).
How it works#
- Only
$queryRawUnsafe,$executeRawUnsafeand$transactionare used, always with bound parameters ($1,$2, …; values are never spliced into the SQL). The adapter doesn't depend on your generated models, so renaming them or adding relations to your own models is safe. - Rows are read as JSON (
to_jsonb), so Prisma 5, 6 and 7, with or without a driver adapter, return the same values. - The Supabase functions' work runs as single statements or interactive transactions with the same advisory locks as the SQL functions (
sendo_meta:<meta id>,sendo_default_account:<tenant>): forward-only status updates and placeholders, the placeholder merge inmarkSent, locks, unread increments, the default-number swap, rule reordering, purge, andjsonb ||merges. A Supabase app and a Prisma worker can share one database. sendo.metrics.*runsMETRICS_SQLwith bound parameters.- Failures the adapter doesn't map to a Sendo error throw
PrismaAdapterError(operation, Postgrescode).
Shared: schema, media and access control#
Schema versions#
Every adapter reads the same sendo_* tables, created by the schema migrations. sendo_schema_meta records the installed version; the package warns at startup and sendo doctor fails schema-version when it is behind, naming the files to apply. New Sendo versions only add migrations: apply them with whatever tool you use.
Media#
Media bytes never go through the adapter's tables, only through a MediaStore: createSendo({ media }) wins, else the adapter's default (adapter.storage). The Supabase adapter defaults to its Storage bucket; Drizzle and Prisma have no default, so pass media (for example s3Storage from @quami/sendo/storage/s3, which works with S3, R2 and MinIO) or inbound images, audio and documents are stored as failed. See API reference.
Access control#
- Every inbox, metrics, broadcast, rule, snippet and tag call takes an
actor(sendo.actor(userId, tenantId)) and checks its role and capabilities, on every adapter. That is the security boundary on Drizzle and Prisma. - Never query
sendo_*tables on behalf of a user without your own tenant and role check, and never expose them to browsers. - Without Supabase Realtime the inbox polls instead (list every 10 s, open thread every 3 s, paused while the tab is hidden). See Inbox.
Your own adapter#
Implement SendoAdapter and run the contract suite in your tests; it is the definition of done.
// my-adapter.contract.test.ts (run with Vitest)
import { runAdapterContract } from "@quami/sendo/adapters/contract";
import type { SendoAdapter } from "@quami/sendo";
declare function createMyAdapter(): Promise<SendoAdapter>;
declare function truncateTables(): Promise<void>;
declare const databaseReachable: boolean;
runAdapterContract("my-adapter", () => createMyAdapter(), {
skip: databaseReachable ? false : "database unreachable",
afterEach: truncateTables,
});