How the supabase-* packages use better-supabase repositories instead of hand-written supabase-js queries.
The supabase-* provider packages read and write through better-supabase repositories. packages/supabase-runtime owns the definition and the generated schema; every other package imports only type Db from @workspace/supabase-runtime/db. plans/050-adopt-better-supabase-and-permdock.md holds the adoption plan and the implementation log.
createSupabaseCustomersService(db: Db, ...). Core wiring passes context.db.Db (or a function that returns one) that core wiring builds with connectDb(context.supabaseAdmin); it does not import the shared admin client.connectDb(client, claims), which passes them to better-supabase as connect(client, { claims }), and services read them from db.$context.claims with getCurrentUserIdFromClaims(db), getCurrentUserEmailFromClaims(db) or getCurrentAuthClaims(db) from @workspace/supabase-runtime/auth/claims. An admin connection carries no claims. Unit tests pass the claims to connectDb instead of stubbing auth.db.$client is the supabase-js client of the same request. Use it only for Auth and for the few table calls the repository API cannot express yet (see below), never for table reads and writes or for Storage.db.$storage is Storage as the same caller. Pass it to a bucket's connect, to connectStored, to the URL resolvers and to the blocks. In a support session it is better-supabase's supportStorage, which reads as the target through Postgres. In a writable session it writes after a rehearsal as the target, makes the target the owner and stamps supportSessionId and supportAdminId in the object's metadata, which needs adminSql and session; signed uploads work too, and the upload is finished with the bucket's claimReserved after the browser stored it (the Files finalize step does this; outside a session it is a no-op). A read-only session refuses writes with SUPPORT_READ_ONLY as forbidden; connectDbOverPostgres takes the Storage for its connection.@workspace/supabase-runtime/env validates its keys with t3-env and passes them, over process.env, to better-supabase's loadEnv. getSupabaseServerEnv() is toServerEnv of that (JWKS from SUPABASE_JWKS, SUPABASE_JWKS_URL or the Auth well-known URL), and getPostgresConnectionString() is its dbUrl: DATABASE_URL, then POSTGRES_URL, then POSTGRES_URL_NON_POOLING. Keys must be sb_publishable_ and sb_secret_ keys, so test stubs use sb_publishable_test and sb_secret_test.better-supabase outside packages/supabase-runtime. DbError comes from @workspace/supabase-runtime/db-result.organizationId). RPC arguments, PostgREST select strings on db.$client and SQL keep database names (p_organization_id).findMany, findFirst, findUnique, findById, count, exists. Writes: create, createMany, update, updateMany, upsert, delete, deleteMany. Functions: db.$rpc("name", { p_arg }).include when the result is the same: a primary contact with its methods is one request with include: { contactProfile: { include: { contactMethods: {...} } } } instead of three.Promise.all or db.$many([...specs]).src/generated/schema.meta.js. A table with two foreign keys to the same target gets names from every key column, such as customerLocationsByCustomer and customerLocationsByCustomerOrganization.select lists explicit (as const arrays) so the row type stays narrow.findMany without orderBy orders by the primary key. Pass the old order explicitly when it mattered.{ in: [] } filter skips the request and returns no rows, so an empty id list needs no early return.updateMany or deleteMany with where: { organizationId, id }, or update(id, patch, { where: { organizationId } }). A row that does not match the where returns not_found in one request, so the service maps it with its usual not-found error. Use where for every condition the row must meet (a tenant, deletedAt: null, a status the transition starts from); keep expect only where a mismatch must be told apart from a missing row, which then returns stale.include that replaces a follow-up query also fails the whole read when the embedded part fails, where the old code often treated a failed follow-up as empty. That is accepted....(condition ? { column: value } : undefined); exactOptionalPropertyTypes rejects { column: undefined } and the anti-slop lint rejects : {}. A filter built up statement by statement is typed WhereOf<"timeEntries"> from @workspace/supabase-runtime/db, which re-exports the generated WhereOf; an orderBy built in a helper is typed with the generated OrderByOf<"tasks"> (one sort or a list), and one sort term with OrderTermOf<"tasks">.paginate({ offset: range.from, limit: range.range.limit, count }) call; count is the list's "exact" or "estimated" mode. Build the page with createSupabaseOffsetListPage(items, range.range, page, countMode) from @workspace/supabase-runtime/list, which returns the same pagination fields as createSupabaseListPageForCountMode did for the old two-step reads. An offset past the last row returns no rows and the total instead of an error.paginate with an orderBy adds no tie-breaker, so keep the old { id: "asc" } term where the list had one.status in a list and status not done) go in AND: [...]; a list of OR groups works the same way. Date-bucket filters use dateBucketConditions(buckets) from @workspace/supabase-runtime/list: OR: dateBucketConditions(buckets).map((dueAt) => ({ dueAt })).in and notIn values that need no quotes (uuids, numbers, slugs, emails) are sent bare, and a read without limit, offset, count or a single row whose URL gets too long is split along its longest in list and merged by the library. Pass the whole id list; do not chunk by hand. A paginated read cannot be split, so keep its id lists bounded.db.$rpc returns rows of a table (returns setof x, returns x) and returns table (...) records in camelCase, typed as the model row or a record with app names. Do not map their keys.null, so pass a nullable value directly.insert ... returning, a coalesce, a raise on the missing case, a not-null column), list the function under functions.<name>.notNull in better-supabase.config.ts, as true or as the returns table columns that are never null, and run pnpm supabase:types. Otherwise handle null where the type checker reports it.before insert trigger fills (variant_id from the product's default variant) are optional on insert; leave them out instead of reading the value first.jsonb, a scalar that can be null, or a returns table column that can be null takes a returns schema: db.$rpc("name", args, { returns: schema }) validates the decoded value and types the result from the schema. Use the contract schema when one exists (inventoryLevelSignalSchema.array()), otherwise a schema next to the service (portalQuotePayloadSchema). A value that fails the schema returns a validation error. Never cast an RPC result.P0002 (no_data_found) arrives as not_found; do not special-case the code.maybeSingle() of supabase-js) uses findOnly({ select, where }), which returns a multiple_rows error for several rows. findFirst takes the first row and is only for lookups by a unique key or reads that want the first row by an order.RowOf, InsertOf and UpdateOf in @workspace/supabase-runtime/db, keyed by the repository's camelCase table key (RowOf<"shipmentLines">). Function argument and result types come from Functions in the same module (Functions["list_inbox_threads"]["Returns"][number]).updateMany or deleteMany with returning: true and a select.where: { metadata: { path: ["supersededByRunId"], isNull: true } } or { input: { path: [field], eq: value } }. A jsonb containment takes the JSON value as is: parts: { contains: [{ type: "data" }] }. Text columns take match and imatch for POSIX regular expressions.orderBy sorts by a column of a to-one relation: orderBy: [{ organization: { name: "asc" } }, { id: "asc" }].column is not null types the column as not null and makes it required on insert, even when a before insert trigger fills it. The product variant columns (variantId on stock records, movements, customer assets and supplier links) are filled from the product's default variant in the service before the insert.packages/supabase-runtime/src/storage.ts with better-supabase's defineBucket. Bucket ids come from storageBuckets in packages/foundation/src/storage-buckets.ts. A Storage bucket that holds objects in several layouts has one definition with a path array: buckets.organizationImages holds logos and brand images, buckets.productImages holds product and brand images, and buckets.files holds upload reservations, copies, ZIP exports and {orgId}/{...rest} for any other stored path under an organization.buckets.customerLogos.connect(db.$storage).upload({ orgId, customerId, version }, bytes, options). The values pick the template whose placeholders they fill. Store uploaded.data.path in the *_path column. remove, download, copy, signedUrl(path, { ttl, download }), publicUrl, reserve and uploadReserved take the stored path, which must match one of the templates. A bucket definition's path(values) returns a Result with an invalid_input error for values Storage would refuse; map the error, or pass the values to upload and store the returned path instead of building it first.DbError results: map them with the service's error mapper or keep the old domain error with error.message. publicUrl returns a Result too; read .data (null for a path outside the templates) in resolvers such as resolveAvatarPublicUrl and resolveOrganizationImageUrl.storedObjectBuckets registry (better-supabase defineBuckets): storedObjectBuckets.connectStored(db.$storage, { bucket, path }) returns the bucket client and the checked path, or an invalid_input error for an unregistered bucket id or a path outside its templates. Register a bucket there when rows start to reference it.@workspace/supabase-runtime use connectStorage(client) from @workspace/core/storage, which returns the bucket clients they need (chatAttachments, auditLogExports), storedObject({ bucket, path }) and organizationImageUrl(path).client.storage.from(...) directly.better_supabase, which the Data API never exposes. Each has api: "api" in better-supabase.config.ts, so sql sync writes security invoker entry points into the api schema, which is in [api] schemas and in the supabase:diff schema list.Db with connectComments, connectAttachments, connectDataLifecycle or connectApiKeys from @workspace/supabase-runtime/<block>; they pass rpcTransport(db.$client, { schema: "api" }). Service-role work (the attachment scanner, the data exporter, the organization purger) runs as withBlock entries in packages/core/src/api-provider-context.ts over ctx.postgresAdmin, from the outbox_relay and blocks_purge background jobs.public.organizations and public.organization_users, also with api: "api". connectOrganizations(db, postgres) from @workspace/supabase-runtime/organizations runs sqlTransport(postgres) when the request has a caller Postgres client (API keys and the API's Postgres modes) and rpcTransport otherwise; core-tenant passes context.postgres. Pass role ids; keys resolve within the organization (sql.modules.tenant.options.roleThrough.tenant). The invitations module is adopted over public.organization_invitations for organization and platform invitations (token_hash, revoked_at); invite, edit (updateInvitation), resend, revoke, preview, accept, the inbox list (myInvitations) and the inbox's accept and decline by id go through the same Organizations client. The public.invitation_preview_extra hook adds the role's key and name and the brand images to the preview and the inbox; clients cannot write organization_invitations.api: "api". connectNotifications(db, { types, actionable }) from @workspace/supabase-runtime/notifications sends through notify() and reads the inbox, counts, subscriptions and preferences; clients cannot write the tables. A send names its people in recipientUserIds and adds subject watchers only with watchers (participating or all); members who ignore the subject never get it. public.notification_audience adds the run creator and the watchers of the workflow to a workflow run notification. The module writes the recipient broadcast trigger and the realtime.messages receive policy for org:{tenantId}:notifications:{userId}.public.organization_number_sequences. Its bs_assign_number_<entity> triggers number tasks, quotes, orders, purchase orders and shipments on insert, so services and SQL functions insert those rows without number (gen makes the column optional in InsertOf), and a client that sends a number gets NUMBER_MANUAL. Invoices are numbered when send_invoice sets the status to sent: the invoice entity takes each organization's invoice_number_format (formatFrom) and its reset period from the generated invoice_number_reset column (periodFrom: year when the format has {yyyy} or {yy}, otherwise none, so a {mm} token never restarts the counter monthly), reads the year and month from issued_on, writes formatted_number and the bare number, and number_year is generated from issued_on. A format change that changes the period runs sync_number_sequences(organization, 'invoice') in its transaction through the generated bs_sync_number_period_invoice trigger on organizations, which sql sync writes because the entity sets periodFrom, formatFrom and periodColumn; do not hand-write that sync. The assign trigger numbers an invoice that reaches sent, paid, overdue or uncollectible without a number. better_supabase.preview_number(organization, 'invoice', at) shows the next invoice number to holders of invoice.send (entities.invoice.permission); the other entities fall back to permissions.assign, organization.update. Seeds and data migrations that write numbered rows directly end with better_supabase.sync_number_sequences().auth.* (ADR 0012). Give the module an api option with a functions list of service-role entry points and call it with rpcTransport(serviceClient, { schema: "api" }) (the audit recorder, billing, readLastSignIns, rpcQueueMonitor for the admin dashboard's job health). Change Auth users through auth.admin and end sessions through public.end_user_sessions. ctx.postgresAdmin is for the SQL-only libraries in the API and workflow runtimes: job claiming, the webhook inbox, idempotency, the Stripe Sync Engine and agent checkpoints.organizations; the organization purge deletes their rows (CentraKit has no on_organization_purge hook, so a purge of the adopted tenant rows completes only once better-supabase purges adopted tenants itself), and 012_auth_users.sql deletes the seeded organizations' block rows before a rerun.better-supabase sql data and permdock:generate both stamp migrations with the current second. Rename the later file when they collide, or supabase db reset fails on a duplicate version.toXError(error: DbError): XError built on mapDbError from @workspace/supabase-runtime/db-result, with domain special cases first (for example foreign_key to CustomerInUseError). DomainErrorFactories and MapDbErrorOptions come from the same module.toDomainResult(db.x.findMany(...), toXError) to get a better-result Result. For an awaited write whose data the caller drops, use toVoidResult(await db.x.deleteMany(...), toXError).db.$client, write toXError(toDbError(error)) with toDbError from @workspace/supabase-runtime/db-result (better-supabase's own, re-exported), so one mapper covers both paths. It classifies a PostgREST error by its code and keeps code, hint, details and the constraint name, so special cases on codes such as P0001, P0002 or 22023, on hints and on constraint names fire on both paths. Do not write a second mapper that takes a PostgrestError or a DbError | PostgrestError union.findById returns a not_found error for a missing row; findFirst and findUnique return null.25006, as in a read-only support session) arrives as forbidden with code 25006, so the service's forbidden factory maps it and the app shows its translated forbidden message. Map block and Storage results through the same toXError instead of wrapping them in an unexpected error.insert, update or select ... for update when nothing changes, or it fails in a read-only session. Ensure-style functions such as ensure_user_file_drives check with if not exists before they write.*_path, nested addresses, parsed jsonb). Delete identity mappers.pnpm supabase:types.Table reads and writes go through repositories. PermDock's approval store is the generated one: rls.approvals adopts approval_requests (permdock_token, permdock_request, open: 'attach', functions in public), and packages/supabase-identity/src/approvals/store.ts wraps supabaseApprovalStore so that create opens the inbox row and claims the token before PermDock attaches the request; a request asked again after it expired gets a new inbox row. It passes testApprovalStore. The open gaps are listed under "Upstream findings" in the plan.
toDomainResult and toVoidResult are built on better-supabase's typed toBetterResult; keep using them instead of calling toBetterResult in a service.
The CLI finds supabase/config.toml and the root better-supabase.config.ts in parent directories, so pnpm supabase:types and the Database workflow run it from packages/supabase-runtime without --cwd. Doctor's --format github and SARIF output use repository-relative paths.
paginate with an offset reads one look-ahead row, so a page of 50 asks for limit=51.createFakeSupabaseClient from @workspace/test-fixtures/fake-postgrest and pass connectDb(client) to the service. Reply with camelCase rows; assert on the recorded request (path, params with database names such as id=eq.42, body for writes and RPCs).in values that need no quotes are bare in the request (status=in.(sent,overdue), id=in.(<uuid>,<uuid>)); values inside an or stay quoted (title.ilike."%x%", number.eq."42"). An OR with one condition is sent as a plain filter (valid_until=is.null).contact_profiles!customer_contacts_contact_profile_id_fkey!inner(...)), so assert on parts of params.get("select") with toContain.createRequestContext({ bearerToken }), built in beforeAll because the integration setup re-stubs the env before each test.Last updated on