Using MaluDB
A MaluDB project is its own PostgreSQL database with a Supabase-shaped API in front of it.
If you have used Supabase, the official @supabase/supabase-js client works
against your project with only its URL and key changed.
Getting started
- Sign in and create a project from the dashboard. It shows Setting up for about a minute, then Ready.
- On the project, open API keys and create a publishable key.
- Create a table in the project's SQL editor — see Creating tables.
- Connect with the Supabase client, using the project URL shown on the project's card:
npm install @supabase/supabase-jsimport { createClient } from '@supabase/supabase-js' const supabase = createClient('<project URL>', '<publishable key>') const { data, error } = await supabase.from('todos').select('*') - For server code that must bypass row-level security, create a secret key and keep it on the server.
API keys
Every request to your project carries a key. There are two kinds, and which one you use decides what the request can do.
| Publishable key | Secret key | |
|---|---|---|
| Looks like | mldb_publishable_… | mldb_secret_… |
| Use it in | Browsers, mobile apps, anything a user can inspect | Your servers, jobs and scripts only |
| Row-level security | Applies. A request sees what your policies allow the anon role — or the signed-in user — to see. | Bypassed. Acts as service_role: every row, every table. |
| Shown | Any time, in the dashboard | Once, when you create it. It cannot be retrieved later. |
How it is sent. The Supabase client sends the key for you. Called directly, put it in the
apikey header:
curl '<project URL>/rest/v1/todos?select=*' \
-H 'apikey: <publishable key>'
Never put a secret key in client code. Anyone who can read your bundle could read and change every row. If one leaks, or you lose it: create a new secret key, deploy it, then revoke the old one in the dashboard. Revoking takes effect at once.
Only organization owners and admins can create or revoke keys; other members can see them.
Creating tables
On a ready project, open SQL editor. Statements run in your project's own database on every plan, including Developer, which has no direct database connection. Insert an example table gives you a table, row-level security and a policy to start from; Ctrl+Enter runs it.
create table public.todos (
id bigint generated by default as identity primary key,
title text not null,
done boolean not null default false
);
alter table public.todos enable row level security;
create policy "anyone can read todos" on public.todos
for select to anon, authenticated using (true);
Run as shows what your app would see: anon runs a statement as the publishable key,
authenticated as a signed-in user (with the JWT claims you give it), service_role as the
secret key. The default, your project's admin role, owns your tables, so policies do not apply to it.
Tables lists what is in the database — columns, policies, indexes and constraints — and warns
about any table in public without row-level security. It never runs a query itself:
Query this table puts one in the editor for you to run.
From a script
The editor uses the platform API — this site's /api — which you can call directly:
- Get a personal access token from Access tokens in the dashboard. It acts as
you, is shown once, and can be given an expiry — keep it private, and revoke it there if it leaks. From a
terminal instead:
curl -X POST https://<this site>/api/v1/auth/signin \ -H 'Content-Type: application/json' \ -d '{"email": "you@example.com", "password": "…"}' # use the "token" from the answer: curl -X POST https://<this site>/api/v1/auth/tokens \ -H 'Authorization: Bearer <session token>' \ -H 'Content-Type: application/json' -d '{"name": "my laptop"}' - Run SQL against your project:
curl -X POST https://<this site>/api/v1/projects/<project-ref>/sql \ -H 'Authorization: Bearer <personal access token>' \ -H 'Content-Type: application/json' \ -d '{"statement": "create table public.todos (id bigint generated by default as identity primary key, title text not null, done boolean not null default false); alter table public.todos enable row level security; create policy \"anyone can read\" on public.todos for select to anon, authenticated using (true);"}'
Turn on row-level security and add a policy for every table your publishable key should reach. Without a policy, the publishable key sees nothing — which is the safe default, and the most common reason a first query returns an empty list.
Use generated by default as identity rather than generated always for keys you want to
upsert. Add "role": "anon" to an API request to run it as your publishable key would.
Statements are rate-limited by plan — on Developer, one at a time; a 429 means wait a moment.
Reading and writing data
Through the Supabase client, as you would against Supabase:
await supabase.from('todos').insert({ title: 'Write docs' })
await supabase.from('todos').update({ done: true }).eq('id', 1)
await supabase.from('todos').upsert({ id: 1, title: 'Write docs', done: true })
await supabase.from('todos').delete().eq('id', 1)
const { data, count } = await supabase
.from('todos')
.select('*', { count: 'exact' })
.neq('done', true)
.order('id', { ascending: false })
.range(0, 9)
await supabase.rpc('my_function', { arg: 1 })
Tested filters are eq, neq, like and in, with ordering, ranges,
limits and counts. Functions you create in public are callable with rpc; functions installed
by extensions are usable from SQL but not as rpc.
On the server, create the client with the secret key instead of the publishable one to bypass row-level security.
Auth
Email and password sign-in, with the client's usual calls. A signed-in user's requests use their own row-level
security policies (the authenticated role).
await supabase.auth.signUp({ email, password })
await supabase.auth.signInWithPassword({ email, password })
const { data: { user } } = await supabase.auth.getUser()
await supabase.auth.signOut()
Sessions refresh automatically.
Storage
Buckets, uploads, downloads, listing, deletion, and signed and public URLs:
// on the server, with the secret key: creating buckets is an admin action
await admin.storage.createBucket('avatars')
// anywhere, as your policies on storage.objects allow
await supabase.storage.from('avatars').upload('me.png', file)
const { data } = await supabase.storage.from('avatars').download('me.png')
const { data: signed } = await supabase.storage.from('avatars').createSignedUrl('me.png', 60)
Access to objects follows row-level security policies on storage.objects. Your plan sets upload size,
total storage and monthly egress.
Realtime
Subscribe to row changes (Postgres Changes) on the Builder and Professional plans:
supabase.channel('todos')
.on('postgres_changes', { event: '*', schema: 'public', table: 'todos' }, (change) => console.log(change))
.subscribe()
Memory spaces
Long-term memory for your agents: send what they learned as text, and search it later by meaning, narrowed to a subject or a verb. A project can hold several named spaces; nothing is shared between them. Server-side only — every memory call takes the secret key.
Set it up in the dashboard
- On a ready project, open Memory and create a space (lower-case letters, digits,
underscores, e.g.
support_bot). - Set provider keys — your own accounts do the model work, and bill you for it: an Anthropic or OpenAI key to find the statements in a text, and an OpenAI or Voyage key to embed them. (Anthropic has no embeddings; Anthropic recommends Voyage.) Keys are stored encrypted and never shown again.
- Under the space's Models, choose the providers. The defaults are sensible; pick another model from the list, or Other model… for one that is not listed.
The embedding model is fixed once a space holds memories — search compares only vectors from one model. To change it, create a new space.
Store memories
curl -X POST <project URL>/memory/v1/spaces/support_bot/ingest \
-H 'apikey: <secret key>' -H 'Content-Type: application/json' \
-d '{"items": [{"title": "parser ownership",
"text": "Carol owns the parser module and reviews every change to it."}]}'
The answer is 202 with an id and a status_url; the work happens in the
background, usually within seconds:
curl <project URL>/memory/v1/ingests/<id> -H 'apikey: <secret key>'
# {"state": "succeeded", "items": 1, "written": 1, "results": [{"edges": [{"subject": "Carol", "verb": "owns", …}]}]}
Search
curl -X POST <project URL>/memory/v1/spaces/support_bot/search \
-H 'apikey: <secret key>' -H 'Content-Type: application/json' \
-d '{"text": "who is responsible for the parser?", "subject": "Carol", "limit": 5}'
# [{"content": "Carol owns the parser module", "similarity": 0.83, "subject_name": "Carol", …}]
A search names a subject or a verb (or both). If you compute embeddings yourself, store
items with an embedding instead of text and search with a vector through the client —
no provider key needed:
await supabase.schema('maludb').rpc('memory_search', { space: 'support_bot', query: [0.1, 0.2, …], subject: 'Carol' })
If a provider refuses — a wrong key, or its rate limit on a new account — the ingest or search reports the provider's own message, so you know which account to look at.
Deleting a project
Open the project's Overview and use Delete project. You are asked to type the project's reference, because this cannot be undone.
The moment you confirm, the project stops serving and every API key is revoked — a key already in an app stops working immediately. Its database, its files and its database roles are then removed in the background, usually within a minute. The reference is never issued again.
Export anything you want to keep first. There is no undo, and a deleted project is not recoverable from backups by us: the backups themselves expire on their own schedule.
To close your whole account, delete your projects and then write to support; we remove the account and its record of you.
Schema graph and vector search
Two MaluDB features your project can turn on from MaluDB in the dashboard. Both are served
through your Data API under the maludb schema, to your secret key only — a
publishable key and a signed-in user are refused, so keep these on your server.
The data-model graph
A map of your database's own structure: every table, view and function in public, how they relate,
and a full description of each relation's columns and keys. For tooling that has to understand a schema —
documentation, diagrams, migration review, code assistants.
It is a copy, not a live view. Turning it on builds it; Refresh now rebuilds it after a migration. A free project may refresh it six times an hour, and turning it on counts as one.
const { data: relations } = await supabase.schema('maludb')
.from('datamodel_relations').select('relation_name, kind, description, refreshed_at')
const { data: edges } = await supabase.schema('maludb')
.from('datamodel_edges')
.select('relationship, source:datamodel_nodes!source_node_id(name), target:datamodel_nodes!target_node_id(name)')
Vector compartments
Named compartments of embeddings, each with a fixed dimension and distance metric, searched exactly and filtered by metadata. You bring the embeddings from whichever model you use. A free project may store 10,000 vectors of up to 1,536 dimensions across 10 compartments.
const where = { namespace: 'docs', subject: 'page', verb: 'about' }
await supabase.schema('maludb').rpc('vector_compartment_create', { ...where, dimensions: 1536 })
await supabase.schema('maludb').rpc('vector_insert', { ...where, content: 'How to reset a password', embedding })
const { data } = await supabase.schema('maludb').rpc('vector_search',
{ ...where, query: queryEmbedding, match_count: 5, filter: { lang: 'en' } })
Search is exact rather than approximate: it compares against every vector in the compartment, which your plan's limits keep bounded. A compartment has no row-level security — anything that can search it reads every chunk in it. For search a signed-in user runs in the browser, with policies deciding what each person sees, use pgvector on your own tables.
Turning either off withdraws it from the Data API and deletes nothing; turning it on again finds what was there.
Not yet available
- Dashboard: editing rows in a grid, logs, and organization member management.
- Auth: OAuth providers, magic links, MFA and enterprise SSO.
- Storage: resumable uploads, image transformations, signed upload URLs and the S3 protocol.
- Realtime: Broadcast and Presence.
- Database: direct connections — planned for paid plans; Developer projects are API-only.