Adopt in an existing app
Add Lunora to a working product that already has its own auth, Worker, and data layer — without rewriting either.
Last updated:
Most Lunora adoptions are not greenfield. The app already exists, already ships, already has a session authority (better-auth, Clerk, Auth.js, its own cookie), its own Worker with its own routes, and a data layer it can't switch off on a Tuesday. Lunora comes in beside all of that, takes over one slice of sync, and proves itself before anything is deleted.
This page is that path: what to keep, what to wire, and the specific things that will otherwise cost you an afternoon.
Keep your auth. The single most common wrong turn is standing up a second signup stack. Don't. Your existing session authority stays authoritative; Lunora reads it.
Keep your session authority
Lunora ships @lunora/auth, but it is optional and it is not the adoption path for
an app that already authenticates users. Running a second signup/session stack beside
your real one means two user tables, two cookie lifetimes, and two places a session
can be revoked.
Instead, bridge the session you already have with resolveIdentity. It receives the
inbound request and returns the identity Lunora should trust:
import { createWorker } from "lunorash/runtime";
export default createWorker({
shardDO: ShardDO,
// Your existing session authority, unchanged.
resolveIdentity: async (request, env) => {
const auth = createAuth(env, new URL(request.url).origin);
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
return null; // anonymous
}
// `userId` becomes ctx.auth.userId on the shard; the rest is forwarded as
// claims for ctx.auth.getIdentity() and any claim-reading RLS policy.
return { email: session.user.email, userId: session.user.id };
},
// Which shard a given identity may reach. With `.shardBy("userId")` this is the
// whole per-user isolation boundary.
authorizeShard: (identity, shardKey) => identity?.userId === shardKey,
});Memoize it
resolveIdentity is called once per RPC and once per fan-out leg, so a
cross-shard query verifies the session once per shard — and the
createAuth(...) line above runs every time too. Wrap it:
import { memoizeIdentityPerRequest } from "lunorash/runtime";
resolveIdentity: memoizeIdentityPerRequest(async (request, env) => { … }),memoizeIdentityPerRequest is keyed on the Request object, so it cannot serve a
stale identity — it just stops the duplication. If verification is genuinely expensive
(a JWKS fetch, a database read), memoizeIdentity(resolver, { ttlMs: 5000 }) also
caches across requests. ttlMs is in milliseconds — 5000 is five seconds — and it
is your revocation delay, so keep it to a few seconds.
Declare ownership once
With .shardBy("userId") and authorizeShard already stating who owns what, don't
restate it in every shape's where. Declare it on the table:
// lunora/schema.ts
nodes: defineTable({ text: v.string(), userId: v.string() })
.shardBy("userId")
.ownedBy("userId"),// lunora/shapes.ts — no predicate needed; the filter comes from the verified identity
export const myNodes = defineShape({ owner: true, table: "nodes" });A shape's where returns a predicate, not a boolean, so if you do write one, use
deny() for the unauthorized branch:
import { deny } from "lunorash/server";
where: (ctx, { channelId }) => (ctx.auth.userId === null ? deny() : { channelId }),deny() is { OR: [] } — a disjunction over zero branches, which matches nothing.
The plausible-looking {} is its exact opposite and replicates the entire table,
silently. false works as sugar for the same thing.
Call Lunora from your own Worker
Your app probably has server-side code that needs Lunora data — an MCP server, a webhook handler, a cron, an admin route. Don't reach for the raw DO stub and hand-roll the protocol. Use the typed client:
import { createShardClient } from "lunorash/runtime";
import { internal } from "../lunora/_generated/api";
const shard = createShardClient(env.SHARD).as({ userId }).forShard(userId);
const nodes = await shard.call(internal.mcp.listNodes, { userId });
// ^? typed from the function's own args + return validatorsA fresh client is a system caller (it may invoke internalQuery /
internalMutation), because code holding the DO binding is already inside the trust
boundary. .as({ userId }) additionally attaches a verified identity so the call runs
under that user's RLS context. Pass { system: false } if you want it to behave
exactly like an end-user RPC.
createShardClient does not run authorizeShard — you chose the shard key. Verify the identity owns the shard first, normally the same one-line check
your worker's authorizeShard does.
Dev proxy: two silent failures
If Vite serves your app and proxies /_lunora to a separately-running worker, two
things go wrong in a way that looks identical — the page loads, HTTP RPC answers, and
the live query never arrives, so the UI sits on its loading state with nothing in the
console.
// vite.config.ts
server: {
proxy: {
// ws: true is REQUIRED. The string shorthand ("/_lunora": "http://…") and
// the object form without `ws` do not install the upgrade listener.
"/_lunora": { target: "http://localhost:8787", ws: true },
},
},The second is changeOrigin: true: it rewrites the Host, so the worker computes a
different self-origin than the browser's Origin and the CSRF guard rejects the
cookie-bearing upgrade with FORBIDDEN_ORIGIN. Loopback-to-loopback is trusted by
default, so the common localhost:3000 → localhost:8787 case just works; any other
dev host needs the dev-server origin in security.csrf.trustedOrigins.
@lunora/vite warns about both at startup, and the FORBIDDEN_ORIGIN body names the
origin it received, the origin it expected, and the knob.
Run both engines during the migration
Cut over behind a flag, not in one commit. Read the flag at the boundary where the data layer is constructed, so the old path is untouched when it's off:
export function DataLayer({ children }: { children: ReactNode }) {
const [useLunora, setUseLunora] = useState(false);
// Read in an effect, not during render, so SSR/prerender and hydration agree.
useEffect(() => setUseLunora(isLunoraEnabled()), []);
if (!useLunora) {
return <>{children}</>; // legacy path, unchanged
}
return <LunoraProvider client={getLunoraClient()}>{children}</LunoraProvider>;
}Pair it with a server-side switch read from the same preference, so a user's browser and your server-side code (MCP, webhooks) agree about which backend owns their data.
Move the data
One-shot import per user. Chunk it — a per-row loop pays a round-trip and a watermark wait per row:
await client.importRows(api.migrate.importNodes, rows, {
importId: `migrate-${userId}`, // stable → a retried chunk is deduped, not doubled
onProgress: ({ done, total }) => setProgress(done / total),
shardKey: userId,
toArgs: (chunk) => ({ nodes: chunk }),
});Back the receiving mutation with ctx.db.insertMany(...) (or insertManyUnsafe for
data you vouch for). For the reverse — a user leaving, or GDPR erasure — use
ctx.db.wipeShard() from an internalMutation.
Test it without a worker
Don't stand up a worker and a DO per e2e test, and don't hand-roll the wire protocol either — the watermark bookkeeping is easy to get subtly wrong, and then the failure looks like a Lunora bug:
import { mockLunora } from "@lunora/testing/playwright";
const lunora = await mockLunora(page, {
rows: { nodes: [{ _id: "n1", text: "hello", userId: "u1" }] },
shapes: { myNodes: { tables: ["nodes"], where: { userId: "u1" } } },
});
await page.goto("/");
await expect(page.getByText("hello")).toBeVisible();
await lunora.insert("nodes", { _id: "n2", text: "world", userId: "u1" });
await expect(page.getByText("world")).toBeVisible();
lunora.suppressPokes(); // reproduce a dropped poke
lunora.failWrites("CONFLICT"); // reproduce a rejected mutationWhen something is stuck
client.debug() answers the questions that are otherwise unanswerable from outside
the client — is the socket open, what watermark has the server confirmed for this
shard, has this subscription been acked, is anything queued:
const { shards, subscriptions } = client.debug();And lunora doctor checks the project-level things: the SHARD binding, .dev.vars
placeholders, and whether your @lunora/* versions are a coherent set (they are
versioned independently, so a partial update is easy to miss).