Coming from Supabase

The Seige query builder follows supabase-js: the same from().select().eq() chains, { data, error } results and Postgres error codes. The difference is where data lives. Each user has their own encrypted SQLite database in their Seige account, so there is no shared Postgres database, no access policies and no server to call.

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

SupabaseSeige
createClient(url, anonKey)createSeige({ appName }). No URL, key or project; the app is identified by its website.
auth.signInWithOtp(), signInWithPassword(), OAuth providersseige.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/deleteThe same, on seige.from(table).
Row level security policiesNone. 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/migrationsSQLite 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 URLsseige.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 FunctionsNone. 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:

supabase/migrations/001_todos.sql (before)
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;
src/migrations/20261019120000_todos.sql (after)
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 id into a table whose primary key is a single text column named id gets a UUID, so gen_random_uuid() is not needed. integer primary key keeps SQLite’s numbering.
  • Columns declared boolean read back as true/false, and json or jsonb as parsed values. Store timestamps as ISO text.
  • Foreign keys are enforced, including on delete cascade and on 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_FAILED for 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_USE without 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 returns CONFLICT and the app resolves it with seige.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, and PGRST116 for single(). Other failures use Seige’s codes, listed in the error table.