Skip to content

@mantlejs/supabase

Supabase adapter for Mantle JS. Provides a supabase() plugin, an abstract SupabaseRepository<T> base class backed by Supabase PostgREST, a supabaseAdapter() for cross-instance event sync via Supabase Realtime Broadcast, and optional Postgres Changes subscriptions that translate direct DB mutations into Mantle service events.


Terminal window
npm install @mantlejs/supabase @supabase/supabase-js

supabase(config) is a Mantle plugin. It creates a SupabaseClient and stores it on the application at app.get("supabase"). Call it once during app configuration before registering any repositories.

SupabaseRepository is an abstract base class that implements all eight Repository<T> methods against a Supabase-hosted PostgreSQL table via the Supabase PostgREST API. Extend it, set tableName, and optionally override primaryKey and timestamps.

supabaseAdapter() — Realtime Broadcast sync transport

Section titled “supabaseAdapter() — Realtime Broadcast sync transport”

supabaseAdapter() returns a SyncAdapter compatible with @mantlejs/sync. It replaces Redis with Supabase Realtime Broadcast channels so teams already using Supabase get horizontal scaling with zero additional infrastructure.

listenToChanges — Postgres Changes subscription

Section titled “listenToChanges — Postgres Changes subscription”

When a SupabaseRepository<T> subclass sets readonly listenToChanges = true, it opens a Supabase Realtime Postgres Changes subscription. Direct database mutations from PostgREST, Supabase Studio, migrations, or any other source are automatically translated to Mantle service:event emissions and fan out through the app event bus. This works alongside @mantlejs/sync — both can be active simultaneously.

All standard Mantle QueryParams operators are translated to PostgREST filter calls:

Operator PostgREST equivalent
{ field: value } .eq(field, value)
{ field: null } .is(field, null)
$lt, $lte, $gt, $gte .lt(), .lte(), .gt(), .gte()
$ne .neq() / .not(field, "is", null) for null
$in .in(field, [])
$nin .not(field, "in", "(…)")
$like, $ilike .like(), .ilike()
$notlike .not(field, "like", pattern)
$contains .contains(field, value) — PostgREST cs (@> containment)
$or .or(filter)
$and chained calls

Dot-path field names address JSON columns and are translated to PostgREST arrow syntax: "metadata.owner.name" becomes metadata->owner->>name when compared against a string (text comparison) and metadata->owner->name otherwise (jsonb comparison).

$contains uses jsonb @> semantics: an array operand requires the field array to contain every element, a scalar operand is treated as a single element, and an object operand requires the field object to be a recursive superset. Scalar operands are wrapped in an array before being passed to .contains(). $contains is not supported inside $or — move it to the top level of the where clause (top-level conditions are ANDed).

await repo.findAll({
where: {
"metadata.owner.name": "alice", // metadata->owner->>name = 'alice'
"metadata.level": { $gt: 4 }, // metadata->level > 4 (jsonb)
tags: { $contains: ["red", "blue"] }, // tags @> '["red","blue"]'
metadata: { $contains: { owner: { name: "alice" } } },
},
});

import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { supabase, SupabaseRepository } from "@mantlejs/supabase";
// 1. Configure the plugin
const app = mantle()
.configure(express())
.configure(
supabase({
url: process.env.SUPABASE_URL!,
key: process.env.SUPABASE_KEY!,
}),
);
// 2. Define your entity
interface User {
id: string;
name: string;
email: string;
created_at?: string;
updated_at?: string;
}
// 3. Create a repository
class UserRepository extends SupabaseRepository<User> {
readonly tableName = "users";
}
// 4. Wire it up
app.use("/users", new UserService(new UserRepository(app)));
app.listen(3030);

Access the raw PostgREST query builder via this.db inside your repository:

class UserRepository extends SupabaseRepository<User> {
readonly tableName = "users";
async findByEmail(email: string): Promise<User | null> {
const { data, error } = await this.db.select("*").eq("email", email).maybeSingle();
if (error) throw this.wrapError(error);
return data ?? null;
}
}

Returns a MantlePlugin. Call via app.configure(supabase(config)).

app.configure(
supabase({
url: process.env.SUPABASE_URL!, // required — Supabase project URL
key: process.env.SUPABASE_KEY!, // required — anon or service_role key
options: {
// optional — SupabaseClientOptions
auth: { autoRefreshToken: false },
},
}),
);

Side effects:

  • Stores the SupabaseClient at app.get("supabase")
Field Type Default Description
url string — Supabase project URL (required)
key string — Supabase API key — anon for client-side, service_role for server-side (required)
options SupabaseClientOptions — Additional Supabase client options (optional)

Abstract base class. Extend with a concrete tableName.

abstract class SupabaseRepository<T, D = Partial<T>> implements Repository<T, D> {
abstract readonly tableName: string;
readonly primaryKey: string; // default: "id"
readonly timestamps: boolean; // default: true
}
Property Type Default Description
tableName string — Supabase table name (abstract — must be set)
primaryKey string "id" Primary key column name
timestamps boolean true Auto-write created_at / updated_at ISO-8601 strings
Method Description
findAll(params?) Fetch all rows matching QueryParams (where, sort, limit, skip, select)
findById(id) Fetch a single row by primary key; returns null if not found
save(data) Insert a new row and return it
saveAll(data[]) Insert multiple rows and return them
updateById(id, data) Replace all non-key columns; throws NotFound if missing
patchById(id, data) Merge supplied fields only; throws NotFound if missing
deleteById(id) Delete a row and return it; throws NotFound if missing
count(params?) Count rows matching optional where clause

Returns a PostgREST PostgrestQueryBuilder scoped to tableName. Use it in subclass custom queries.

Set readonly listenToChanges = true in a subclass to subscribe to Postgres Changes for that table. Requires Realtime to be enabled for the table in your Supabase project settings.

class PostRepository extends SupabaseRepository<Post> {
readonly tableName = "posts";
readonly listenToChanges = true; // subscribe to WAL-based DB events
}

Changes are emitted as service:event on the app event bus:

Postgres event Mantle event
INSERT created
UPDATE patched
DELETE removed

These re-emissions did not pass through the hook pipeline — no before/after hooks ran, no validation, no params.user. So their params argument is { external: true } (typed on ServiceParams), letting event consumers (socket.io broadcasts, @mantlejs/sync, custom listeners) distinguish them from hook-pipeline events and, for example, skip authorization-derived filtering that assumes hook context.

Why UPDATE maps to patched — never updated
Section titled “Why UPDATE maps to patched — never updated”

In Mantle, updated means “a caller replaced the full record via update()” while patched means “some fields changed”. A WAL-level UPDATE only tells us the row’s new state — the intent (full replace vs. partial mutation) is unknowable from outside the service layer. patched is the honest, conservative claim: consumers reacting to patched must already tolerate partial change, whereas mislabeling a partial write as updated would let them assume every field was intentionally set.

Maps Supabase / PostgreSQL error codes to typed MantleError subclasses. Call it in custom query methods to keep error handling consistent.


Returns a SyncAdapter for use with @mantlejs/sync. Credentials fall back to SUPABASE_URL and SUPABASE_SERVICE_KEY (or SUPABASE_KEY) environment variables.

import { sync } from "@mantlejs/sync";
import { supabase, supabaseAdapter } from "@mantlejs/supabase";
const app = mantle()
.configure(express())
.configure(supabase({ url: process.env.SUPABASE_URL!, key: process.env.SUPABASE_SERVICE_KEY! }))
.configure(socketio())
.configure(sync({ adapter: supabaseAdapter() }));
Field Type Default Description
url string SUPABASE_URL env var Supabase project URL
key string SUPABASE_SERVICE_KEY env var Service role key

import type { SupabaseConfig, SupabaseAdapterOptions, SyncAdapter, SyncMessage } from "@mantlejs/supabase";
import { SupabaseRepository, supabaseAdapter } from "@mantlejs/supabase";
Export Kind Description
SupabaseConfig interface Options passed to supabase()
SupabaseRepository abstract class Base repository class to extend
SupabaseAdapterOptions interface Options passed to supabaseAdapter()
SyncAdapter interface Pluggable transport interface for sync
SyncMessage interface Cross-instance sync message shape
supabaseAdapter function Factory that returns a Supabase SyncAdapter

All errors are converted to typed MantleError subclasses:

PostgreSQL / PostgREST code Error thrown Condition
PGRST116 NotFound (404) Zero rows returned by a .single() call
23505 Conflict (409) Unique constraint violation
23503, 23514, 23502 BadRequest (400) FK, check, or NOT NULL violation
42501, 28000, 28P01 Forbidden (403) Insufficient privilege
anything else GeneralError (500) Unexpected error

Terminal window
npx nx build supabase # compile
npx nx test supabase # run tests
npx nx lint supabase # lint

Build before publishing:

Terminal window
npx nx build supabase

First publish (scoped packages require --access public):

Terminal window
cd packages/supabase
npm publish --access public

Subsequent releases — bump version in packages/supabase/package.json, then:

Terminal window
cd packages/supabase
npm publish
Terminal window
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/supabase
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/supabase --registry http://localhost:4873