n naroDocs

Backend

Every project can come with a backend: its own database, sign-up and sign-in, and a Data API your browser code calls directly — no server of yours in between. It runs as a separate naro worker behind your project's URL, so there is no second host to set up and no connection string to keep.

Early access. Install the client with npm install naro-js, and turn the backend on for each project that uses it.

What you get

A Cloudflare D1 database bound to your app as env.DB, accounts that sign in with an email or a username and a password, stored in that same database, and a Data API at /_naro/v1 on your project's URL. Every request passes the API key, the signed-in user and the table's policy before it reaches SQL, and naro builds the SQL itself: values are always bound, and table and column names must exist in your schema. Accounts and policies live in _naro_ tables that the Data API never opens.

Files your users upload — avatars, attachments — go in Storage, behind the same URL, with policies written the same way: Storage

Turn it on

The backend is off until you turn it on, one project at a time: Turn on in the Backend card of the project's Database page, or naro backend enable (provision, its first name, still works). Turning it on creates the database, the keys and the Naro worker. It is safe to run again: a run that stopped picks up at the step that failed, and turning on a backend that is already running is a repair — it writes the routes to it again and runs its migrate and health check once more. naro backend status shows whether it is on, the status, the URL your code talks to and the anon key, and calls a run that died part-way stalled — the same enable command picks it up.

naro backend enable
naro backend status

naro backend disable, or Turn off in the same card, turns it off: in up to about 90 seconds your app's sign-ins and data requests get 404. The database with its data and users, the sign-in settings, the policies and the keys are kept, and turning it back on brings the backend back with the same keys — the anon key in your app keeps working. While it is off, key rotation and sign-in changes are refused with 409 BACKEND_OFF; policies can still be changed.

naro backend disable

Quick start

Give createClient the URL and the anon key that naro backend prints, and query tables the way you would with supabase-js. Calls never throw: each one returns { data, error }.

npm install naro-js
import { createClient } from "naro-js";

const naro = createClient({
  url: "https://my-app.naro.sh",
  anonKey: "naro_pk_…",
});

await naro.auth.signIn({ email, password });
const {
  data: { user },
} = await naro.auth.getUser();

const { data, error } = await naro
  .from("posts")
  .select("*")
  .eq("user_id", user.id)
  .order("created_at", { ascending: false })
  .limit(20);

await naro.from("posts").insert({ title: "Hello", content: "World" });
await naro.from("posts").update({ title: "Updated" }).eq("id", 10);
await naro.from("posts").delete().eq("id", 10);

Email and social sign-in

Email and password sign-up and sign-in need no setup: they work as soon as the backend is on. Confirmation emails, password reset and Google or GitHub sign-in run on the project's own accounts: a Resend API key to send those confirmation and reset emails, and your own OAuth app for each provider. None of these is on until you set it up. Secrets go in on stdin, never on the command line, and are never shown again — only the first characters of the email key, or set. naro backend auth prints a callback URL for each of the project's production hosts — its naro.sh address and each custom domain. Register, as the provider app's redirect URI, the one for the origin you pass to createClient({ url }): the provider sends the browser back to the host the sign-in began on, where its cookie is.

naro backend auth email set --provider resend --from "App <no-reply@example.com>" --api-key-stdin < resend-key.txt
naro backend auth oauth set github --client-id <client-id> --client-secret-stdin < github-secret.txt
naro backend auth redirects add https://app.example.com/auth/

A sign-in sends the browser back to your app: only to one of the project's production hosts — its https://<name>.naro.sh address and its custom domains — or to an entry you allowed with naro backend auth redirects add — an origin (https://app.example.com, every page) or an origin and a path (https://app.example.com/auth/, only the pages under it); https, or http on localhost. The preview host (<name>-preview.naro.sh) runs whatever branch was pushed, against the same accounts: it is allowed only once you add it, and confirmation and reset emails always link to a production host. signInWithOAuth sends the browser to the project's /_naro/v1/auth/authorize/<provider>, which sets a ten-minute __Host- cookie for that one sign-in on the host it runs on — another app on naro.sh cannot set or overwrite it, and sign-ins in two tabs keep their own — and goes on to the provider; the sign-in finishes only in the browser that holds that cookie (OAUTH_BROWSER_MISMATCH anywhere else). The answer rides in the URL fragment, which never reaches a server — a one-time code that lives 60 seconds, works once, and only with the PKCE verifier this browser kept for that sign-in. createClient reads it when the page loads, trades it for the session and clears it from the address bar, and getSession and every query wait for that, so none goes out signed out — for at most 15 seconds: then auth.ready() resolves with TIMEOUT and the queries go out signed out. A client built twice on one page (React StrictMode) waits on the same exchange. A confirmation link only confirms the address, in whatever browser opens it: after you confirm, sign in.

// Google or GitHub: the browser goes to the project's /authorize, then to
// the provider, and comes back signed in.
await naro.auth.signInWithOAuth({
  provider: "github",
  redirectTo: "https://app.example.com/auth/done",
});

// On the page it comes back to, createClient finishes the sign-in itself.
const {
  data: { user },
} = await naro.auth.getUser();

// Or read what came back: a failure, a confirmation or a reset link.
const { data, error } = await naro.auth.getSessionFromUrl();
// data.type: "oauth" | "email_verification" | "recovery" | null

// Email confirmation: the link only confirms. After you confirm, sign in.
await naro.auth.signUp({ email, password, redirectTo: "https://app.example.com/auth/confirmed" });
await naro.auth.resendVerification(email, { redirectTo: "https://app.example.com/auth/confirmed" });

// Password reset
await naro.auth.resetPasswordForEmail(email, { redirectTo: "https://app.example.com/auth/reset" });
// On /auth/reset, after the link — within 15 minutes, while nobody is signed
// in here (opening the link signed out whoever was):
await naro.auth.updateUser({ password: newPassword });

resetPasswordForEmail answers the same whether or not the address has an account, and an address gets at most one email a minute and five an hour. The reset link comes back with naro_type=recovery: call updateUser({ password }) on that page. Like supabase-js's recovery session, opening the link signs out whoever was signed in on that browser; updateUser uses its token only while nobody is signed in, for 15 minutes, and signing in, signing up or signing out forgets it — a link left open on a shared computer is not someone else's to use. A reset ends every session of the account, removes every Google or GitHub sign-in linked to it before (sign in with the provider again afterwards to link it back) and confirms the address; sign in with the new password afterwards. With naro backend auth require-verification on, sign-up answers without a session, and signing in before the address is confirmed answers 403 EMAIL_NOT_VERIFIED.

Any script on the page a sign-in lands on can read its fragment, and an open redirect passes it on: a page on an allowed origin that forwards elsewhere (/logout?next=…) hands the code or the reset token to that site. Give sign-in pages of their own — allow https://app.example.com/auth/ rather than the whole origin — and keep third-party scripts and open redirects off them. Google or GitHub sign-in is refused for an email the provider has not verified (OAUTH_PROVIDER_EMAIL_NOT_VERIFIED): verify it with the provider first. If someone else signed up with an address before its owner confirmed it — or the owner's Google or GitHub sign-in was refused for that unconfirmed account (OAUTH_ACCOUNT_NOT_LINKED) — confirming clears that password (PASSWORD_RESET_REQUIRED): choose one with a password reset.

Admin accounts and closed sign-ups

Sign-in is naro Auth, the project's own, like Supabase Auth. For an app only you or your admins sign in to, close sign-ups and create the accounts yourself. With sign-ups closed, every sign-up — by email, or a first Google or GitHub sign-in — answers 403 SIGNUPS_DISABLED ("Sign-ups are closed for this project."), and authenticated in a policy means exactly the accounts you created; users that exist keep signing in. naro backend users create takes --username, --email or both, and the password on stdin (--password-stdin: a pipe, or a hidden prompt; --password works too, but lands in shell history). The account is confirmed and signs in at once, also while sign-ups are closed, and one with only a username has email: null. naro backend users lists every user, and naro backend users delete <id|username|email> removes one and ends its sessions.

naro backend auth signups off
naro backend users create --username admin --password-stdin
naro backend auth password --min-length 10
// In the app: sign in by username, or by email.
await naro.auth.signIn({ username: "admin", password });

// On a server, with the service key: supabase-js's auth.admin.
const admin = createClient({
  url: "https://my-app.naro.sh",
  serviceKey: process.env.NARO_SERVICE_KEY,
});
await admin.auth.admin.createUser({ username: "editor", password });
const {
  data: { users, total },
} = await admin.auth.admin.listUsers({ page: 1, perPage: 50 });
await admin.auth.admin.deleteUser(users[0].id);

A username is 3 to 30 letters, digits, _ and ., matched in any case. While sign-ups are open, a sign-up with a username someone has answers 409 USERNAME_TAKEN ("That username is already taken."), so anyone can learn which usernames exist, as fast as the per-address sign-up limit allows; with sign-ups closed, every sign-up answers 403 SIGNUPS_DISABLED first. From your server, the service key's auth.admin.createUser, listUsers and deleteUser do the same with supabase-js's names; on an anon client they answer 403 PERMISSION_DENIED without a request. Passwords take at least 6 characters: naro backend auth password --min-length sets 6 to 128, and a shorter one on sign-up, password reset or create answers 400 PASSWORD_TOO_SHORT ("Use at least 10 characters."). Sign-in never checks the length, so passwords set before keep working.

A backend turned on before these settings gets them with its next upload: run naro backend enable once. Until then naro backend users answers 409 BACKEND_OUTDATED, and signIn({ username }) and auth.admin answer 404 NOT_FOUND.

Keys

The anon key identifies the project and grants nothing on its own — every request it makes still goes through the policies — so it is meant to sit in browser code. The service key skips every policy. Keep it on a server as NARO_SERVICE_KEY: createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers) and refuses it in a browser, a web worker or a React Native app. naro backend keys --reveal prints it, for roles that may write to the database. naro backend keys rotate replaces a key on a ready backend, with no overlap: once the new key is live, every client still sending the old one is refused — and for a few seconds during the rotation the old key can already be refused. The Naro worker takes the new key before the database does, so a rotation that fails usually leaves the old key working. If you are not sure — a 5xx answer, a timeout, or the message that the worker may still expect the new key — run naro backend keys --reveal to see which key is live, then naro backend enable.

// On a server only (Node, Deno, Bun, Workers). Anywhere else createClient throws.
const admin = createClient({
  url: "https://my-app.naro.sh",
  serviceKey: process.env.NARO_SERVICE_KEY,
});
naro backend keys --reveal
naro backend keys rotate --anon

Row-level security

Policies are Postgres's row-level security, the same as in Supabase, and it is always on. A table without a policy refuses every anon and signed-in request; the service key skips policies. Write a table's policies in the migration that creates it: CREATE POLICY after the CREATE TABLE, in the same file. naro db push, naro migration up, naro db query and the MCP tools apply_migration and execute_sql apply them with the table, all or nothing. Three shapes cover most apps — each user's own rows, a table anyone may read, and an app only its admins sign in to (run naro backend auth signups off and create the admin accounts first, so that authenticated means exactly those accounts):

-- migrations/0002_todos.sql: each signed-in user reads and changes their own rows.
create table todos (
  id integer primary key,
  user_id uuid not null,
  title text not null
);
create policy "own rows" on todos for all to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

-- migrations/0003_posts.sql: anyone reads, signed in or not.
create table posts (id integer primary key, title text not null);
create policy "read" on posts for select to anon, authenticated using (true);

-- migrations/0004_reports.sql: an app only its admins sign in to. First run
-- naro backend auth signups off and create the admin accounts: authenticated
-- then means exactly those accounts.
create table reports (id integer primary key, body text not null);
create policy "admins" on reports for all to authenticated using (true) with check (true);

A migration or a query takes CREATE POLICY; DROP POLICY, where IF EXISTS skips a missing table too, as in Postgres; COMMENT ON POLICY p ON t IS 'naro:owner <column>', which makes p the owner rule naro policy set --owner writes (any other comment is ignored); and ALTER TABLE t ENABLE, FORCE or NO FORCE ROW LEVEL SECURITY, which changes nothing because it is always on. Before anything runs, naro refuses these with 400 VALIDATION_FAILED "Statement <i> of <n>: …": ALTER TABLE … DISABLE ROW LEVEL SECURITY (to let everyone through, create a policy that uses (true)), GRANT and REVOKE (every table is behind row-level security, so write a policy instead), ALTER POLICY (DROP POLICY and CREATE POLICY again), and a table named public.x in CREATE TABLE, DROP TABLE or a rename ('SQLite has no schema "public": write the table name without "public.".'). The statements of one request apply all-or-nothing: a policy on a table that does not exist, a name already taken, or a 33rd policy on one table fails the whole request with 400 DATABASE_QUERY_FAILED, names the statement, and applies nothing — not even the CREATE TABLE before it. Policy statements take no params: write the values into the SQL.

Policies go with their table, as in Postgres: DROP TABLE drops them, and a new table never inherits the leftovers of an earlier one with the same name (except tables changed through the table editor on a project without a backend). SQLite changes a column by building the table again — create __new_x, copy the rows, drop x, rename __new_x to x — so the drop removes x's policies and the rename does not bring them back. Write the CREATE POLICY statements again after the rename, in the same migration. A plain ALTER TABLE a RENAME TO b does not move policies either: they stay under a, and b has none until you create them again. The answer's warnings lists the policies a migration removed, with SQL you can paste; it also says when the project has no backend yet (the policies are saved and apply once naro backend enable turns it on) and when a policy was applied that the Data API refuses, with the reason. The CLI prints each warning as ⚠ …, and the MCP tools return them.

naro db push --dry-run parses the policy statements of the pending files on your machine and prints what it would refuse as <file>: Statement <i> of <n>: …; whether a table or a policy exists is known only when the file is applied. naro db pull writes the policies that exist at the end of the baseline file, as ALTER TABLE … ENABLE ROW LEVEL SECURITY and CREATE POLICY statements (an owner rule's column as a COMMENT ON POLICY). naro db reset clears the policies with the schema and creates them again from the migration files, so a policy that exists only in the database is lost: before it asks, it lists the policies your files do not make, each with the SQL that makes it again: add that SQL to a migration to keep them (naro db pull refuses once the database has migration history). The policies of a table your files do not create go with it, and naro db reset refuses to start when the control plane would refuse one of the files. On a project with a backend, naro db lint reports rls_no_policy (a table the Data API refuses entirely), rls_policy_text_default (a number column a policy reads as a boolean whose DEFAULT is text, such as BOOLEAN DEFAULT 'true') and rls_policy_unindexed as warnings, and rls_policy_invalid — a policy the backend cannot apply, such as one that reads a text column as true or false — as an error.

More complete policies. These give each user their own todos, everyone the published posts and team members their team's docs, and hide deleted docs from all of them. Write them in a migration, or send them with naro policy create — one statement in quotes, or --file with several, DROP POLICY included — and naro policy list prints them back as SQL it takes again. A policy change is live within about 10 seconds. A policy made with naro policy create is recorded nowhere in ./migrations, so naro db reset removes it.

-- Each signed-in user reads and changes their own todos.
create policy "own todos" on todos to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

-- Anyone reads published posts; authors add their own.
create policy "published posts" on posts for select
  using (published);
create policy "authors insert" on posts for insert to authenticated
  with check (author_id = auth.uid());

-- Team members read their team's docs. The subquery reads members through
-- members' own select policies, so members needs one too. It runs once per
-- doc, so it needs an index to find its rows by — create it first:
--   naro db index create members team_id
create policy "own memberships" on members for select to authenticated
  using (user_id = auth.uid());
create policy "team docs" on docs for select to authenticated
  using (exists (
    select 1 from members m
    where m.team_id = docs.team_id and m.user_id = auth.uid()
  ));

-- Deleted docs stay hidden, whatever else lets them through.
create policy "hide deleted" on docs as restrictive for select
  using (deleted_at is null);
naro policy create --file policies.sql
naro policy list

A request goes through when at least one permissive policy for its command and role passes and every restrictive policy does, so a restrictive policy only narrows what a permissive one lets in, and alone lets nothing in. FOR ALL, the default, covers every command; TO public, the default, is anon and signed-in users together, and TO anon or TO authenticated picks one. Each command is checked the way Postgres checks it:

RequestRows it reachesA row it writes must pass
selectThose the select policies' USING passes.—
insert—WITH CHECK, and with .select() the select policies' USING too.
updateThose its USING and the select policies' USING pass.WITH CHECK — USING for a policy without one — and the select policies' USING.
deleteThose its USING and the select policies' USING pass.—

A row USING does not pass is left out without an error. A row an insert or update would write that fails a check writes nothing — the whole request is undone — and the answer is 403 PERMISSION_DENIED "New row violates row-level security policy for table <table>.", with the policy's name in quotes after "policy" when a restrictive one failed. So an update that moves a row out of what select shows is refused, as in Supabase: with the policies above and an update policy on docs, setting deleted_at is refused by "hide deleted". When no permissive policy covers the command and role, naro refuses the request — 403 PERMISSION_DENIED "This request is not allowed by the <action> policy of <table>." — where Postgres would answer with no rows. A check reads a row the way it will be stored and compares it the way the column does, COLLATE NOCASE or RTRIM included. So a number column it reads takes numbers, booleans and null, not strings, and cannot be matched with LIKE or turned into text; a primary key column it reads must be sent, and not as null; any other column it reads may be left out only when it has no default or a plain one — NULL, TRUE, FALSE, a number, or a quoted string on a column that is not a number column — so a column whose default is CURRENT_TIMESTAMP or another expression must be sent; and it cannot read a generated column or a virtual table's column.

USING and WITH CHECK take the part of Postgres's expressions a policy needs: columns; strings, numbers, true, false and null; =, <>, <, <=, >, >=; AND, OR and NOT; IS [NOT] NULL, IS [NOT] DISTINCT FROM, [NOT] IN (…), [NOT] LIKE and ILIKE, BETWEEN; EXISTS (SELECT …) and IN (SELECT …) over your other tables; lower, upper, coalesce, length and now(); and auth.uid(), auth.role() and auth.jwt() ->> 'sub', 'role', 'email' or 'email_verified'. Supabase's (select auth.uid()) reads as auth.uid(). For anon, auth.uid() is null and auth.jwt() holds only the anon key's role, as in Supabase, and NOT, IS NULL and NOT EXISTS read them as Postgres does: auth.uid() IS NULL is true for anon, while user_id = auth.uid() and its NOT match no row. To write a policy for anon, use TO anon or auth.role() = 'anon'. auth.jwt() ->> 'email' is the signed-in user's address once it is verified, and null until then — sign-up signs a user in before the address is confirmed, so an unverified one is only what they typed; email_verified says which. A subquery sees its table through that table's own select policies, as in Postgres, so a table a policy looks into needs a select policy for the same users — above, members has one. Inside a subquery a column without a table name is the subquery's table's first, so write docs.team_id for the outer row. A subquery that reads the outer row runs once for every row the statement looks at, so it needs an index: naro refuses the policy until a column its WHERE compares with = starts an index — above, naro db index create members team_id — and names the index to create; naro policy create --allow-unindexed accepts it anyway, and every run then reads the whole table. Policies that would expand into themselves are refused when you create them, with Postgres's words: "Infinite recursion detected in policy for relation members". The policies one statement applies bind at most 50 values and write at most 50,000 bytes of SQL, leaving the other half of what a statement may bind and hold to the request.

Where naro differs from Postgres. Row-level security is always on and cannot be disabled: to open a table to everyone, create policy "open" on t using (true) with check (true). A command no permissive policy covers is a 403, not zero rows, and an update or delete when the caller has no select policy is refused with the select message. The roles are anon and authenticated only, and there are no security definer functions. Comparisons follow SQLite: a column's affinity and collation decide them, there are no type errors, lower, upper and ILIKE fold ASCII letters only, and now() is UTC text as CURRENT_TIMESTAMP writes it, so compare it with columns stored the same way. A column is read as the type it was declared with: where a row of a number or boolean column holds the text 'true', it reads as false and NOT is_private returns that row. So the Data API refuses a string for a column that any table's policy reads (bar an owner rule's own id column), with or without returning, as 400 INVALID_QUERY. For a column whose declared type has INT, REAL, FLOA, DOUB, NUM, DEC, BOOL or BIT in it, that is every string — and even when no policy reads it, a string SQLite would keep as text ('abc', 'true'; '7' is stored as 7), which Postgres refuses as a type error and a policy written later would read inside out. A column declared another type — uuid, date, timestamptz, json — keeps a string as text, so Supabase's user_id uuid with auth.uid() = user_id works; the Data API refuses there only a string SQLite would store as a number ('12'), a string with a NUL character, and any string when a policy reads the column as true or false or compares it with a number. An insert that leaves out such a column whose default is a string SQLite keeps as text, such as is_private BOOLEAN DEFAULT 'true', is refused the same way (must include is_private): send the value. The service key, raw SQL and an import can still store a string, so send true, not 'true'. On such a uuid-like column, a WITH CHECK that orders it against a number (<, >, BETWEEN) can judge a new text differently from the row it becomes, and LIKE or a text function on it is refused as on a number column. An update's check whose subquery reads the table being updated sees the rows the same statement already changed.

A backend turned on before named policies answers the commands that write a policy — naro policy create, naro policy drop and naro policy set — with 409 BACKEND_OUTDATED: "The project backend is older than named policies. Run `naro backend enable` to update it, then try again." naro backend enable uploads the current backend and turns the stored rules into named policies; it also turns a backend that is off back on, so run naro backend disable again afterwards if it should stay off. naro policy list and naro policy remove work in the meantime. A migration or a query that holds a CREATE POLICY or DROP POLICY gets the same answer before anything runs; SQL without one runs as before.

Shorthand: naro policy set

naro policy set writes a table's policies naro_select, naro_insert, naro_update and naro_delete from one rule or condition per command, and leaves the table's other policies alone; none drops that command's policy. naro policy list shows what it wrote as CREATE POLICY. An owner policy carries a -- naro: owner comment there, since filling the owner column in on insert is a naro extension SQL has no words for.

RuleWho it lets through
noneNobody but the service key. The default.
publicAnyone with the anon key, signed in or not.
authenticatedAny signed-in user.
ownerSigned-in users, and only rows whose owner column holds their user id. On insert naro fills that column in; a different value is refused.

The command below lets each signed-in user read and change their own todos and nothing else. What naro policy set leaves out keeps its stored value. naro policy remove drops every policy on a table, closing it again.

naro policy set todos --select owner --insert owner --update owner --delete owner --owner user_id

A command can also take a condition on the row, written in the filter syntax of .or() — conditions separated by commas are ANDed — with auth.uid() where the signed-in user's id goes; members.cs.{auth.uid()} matches a JSON array of member ids. --select-when and --delete-when set USING and --insert-when sets WITH CHECK; --update-when sets update's USING, and every changed row must still meet --update-check, which is --update-when unless you give it. For anon, auth.uid() has no value: a single comparison with it matches no row, and neither does its not. Inside a group, SQL's three-valued logic applies, so a not over a group can still let anon in — not(and(is_public.eq.1,user_id.eq.auth.uid())) matches every row that is not public. Write a condition as what it lets in, not as the not of what it keeps out. --signed-in turns anon away from the conditions you set in that command. --from-file takes the policy as JSON instead, with auth.uid() written as {"auth":"uid"}; naro policy set prints the conditions back in filter syntax and --json gives the exact JSON. Each using and each check holds at most 100 conditions nested at most 4 deep, 4 of them contains, containedBy or overlaps, and what one naro policy set sends is at most 16 KB of JSON.

# Public posts for everyone, the rest for their author; authors write their own.
naro policy set posts \
  --select-when 'or(is_public.eq.true,user_id.eq.auth.uid())' \
  --insert-when 'user_id.eq.auth.uid()' \
  --update-when 'user_id.eq.auth.uid()' \
  --delete-when 'user_id.eq.auth.uid()'

# The same select condition as JSON, for naro policy set posts --from-file policy.json
{
  "select": {
    "using": {
      "or": [
        { "operator": "eq", "column": "is_public", "value": true },
        { "operator": "eq", "column": "user_id", "value": { "auth": "uid" } }
      ]
    }
  }
}

An --update-check you give replaces the default, so unless the select policies also hold a row to its owner, a check without user_id.eq.auth.uid() lets a user hand a row to someone else by changing its user_id. Include it: --update-when 'user_id.eq.auth.uid()' --update-check 'user_id.eq.auth.uid(),status.eq.draft'.

Filters

Chained filters are ANDed. .or() and .and() group conditions — pass a callback, as below, or a Supabase filter string — and .not() negates one condition or a callback's group. .match({ column: value }) adds an eq for each key, and .filter(column, operator, value) takes any operator by name. A condition on a NULL column is neither true nor false, and so is its not: .not("status", "eq", "draft") leaves out rows whose status is NULL, as in SQL and Supabase — add status.is.null to an .or() to include them.

import { quoteFilterValue } from "naro-js";

// Chained filters are ANDed. Group them with a callback…
await naro
  .from("posts")
  .select()
  .or((q) => q.eq("status", "draft").lt("price", 10))
  .not("deleted_at", "is", null)
  .match({ user_id: user.id })
  .filter("views", "gte", 100);

// …or paste Supabase's filter string.
await naro
  .from("posts")
  .select()
  .or("status.eq.draft,and(price.lt.10,tags.cs.{sale})");

// Input from users goes in quoted, as one value.
await naro
  .from("posts")
  .select()
  .or(`title.eq.${quoteFilterValue(q)},body.eq.${quoteFilterValue(q)}`);

// A TEXT column holding a JSON array, like ["a","b"].
await naro.from("posts").select().contains("tags", ["a", "b"]);

In a filter string every value is text: id.eq.10 sends "10". A column declared with a type (INTEGER, REAL…) converts it back to a number; a column without one does not, and SQLite orders every number before any text, so n.lt.10 matches every number in an untyped n — use the callback form or .lt("n", 10) there. Unquoted, a few values are not text. is.null, is.true and is.false are those values. true and false after eq or neq, and in an in.(…) list, are booleans: done.eq.true matches a BOOLEAN or INTEGER column holding 1 and archived.neq.true only the rows holding 0, while done.eq."true" compares with the text. In a cs, cd or ov list, a plain decimal that goes out exactly as written is a number — a safe integer, or a fraction with no exponent — so {1e5} and ids above 9,007,199,254,740,991 stay text. In every like and ilike, .like() and .ilike() included, * stands for %, as in PostgREST, so a literal * cannot be matched. For input from users, prefer the callback form or .filter(); if you build a string, pass each value through quoteFilterValue(). A filter only narrows what the table's policy allows, but an injected condition can widen a query up to that boundary — a .delete().or() built from raw input can delete every row the caller may delete.

.contains(), .containedBy() and .overlaps() work on a TEXT column holding a JSON array: it has all of the values, only the values, or at least one of them. Each takes 1 to 100 strings and numbers, and one request holds at most 4 of them. A row that is not a JSON array is no match, never an error. A value longer than 65,536 characters is not looked into and matches neither the condition nor its not, and a JSONB value never matches.

Types

naro gen types writes TypeScript for your schema. Pass its Database to createClient<Database>() and the client knows your tables: from() takes their names, select("id, title") returns those columns and .single() follows — a column the table lacks is a type error where you write it — insert() and update() take what each table can store, and filters and order() take its column names. --lang typescript is the default and the only language. Without a type argument the client stays untyped, as before.

naro gen types --output src/database.types.ts
import { createClient } from "naro-js";
import type { Database, Tables } from "./database.types";

const naro = createClient<Database>({
  url: "https://my-app.naro.sh",
  anonKey: "naro_pk_…",
});

// { id: number; title: string }[] | null
const { data } = await naro.from("posts").select("id, title");

// Does not compile: Argument of type '"id, nope"' is not assignable
// to parameter of type '"Unknown column 'nope'"'.
await naro.from("posts").select("id, nope");

await naro.from("posts").insert({ title: "Hello", user_id: user.id });
const post: Tables<"posts"> | null = (
  await naro.from("posts").select().eq("id", 1).maybeSingle()
).data;

Insert leaves out generated columns and makes a column optional when it has a default, allows NULL, is the INTEGER PRIMARY KEY, or is the owner column of a table whose insert rule is owner — the backend fills it, and naro gen types reads the policies to know (run it again after naro policy set). Update makes every column optional. Types follow SQLite's affinity rules and what D1 returns: INTEGER and REAL are number, TEXT is string, BOOLEAN is number — 0 or 1, and insert takes true or false — NUMERIC, DATE and DATETIME are number | string (CURRENT_TIMESTAMP is text), a BLOB is number[] and cannot be written through the Data API, and a column with no type, or a STRICT table's ANY column, is number | string | number[]. An array or object can be written straight into a TEXT column — insert({ tags: ["a", "b"] }) — and the Data API stores its JSON text, the same as JSON.stringify: that is what .contains() reads, and it comes back as that string, so JSON.parse it. Views get a Row in Database.views for Tables<"view">, but from() does not take them: the Data API reads no view and answers 404 TABLE_NOT_FOUND. A quoted name or a string variable in select() gives the whole row, and filter values and filter strings are not checked.

Limits

One statement binds at most 100 values, and one insert may take at most 100 statements — send large batches in pieces. One select reads at most 1,000 rows, by .limit() or .range(). One request holds at most 100 filter conditions, with groups nested at most 4 deep. like and ilike patterns are at most 50 bytes of UTF-8, which is D1's limit; in like, a literal *, ? or [ counts as 3. ilike folds ASCII letters only: É and é do not match each other. update and delete need at least one filter; only the service key can change a whole table, with .all(). Past the project's daily database write limit (see Database), inserts, updates, deletes and sign-ups are refused until 00:00 UTC; reads and sign-in keep working.

When a request fails

Calls return { data, error } instead of throwing. These errors point at the setup rather than at the query:

ErrorWhat to do
404 NOT_FOUND
There is no Naro backend at this host.
The URL is not a project whose backend is on. Use the URL naro backend status prints, and if it says off, turn it on with naro backend enable. Turning it on or off takes up to about 90 seconds to reach every location.
503 BACKEND_UNAVAILABLE
The Naro backend could not be reached. Try again.
naro could not look the backend up for this host just now. Try again.
401 INVALID_API_KEY
The API key is missing or not valid for this project.
The key belongs to another project, or it was rotated. Take the anon key from naro backend status.
401 INVALID_SESSION
The session is expired or not valid. Sign in again.
The session expired or was signed out. Sign in again — the SDK has already dropped the old token.
403 PERMISSION_DENIED
This request is not allowed by the <action> policy of <table>.
No permissive policy lets this caller run this command on the table — an update or delete needs a select policy too. naro policy list shows the table's policies; add one with CREATE POLICY in a migration, or with naro policy create.
403 PERMISSION_DENIED
New row violates row-level security policy for table <table>.
A row this insert or update writes fails a WITH CHECK or, for an update or an insert with .select(), the select policies, so nothing was written. A name in the message is the restrictive policy it failed. Compare the values you send — the user_id, say — with what naro policy list prints.
500 POLICY_INVALID
Policy <policy> on <table> cannot be applied.
The policy names a column or table that is gone, checks a generated column, binds more values than a statement can, or was stored in a form naro cannot read. It refuses the requests it applies to, for everyone but the service key, until you drop it or create it again.
500 POLICY_INVALID
Infinite recursion detected in policy for relation <table>.
A policy's subquery reads a table whose own select policies lead back to it. naro refuses such policies when you create them; if one gets through, break the loop by making one of those select policies stop reading the other table.
429 WRITE_QUOTA_EXCEEDED
This project has reached its daily database write limit. Reads still work; writes resume at 00:00 UTC.
The project's database wrote more rows today (UTC) than its daily limit. Inserts, updates, deletes and sign-ups resume at 00:00 UTC — the Retry-After header and details.retryAfter say in how many seconds, and naro db info shows the count. Reads, sign-in and sign-out keep working. The service key is refused too: the limit is about cost, not permission.
507 DATABASE_FULL
The database has reached D1's 10 GB limit, which cannot be raised. Delete data to free space; writes fail until it is under the limit.
The project's database reached D1's 10 GB limit. Writes fail until you delete data you no longer need or move large blobs to storage (R2); reads keep working and no retry helps. naro db info shows the size and warns from 8 GB.
409 EMAIL_NOT_CONFIGURED
Email is not set up for this project. Run `naro backend auth email set` first.
The project has no email set up, so it cannot send confirmation or reset links. Set one up with naro backend auth email set.
409 PROVIDER_NOT_CONFIGURED
<Provider> sign-in is not set up for this project. Run `naro backend auth oauth set <provider>` first.
Set up the provider's OAuth app with naro backend auth oauth set, and register as its redirect URI the callback URL naro backend auth prints for the origin your app passes to createClient.
403 INVALID_REDIRECT_URL
redirectTo is not an allowed redirect for this project. Allow its origin, or a path under it, with `naro backend auth redirects add <url>`.
Allow your app's origin with naro backend auth redirects add — or better, only its sign-in pages, like https://app.example.com/auth/ — or send the browser back to a page on one of the project's production hosts. The preview host is allowed only once you add it.
400 HTTPS_REQUIRED
Naro sign-in works over https only. Use the https:// address of this project.
Sign-in answers only on the project's https:// address (http only on localhost). Use the https URL in createClient and in your links.
403 EMAIL_NOT_VERIFIED
Email not verified
The project requires a confirmed email. Ask the user to open the confirmation link, or send it again with resendVerification.
403 SIGNUPS_DISABLED
Sign-ups are closed for this project.
The project's sign-ups are closed, so nobody signs up on their own — by email or with a first Google or GitHub sign-in. Create the account with naro backend users create (or auth.admin.createUser), or open sign-ups with naro backend auth signups on.
403 PERMISSION_DENIED
Only the service key can manage users.
auth.admin works only on a client made with the service key, on a server. On an anon client naro-js answers this without a request.

Not in this release

Sign-in works with an email or a username and a password, email confirmation and password reset, and Google or GitHub. Other providers, magic links and one-time passcodes are not there yet.