Skip to content

Schema

Synced-Store uses a schema-first approach for type-safe data access. Schemas accept any Standard Schema-compliant library (Zod, Valibot, ArkType, etc.) or hand-written JSON Schema via the jsonSchema utility. The schema also drives LLM tool generation and MCP integration — mutator and action description and input fields are used to automatically produce callable tools for AI models.

No runtime validation

Schemas are used for compile-time type inference only — Synced-Store does not call .parse() or validate data at runtime. This means you can use lightweight schema libraries or plain jsonSchema<T>() without any runtime cost.

Complete Example

typescript
import { z } from "zod";
import { defineSchema, table, singletonTable, item } from "@synced-store/shared";
import { defineMigration } from "@synced-store/backend";

// v1 mutators (needed by the migration)
const v1Mutators = {
  addTodo: {
    description: "Add a new todo",
    input: z.object({ id: z.string(), title: z.string() }),
  },
};

// Migration from v1 → v2: adds the `priority` field
const migration1to2 = defineMigration(v1Mutators, {
  addTodo: {
    description: "Add a new todo",
    input: z.object({ id: z.string(), title: z.string(), priority: z.number() }),
  },
}, {
  migrateData: async (ctx) => {
    const items = await ctx.table("todos").scan().entries().toArray();
    for (const [key, value] of items) {
      await ctx.table("todos").set(key.itemKey, { ...value, priority: 0 });
    }
  },
  migratePendingMutation: {
    addTodo: (args, emit) => {
      emit("addTodo", { ...args, priority: 0 });
    },
  },
});

const todoSchema = defineSchema({
  schemaVersion: 2,
  tables: {
    // table(): All keys return the same type (collections)
    todos: {
      schema: table(
        z.object({
          title: z.string(),
          completed: z.boolean(),
          createdAt: z.number(),
          priority: z.number(),
        }),
      ),
      // Optional: enable search indexing for this table
      searchable: { textField: "title" },
    },

    // singletonTable(): Each key has a specific type (settings/metadata)
    metadata: {
      schema: singletonTable(
        item("totalCount", z.number()),
        item("lastUpdate", z.number()),
      ),
    },
  },
  mutators: {
    addTodo: {
      description: "Add a new todo",
      input: z.object({
        id: z.string(),
        title: z.string(),
        priority: z.number(),
      }),
    },
    setCompleted: {
      description: "Set todo completion",
      input: z.object({
        id: z.string(),
        completed: z.boolean(),
      }),
    },
  },
  actions: {
    analyzeTodos: {
      description: "Analyze todos with AI",
      input: z.object({}),
      output: z.object({ insights: z.array(z.string()) }),
    },
  },
  migrations: {
    "1to2": migration1to2,
  },
});

Table Types

table() — Homogeneous Collections

Use for collections where all values have the same type:

typescript
const schemas = {
  users: table(z.object({ name: z.string(), email: z.string() })),
  counters: table(z.number()),
} as const;

// Any string key is valid
await ctx.table("users").get("user-123"); // { name, email } | undefined
await ctx.table("counters").get("total"); // number | undefined

singletonTable() — Typed Key-Value Pairs

Use for settings/metadata where each key has a specific type:

typescript
const schemas = {
  settings: singletonTable(
    item("theme", z.enum(["light", "dark"])),
    item("count", z.number()),
  ),
} as const;

await ctx.table("settings").get("theme"); // "light" | "dark" | undefined
await ctx.table("settings").get("invalid"); // TYPE ERROR

When to Use Each

Use CaseType
Collections (users, messages, todos)table()
Fixed keys with different types (settings)singletonTable()
Dynamic/user-generated keystable()
Application configsingletonTable()

Schema Versioning

Increment schemaVersion when you change your schema. Both server and client must agree on the version.

The Constant File Pattern

Client configs use type-only schema imports to avoid bundling schema libraries, so the schema object's runtime schemaVersion is not accessible on the client. To share the version between server and client, create a separate app-schema-version.ts file with a plain numeric constant:

my-store/
  app-schema-version.ts   ← Plain constant (no schema library import)
  schema.ts               ← Imports constant for defineSchema()
  client-config.ts        ← Imports constant for defineClientConfig()

1. Define the constant (app-schema-version.ts):

typescript
export const MY_APP_SCHEMA_VERSION = 1;

2. Use in the schema (schema.ts):

typescript
import { MY_APP_SCHEMA_VERSION } from "./app-schema-version";

export const myStoreSchema = defineSchema({
  schemaVersion: MY_APP_SCHEMA_VERSION,
  tables: { /* ... */ },
});

3. Use in the client config (client-config.ts):

typescript
import { MY_APP_SCHEMA_VERSION } from "./app-schema-version";

export const myStoreClientConfig = defineClientConfig<typeof myStoreSchema>({
  mutators: myStoreMutatorHandlers,
  schemaVersion: MY_APP_SCHEMA_VERSION,
});

When to Bump

Increment schemaVersion when you change your schema. When version mismatches:

  1. Client detects mismatch (via $$system/app_schema_info in pull responses)
  2. Client clears local data
  3. Client notifies via onSchemaVersionMismatch callback and disposes

You can handle the mismatch event on the client:

typescript
const client = new SyncedStoreClient({
  schemaVersion: MY_APP_SCHEMA_VERSION,
  onSchemaVersionMismatch: ({ serverVersion, clientVersion }) => {
    console.log(`Schema updated: v${clientVersion} → v${serverVersion}`);
  },
  // ...
});

Migrations

When you bump schemaVersion, you must provide migrations for every version step. Migrations are required for schema versions above 1 and are passed to defineSchema() via the migrations field.

A migration has two parts:

  • migrateData — transforms existing database contents on upgrade (runs once at server startup)
  • migratePendingMutation — transforms in-flight mutations from old clients (runs per-mutation on push)

Defining a Migration

Use defineMigration() for type-safe migrations between schema versions:

typescript
import { defineMigration } from "@synced-store/backend";

const migration1to2 = defineMigration(v1Mutators, v2Mutators, {
  // Transform existing data
  migrateData: async (ctx) => {
    const items = await ctx.table("todos").scan().entries().toArray();
    for (const [key, value] of items) {
      await ctx.table("todos").set(key.itemKey, {
        ...value,
        createdAt: Date.now(),
      });
    }
  },

  // Transform pending mutations from v1 clients
  migratePendingMutation: {
    addTodo: (args, emit) => {
      emit("addTodo", { ...args, createdAt: Date.now() });
    },
  },
});

Mutation Migration Options

Each mutation handler in migratePendingMutation can:

  • Transform args — emit the same mutation with modified args
  • Rename — emit a different mutation name
  • Drop — don't call emit (mutation is discarded)
  • Expand — call emit multiple times (one mutation becomes many)

Registering Migrations

Pass migrations to defineSchema() keyed by version transition:

typescript
export const mySchema = defineSchema({
  schemaVersion: 3,
  tables: { /* ... */ },
  mutators: { /* ... */ },
  migrations: {
    "1to2": migration1to2,
    "2to3": migration2to3,
  },
});

All migrations in the chain must be present — if schemaVersion is 3, both 1to2 and 2to3 are required. A missing migration throws MissingMigrationError at startup.

Schema Libraries

Synced-Store accepts any Standard Schema-compliant library. All type inference is compile-time only — no .parse() calls happen at runtime.

Zod

typescript
import { z } from "zod";

const schema = defineSchema({
  schemaVersion: 1,
  tables: {
    users: { schema: table(z.object({ name: z.string(), age: z.number() })) },
  },
  mutators: {
    addUser: {
      description: "Add a user",
      input: z.object({ id: z.string(), name: z.string(), age: z.number() }),
    },
  },
});

Valibot

typescript
import * as v from "valibot";

const schema = defineSchema({
  schemaVersion: 1,
  tables: {
    users: { schema: table(v.object({ name: v.string(), age: v.number() })) },
  },
  mutators: {
    addUser: {
      description: "Add a user",
      input: v.object({ id: v.string(), name: v.string(), age: v.number() }),
    },
  },
});

Hand-written JSON Schema

Use the jsonSchema<T>() utility to get type inference from a plain JSON Schema object. This requires no schema library at all — jsonSchema only provides the TypeScript type annotation:

typescript
import { defineSchema, table, jsonSchema } from "@synced-store/shared";

const schema = defineSchema({
  schemaVersion: 1,
  tables: {
    users: {
      schema: table(
        jsonSchema<{ name: string; age: number }>({
          type: "object",
          properties: {
            name: { type: "string" },
            age: { type: "number" },
          },
          required: ["name", "age"],
        }),
      ),
    },
  },
  mutators: { /* ... */ },
});