@mantlejs/supabase
v0.1.0
Published
Supabase adapter for Mantle JS — SupabaseRepository with full QueryParams support
Maintainers
Readme
@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.
Installation
npm install @mantlejs/supabase @supabase/supabase-jsConcepts
The supabase() plugin
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<T>
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
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
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.
QueryParams support
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 |
Nested paths and $contains
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" } } },
},
});Quick start
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);Custom queries
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;
}
}API
supabase(config)
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
SupabaseClientatapp.get("supabase")
SupabaseConfig
| 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) |
SupabaseRepository<T, D>
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
}Instance properties
| 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 |
Repository<T> methods
| 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 |
protected db
Returns a PostgREST PostgrestQueryBuilder scoped to tableName. Use it in subclass custom queries.
listenToChanges
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 |
External events carry { external: true }
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
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.
protected wrapError(err)
Maps Supabase / PostgreSQL error codes to typed MantleError subclasses. Call it in custom query methods to keep error handling consistent.
supabaseAdapter(options?)
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() }));SupabaseAdapterOptions
| Field | Type | Default | Description |
| ----- | -------- | -------------------------------- | ---------------------------- |
| url | string | SUPABASE_URL env var | Supabase project URL |
| key | string | SUPABASE_SERVICE_KEY env var | Service role key |
Types
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 |
Error mapping
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 |
Development
npx nx build supabase # compile
npx nx test supabase # run tests
npx nx lint supabase # lintPublishing
Build before publishing:
npx nx build supabaseFirst publish (scoped packages require --access public):
cd packages/supabase
npm publish --access publicSubsequent releases — bump version in packages/supabase/package.json, then:
cd packages/supabase
npm publishTesting locally with Verdaccio
# 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