Appearance
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 | undefinedsingletonTable() — 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 ERRORWhen to Use Each
| Use Case | Type |
|---|---|
| Collections (users, messages, todos) | table() |
| Fixed keys with different types (settings) | singletonTable() |
| Dynamic/user-generated keys | table() |
| Application config | singletonTable() |
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:
- Client detects mismatch (via
$$system/app_schema_infoin pull responses) - Client clears local data
- Client notifies via
onSchemaVersionMismatchcallback 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
emitmultiple 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: { /* ... */ },
});