Server API

The server entry point (@robelest/convex-embedded/server) provides the core primitives for declaring embedded tables, binding the embedded component, scoping queries with views, and declaring local migration metadata.

import {
  embeddedTable,
  setup,
  localOnly,
  remoteOnly,
  view,
  getTableRegistry,
} from "@robelest/convex-embedded/server";

API layers

LayerUsePurpose
table definitionembeddedTable(...)declares an embedded synced table
synced exportstable.query(...), table.mutation(...), table.resolvebuilds local-first synced functions
execution controllocalOnly(...), remoteOnly(...)forces local-only or remote-only execution
component bindingsetup({ component })binds delta storage / resolve component refs
extrasview.*query scoping utilities

Most common usage

export const tasks = embeddedTable("tasks", {
  title: schema.register(v.string()),
  body: schema.prose(),
  remoteAttachmentId: schema.omit(v.string()),
  done: v.boolean(),
});

export const resolve = tasks.resolve;

export const create = tasks.mutation({
  args: { title: v.string(), body: v.string() },
  handler: async (ctx, args) => await ctx.db.insert("tasks", args),
  remote: async (_ctx, _args, taskId) => {
    await sendTaskCreatedWebhook(taskId);
  },
});

export const list = tasks.query({
  args: {},
  handler: async (ctx) => await ctx.db.query("tasks").collect(),
  resolve: { args: () => ({}) },
});

Use this page to answer:

  • how do I export resolve?
  • how do I mark a field remote-only?
  • when do I use remote:?
  • when do I use localOnly() or remoteOnly()?

embeddedTable(name, shape, options?)

Declares an embedded synced table at schema-definition time. The return value is both a valid TableDefinition (from defineTable) that can be passed directly to defineSchema() and an EmbeddedTableHandle with .mutation(), .query(), and .resolve members for building per-table Convex functions.

function embeddedTable(
  tableName: string,
  shape: Record<string, unknown>,
  options?: {
    version?: number;
    defaults?: Record<string, unknown>;
    migrate?: Record<number, LocalTableMigrationStep>;
  },
): TableDefinition & EmbeddedTableHandle;
ParameterTypeDefaultDescription
tableNamestringrequiredTable name. Must match the key used in defineSchema().
shapeRecord<string, unknown>requiredCRDT descriptors (schema.*) or plain validators (v.*).
options.versionnumber1Current local schema version.
options.defaultsRecord<string, unknown>{}Default values for additive changes.
options.migrateRecord<number, LocalTableMigrationStep>{}Forward-only local document migration steps by version.

Each call registers the table in a module-level registry (read by setup() and getTableRegistry()).

The current alpha model is explicit:

  • localOnly() means local or fail
  • remoteOnly() means remote or fail
  • top-level component refs remote-route by default
  • nested/local component execution is rejected with structured errors

EmbeddedTableHandle

interface EmbeddedTableHandle {
  readonly table: string;
  readonly schema: Definition;
  resolve: RegisteredQuery<"public", DefaultFunctionArgs, any>;
  mutation: EmbeddedMutationBuilder;
  query: EmbeddedQueryBuilder;
}

.mutation(definition)

Wraps a mutation with CRDT delta recording and remote: support.

On remote Convex: runs handler -> remote: block -> records delta. On local embedded: runs handler only.

tasks.mutation({
  args: { title: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db.insert("tasks", {
      title: args.title,
      body: args.body,
      done: false,
    });
  },
  // Optional: additional logic that only runs on the remote server
  remote: async (ctx, args, result) => {
    // e.g. send a notification, update analytics
  },
  replay: {
    version: 2,
    migrate: {
      2: async ({ args, localResult }) => ({
        args: { ...args, priority: "medium" },
        localResult,
      }),
    },
  },
});

The remote: callback receives the mutation context, the original args, and the return value of handler. It only executes when the mutation runs on the remote Convex server (not in the local embedded runtime).

Use remote: when the main mutation should stay local-first but you still need server-only follow-up behavior.

Use replay: when queued offline mutations need forward-only payload upgrades before replay.

.query(definition)

Wraps a query with remote: support and optional resolve metadata.

On remote Convex: runs handler -> remote: block (which can transform the result). On local embedded: runs handler only.

tasks.query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query("tasks").collect();
  },
  // Optional: transform results on the remote server
  remote: async (ctx, args, result) => {
    return result.map((task) => ({ ...task, source: "remote" }));
  },
  // Optional: tell the resolve engine which args to use for this query
  resolve: {
    args: () => ({}),
  },
});

.resolve

An auto-generated Convex query used by the CRDT resolve engine. Tagged with remote metadata for auto-discovery. You typically export it from your module but never call it directly:

// convex/tasks.ts
export const resolve = tasks.resolve;

This export is what allows the sync engine to discover the table’s resolve path.

Example: convex/schema.ts

import { embeddedTable } from "@robelest/convex-embedded/server";
import { schema } from "@robelest/convex-embedded/crdt";
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export const tasks = embeddedTable("tasks", {
  title: schema.register(v.string()),
  body: schema.register(v.string()),
  done: v.boolean(),
});

export default defineSchema({
  tasks,
  analytics: defineTable({ event: v.string() }),
});

Example: convex/tasks.ts

import { tasks } from "./schema";
import { v } from "convex/values";

export const create = tasks.mutation({
  args: { title: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db.insert("tasks", {
      title: args.title,
      body: args.body,
      done: false,
    });
  },
});

export const list = tasks.query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query("tasks").collect();
  },
});

export const resolve = tasks.resolve;

setup(config)

Binds the component reference to all registered embedded tables. Call once per app in a dedicated file (e.g. convex/embedded.ts). This enables CRDT delta recording and resolve queries against the real component.

Function registration does not depend on setup() — the .mutation() and .query() builders use mutationGeneric / queryGeneric from convex/server directly and produce registered functions at definition time.

function setup(config: SetupConfig): void;

SetupConfig

interface SetupConfig {
  component?: {
    public: {
      insertDelta: FunctionReference<"mutation", any>;
      getLatestDelta: FunctionReference<"query", any>;
      getLatestDeltas: FunctionReference<"query", any>;
      cleanup: FunctionReference<"mutation", any>;
    };
  };
}
FieldTypeDescription
componentComponent API referenceThe Convex component reference (components.embedded). Provides access to the CRDT delta storage functions.

Evaluation order between schema.ts and embedded.ts does not matter. If setup() runs before embeddedTable() (e.g. due to ESM evaluation order), the config is stored and applied automatically when tables register.

Example: convex/embedded.ts

import { setup } from "@robelest/convex-embedded/server";
import { components } from "./_generated/api";

setup({ component: components.embedded });

Execution control

Explicit routing helpers for Convex function exports.

import { localOnly, remoteOnly } from "@robelest/convex-embedded/server";

localOnly(fn)

Forces local execution. If the embedded runtime cannot safely execute the target, the call throws instead of silently routing remote.

function localOnly<T>(fn: T): T;

remoteOnly(fn)

Forces remote execution. If the client is offline, calls fail immediately.

function remoteOnly<T>(fn: T): T;
import { remoteOnly } from "@robelest/convex-embedded/server";
import { action } from "./_generated/server";
import { v } from "convex/values";

export const sendEmail = remoteOnly(
  action({
    args: { to: v.string(), subject: v.string() },
    handler: async (_ctx, args) => {
      await sendEmailViaProvider(args.to, args.subject);
    },
  }),
);

view

Query scoping utilities. Views are standalone helpers that compose into your own queries to scope results based on auth state and indexed ownership.

import { view } from "@robelest/convex-embedded/server";

view.public()

No filter. All rows visible to all callers.

function public(): ViewFilter;

view.authenticated()

Requires a valid identity. Returns all rows. Throws if unauthenticated.

function authenticated(): ViewFilter;

view.ownership(options)

Scopes rows through an index where the specified field matches the current user’s ID (identity.subject ?? identity.tokenIdentifier). Unauthenticated callers scope to null, which should yield no rows when the owner field stores non-null auth identifiers.

function ownership(options: { index: string; field: string }): ViewFilter;
ParameterTypeDescription
options.indexstringThe index name used to scope the query.
options.fieldstringThe indexed field name that contains the owner’s user ID.

ViewFilter interface

interface ViewFilter<Ctx = unknown, Q = unknown> {
  apply(ctx: Ctx, query: Q): Q | Promise<Q>;
}

Usage example

import { view } from "@robelest/convex-embedded/server";

export const myTasks = query({
  args: {},
  handler: async (ctx) => {
    return await view
      .ownership({ index: "by_user_id", field: "userId" })
      .apply(ctx, ctx.db.query("tasks"))
      .collect();
  },
});

Automatic local migrations

Local migrations are now driven by:

  1. embeddedTable(..., { version, defaults, migrate }) for document shape
  2. mutation replay metadata for queued offline payloads

These run automatically during embedded client startup before queue hydration and remote sync begin.

The lower-level runMigrations(ctx, config) helper still exists as an advanced API, but it is no longer the primary app-facing workflow.


getTableRegistry()

Returns a read-only snapshot of all tables declared with embeddedTable(). Used by the browser entry point for auto-discovery and internally by setup().

function getTableRegistry(): ReadonlyMap<string, EmbeddedTableHandle>;
const registry = getTableRegistry();
for (const [name, handle] of registry) {
  console.log(`Table: ${name}, version: ${handle.schema.version}`);
}