Skip to content
DocsconceptsDocumentation

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.

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 validators

A 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, because 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 mutation

When 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).