When the move fits
Seige fits apps where each user works on their own data: notes, trackers, personal finance, journals, planners. It does not fit apps where users read each other’s rows, such as chat, shared team workspaces or public profiles, or apps that need server-side SQL, functions, webhooks or scheduled jobs. As the developer you cannot query your users’ data, because it is in their accounts.
Concept map
| Supabase | Seige |
|---|---|
createClient(url, anonKey) | createSeige({ appName }). No URL, key or project; the app is identified by its website. |
auth.signInWithOtp(), signInWithPassword(), OAuth providers | seige.auth.signIn(), which opens the Seige window. There is no sign-in form in your app. |
auth.getUser(), getSession() | seige.auth.user(), returning { data: { id } } or { data: null }. There is no session object or token for the app to handle. |
auth.onAuthStateChange((event, session) => …) | seige.auth.onChange(({ user }) => …), which returns an unsubscribe function. |
auth.signOut() | seige.auth.signOut(), after seige.sync.flush(). |
from(table).select/insert/update/upsert/delete | The same, on seige.from(table). |
| Row level security policies | None. Each user’s database belongs to that user, so every row the app can read is the signed-in user’s. |
user_id columns, auth.uid() | Not needed. Drop them. |
SQL migrations in supabase/migrations | SQLite migrations passed as createSeige({ migrations }), applied in each user’s database. Optional: tables are created on first insert without them. |
storage.from(bucket).upload(path, file) | seige.files.put(path, file). Use the bucket name as a path prefix. |
download(path), signed URLs, public URLs | seige.files.get(path) returns a Blob; display it with URL.createObjectURL(). There are no public or signed URLs. |
channel().on("postgres_changes") | No change subscriptions yet. See Realtime. |
rpc(), Edge Functions | None. Run the logic in the app, or use SQL through seige.db.open(). |
Port auth state handling
onAuthStateChange fires once with the current session
when you subscribe. seige.auth.onChange does not: it
reports only later changes, including sign-in and sign-out in other
tabs of the app and a session that ended. Read the starting state
with seige.auth.user():
// Before
supabase.auth.onAuthStateChange((_event, session) => render(session?.user ?? null));
// After
async function startAuth() {
const { data: user } = await seige.auth.user();
render(user);
return seige.auth.onChange(({ user }) => render(user));
}
startAuth();
The shorter snippets on this page are the bodies of async
functions, such as event handlers. Vite’s default production
build target does not allow top-level await, so a
module that awaits outside a function runs in the development
server but fails in vite build. Keep the calls in
functions, or set build.target to
"es2022".
The user object is { id }. id is specific
to your website: the same person has a different id on another
origin. Email is present only if the app asked for it with
seige.auth.signIn({ email: true }) and the user
approved; there is no name or avatar. A magic-link or password form
becomes one “Sign in with Seige” button that calls
seige.auth.signIn() from its click handler.
Port queries
Most query code carries over unchanged once
supabase becomes seige:
const { data: open } = await seige.from("todos").select("*")
.eq("done", false).order("created_at", { ascending: false }).limit(20);
const { data: found } = await seige.from("todos").select("*")
.ilike("title", `%${q}%`);
const { count } = await seige.from("todos")
.select("*", { count: "exact", head: true }).eq("done", false);
const { data: todo } = await seige.from("todos").select("*").eq("id", id).single();
const { data: added } = await seige.from("todos").insert({ title }).select().single();
await seige.from("todos").update({ done: true }).eq("id", id);
await seige.from("todos").delete().eq("id", id);
Supported: select with aliases, JSON paths,
count and head; insert,
upsert with onConflict and
ignoreDuplicates, update and
delete, each with .select(); filters
eq, neq, gt, gte,
lt, lte, like,
ilike, is, in,
contains, containedBy,
overlaps, match, not,
filter and or() in PostgREST syntax; and
order, limit, range,
single, maybeSingle,
abortSignal and throwOnError.
Not supported, returning UNSUPPORTED with a hint:
embedded relations such as select("*, tags(*)") and
filters or ordering on related tables, textSearch(),
csv(), explain() and
:: casts. Query each table separately and join the
results in the app, and use ilike() for simple text
search.
Port a schema migration
Seige databases are SQLite. Translate Postgres types and defaults, drop the per-user columns and policies, and leave out anything that needs a server:
create table todos (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users default auth.uid(),
title text not null,
done boolean not null default false,
attachment_path text,
created_at timestamptz not null default now()
);
alter table todos enable row level security;
create policy "own rows" on todos for all
using (auth.uid() = user_id) with check (auth.uid() = user_id);
alter publication supabase_realtime add table todos;
create table todos (
id text primary key,
title text not null,
done boolean not null default false,
attachment_path text,
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
// With Vite
const migrations = import.meta.glob("./migrations/*.sql", {
query: "?raw", import: "default", eager: true,
});
const seige = createSeige({ appName: "Todos", migrations });
-
An insert without
idinto a table whose primary key is a single text column namedidgets a UUID, sogen_random_uuid()is not needed.integer primary keykeeps SQLite’s numbering. -
Columns declared
booleanread back astrue/false, andjsonorjsonbas parsed values. Store timestamps as ISO text. -
Foreign keys are enforced, including
on delete cascadeandon delete set null. -
Migrations cannot use
PRAGMA,ATTACH, transaction control or triggers, and there are no Postgres functions, extensions, enums or row level security. -
Migrations run in name order, once each, in every user’s
database when the app first opens it. Never edit an applied
migration: the app then fails with
MIGRATION_FAILEDfor users who already have it. Add a new file instead.
Port storage buckets
Files live in the app’s folder in the user’s account. A bucket becomes a path prefix, and the per-user path segment is no longer needed:
// Before
await supabase.storage.from("attachments").upload(`${user.id}/${name}`, file);
// After
await seige.files.put(`attachments/${name}`, file);
const { data: blob } = await seige.files.get(`attachments/${name}`);
image.src = URL.createObjectURL(blob);
files.put() resolves once the file is encrypted and
stored in this browser and uploads in the background; call
seige.sync.flush() when you need to know it reached
Seige. files.delete() is permanent and frees the
storage. Files count against the user’s Seige storage
allowance, not yours.
Replace realtime subscriptions
Seige has no change subscriptions yet. For one user with the app
open in several tabs of the same browser, post a message on a
BroadcastChannel after each write and reload in the
other tabs. For the same user on other devices, poll: a read always
returns the newest version stored in Seige.
const changes = new BroadcastChannel("todos-changed");
changes.onmessage = () => refresh();
async function addTodo(title) {
const result = await seige.from("todos").insert({ title });
if (!result.error) changes.postMessage("changed");
return result;
}
// Other devices: refresh while the page is visible.
setInterval(() => {
if (document.visibilityState === "visible") refresh();
}, 10_000);
There is no way for users to see each other’s changes, because each user’s data is separate.
Differences to plan for
-
One writer at a time. A user’s database
takes writes from one browser at a time. A write made while
another of the user’s browsers is writing waits for it, up
to about 45 seconds, then applies to the newest version or fails
with
IN_USEwithout saving, so a retry does not duplicate it. If the page closes during the wait, the write is kept in that browser and runs the next time the app opens there. Show a saving state while a write is in progress. -
Offline writes succeed. While Seige is
unreachable, writes resolve without an error and publish later.
Changes made offline on two devices can conflict;
sync.flush()then returnsCONFLICTand the app resolves it withseige.db.getConflict(). -
Missing tables. Without migrations, reading a
table that does not exist returns no rows rather than an error,
because tables are created on first insert. With migrations,
reading or writing a table that does not exist returns
42P01, as Postgres does, and an insert does not create the table. - The first sign-in is a window. The first call that needs the user opens the Seige window, so make it from a click. Each browser approves the app once.
- User ids are per origin. Preview deployments and other domains see different ids; read origins and previews.
- Limits. A database is limited to 32 MiB and a query result to 10,000 rows and 8 MiB.
-
Error codes. Query errors use the same Postgres
codes, such as
23505, andPGRST116forsingle(). Other failures use Seige’s codes, listed in the error table.