@doync/core
v0.4.0
Published
doync shared types and synced-schema definition
Maintainers
Readme
@doync/core
Shared types and the synced-schema definition layer for doync. This is the isomorphic data layer: one module of tables, migrations, queries, and mutations that every client (web / mobile) and the server (Origin / Mirror) consume. It does not open sockets, hold SQLite storage, or talk to Durable Objects — those live in @doync/web, @doync/mobile, and @doync/server.
Install
pnpm add @doync/coreOptional peers you will usually add next:
pnpm add @doync/web @doync/react # browser
# or
pnpm add @doync/mobile @doync/react # React Native
pnpm add @doync/server # Cloudflare Worker + Durable Objects
pnpm add zod # or any standard-schema validatorThe Drizzle flavor of the same definition API lives in @doync/drizzle (createDoyncDrizzle → bound defineQuery / defineMutation). This README documents the plain @doync/core SQL surface.
Define once, share everywhere
Author one module through createDoync — schema, queries, mutations — and import it from every process that participates in the same Database name:
// shared/data.ts — imported by web, mobile, and the Worker
import { createDoync } from '@doync/core'
import { z } from 'zod'
export const ctxValidationSchema = z.object({
userId: z.string(),
role: z.string(),
})
export const {
schema,
defineQuery,
defineMutation,
defineQueries,
defineMutations,
} = createDoync({
schema: {
migrations: [
{
id: '0001_init',
sql: `
CREATE TABLE task (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
owner_id TEXT NOT NULL
);
CREATE INDEX task_owner ON task (owner_id);
`,
},
],
tables: [
{
name: 'task',
columns: [
{ name: 'id', type: 'text', pk: true, notNull: true },
{ name: 'title', type: 'text', notNull: true },
{ name: 'done', type: 'integer', notNull: true },
{ name: 'owner_id', type: 'text', notNull: true },
],
},
],
},
ctxValidationSchema,
})
export const queries = defineQueries({
tasks: {
all: defineQuery(({ sql }) => sql`SELECT * FROM task ORDER BY id`),
byOwner: defineQuery(
z.object({ ownerId: z.string() }),
({ args, sql }) =>
sql`SELECT * FROM task WHERE owner_id = ${args.ownerId} ORDER BY id`,
),
mine: defineQuery(({ ctx, sql }) => {
if (ctx === null) return sql`SELECT * FROM task WHERE 0`
return sql`SELECT * FROM task WHERE owner_id = ${ctx.userId} ORDER BY id`
}),
},
})
export const mutations = defineMutations({
tasks: {
create: defineMutation(
z.object({ id: z.string(), title: z.string(), ownerId: z.string() }),
({ args, sql }) => {
sql.exec(
'INSERT INTO task (id, title, owner_id) VALUES (?, ?, ?)',
args.id,
args.title,
args.ownerId,
)
},
),
complete: defineMutation(z.object({ id: z.string() }), ({ args, sql }) => {
sql.exec('UPDATE task SET done = 1 WHERE id = ?', args.id)
}),
},
})| Consumer | Imports |
| ------------------- | -------------------------------- |
| Web / mobile client | schema, queries, mutations |
| Origin DO | schema, mutations |
| Mirror DO | schema, queries |
Clients call registered query leaves to produce a Bound query (queries.tasks.byOwner({ ownerId })) and pass that into client.subscribe / useQuery. The wire protocol never carries client SQL — only a name + args the Mirror resolves under the verified auth context.
Schema — DoyncSchema
import type { DoyncSchema, SchemaMigration, TableSchema } from '@doync/core'Pass a plain DoyncSchema object into createDoync({ schema, ctxValidationSchema }) (or import one emitted by @doync/schema-codegen). The factory validates it. Capture triggers, replication, and convergence checks are derived from this single definition.
migrations— append-only history from empty DB → current shape. Order is the schema version; entries are never edited or reordered after ship. EachSchemaMigrationis{ id, sql }.sqlmay freely mix DDL and backfill DML; the engine runs the whole migration as one Schema commit (DDL recorded for replay, backfill captured as ordinary row images).tables— the current shape for capture/apply: eachTableSchemais{ name, columns }wherecolumnslists every column (name,typeaffinity, optionalpk/notNull). The primary key is the pk-flagged columns in declaration order. Secondary indexes, triggers, FKs, and CHECKs are ordinary migration DDL — not fields onTableSchema. Table / column names must be plain SQL identifiers; the__doync_prefix is reserved.
const schema = {
migrations: [
{ id: '0001_init', sql: `CREATE TABLE task (…);` },
{
id: '0002_priority',
sql: `
ALTER TABLE task ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
UPDATE task SET priority = 2 WHERE done = 0;
`,
},
],
tables: [
{
name: 'task',
columns: [
{ name: 'id', type: 'text', pk: true, notNull: true },
{ name: 'title', type: 'text', notNull: true },
{ name: 'done', type: 'integer', notNull: true },
{ name: 'priority', type: 'integer', notNull: true },
],
},
],
} satisfies DoyncSchemaNumber precision
Numbers are float64; anything occupying SQLite's INTEGER class beyond ±(2^53−1) is rejected loudly everywhere; finite REALs of any size are fine; use TEXT or BLOB keys for snowflake-sized ids.
Queries — defineQuery / defineQueries / sql
import {
sql,
type BoundQuery,
type QueryDefinition,
type QueryTree,
type RegisteredQuery,
type ResolvedQuery,
type ResolvedStatement,
type SqlTag,
} from '@doync/core'defineQuery(argsValidationSchema?, ({ args, ctx, sql }) => statement)
- Optional args validation schema is any Standard Schema validator; args are validated at the wire boundary.
ctxisAuthContext(null⇔ anonymous).- The callback is deterministic and pure with respect to the database state it reads through the compiled statement — same
args+ctx⇒ same statement on client and Mirror. - Return a
ResolvedStatementvia the injectedsqltag.
const openTasks = defineQuery(
({ sql }) => sql`SELECT * FROM task WHERE done = ${0} ORDER BY id`,
)
const byId = defineQuery(
z.object({ id: z.string() }),
({ args, sql }) => sql.one`SELECT * FROM task WHERE id = ${args.id}`,
)sql interpolations become bound ? parameters — never string-spliced. sql.one`…` marks a one-row query (one: true is presentation metadata; it does not change Query-instance identity). Type-level one-ness flows into useQuery as [Row | undefined, status] vs a row array. One-ness is static per definition: a resolver must use sql.one on every return path or on none — mixing the two forms is a compile error.
Filters derived from ctx become part of the statement identity: different roles resolve to different Query instances — structural row-level security.
defineQueries(tree) — nested registry, Bound queries
const queries = defineQueries({
tasks: {
open: openTasks,
byId,
},
})
// Call surface for clients:
const bound: BoundQuery = queries.tasks.byId({ id: 't1' })
// bound.kind === 'bound-query'
// bound.query.name === 'tasks.byId'defineQueries stamps each leaf with its dotted name (tasks.byId) and turns it into a RegisteredQuery callable. Binding is pure (no validation / resolve at call time) so it is safe during render. Public client surfaces accept a BoundQuery only.
Pass the tree to every surface that needs it (createWorker, DoyncMirrorConfig.queries, …).
Mutations — defineMutation / defineMutations
import type {
MutationBody,
MutationDefinition,
MutationExec,
MutationTree,
} from '@doync/core'defineMutation(argsValidationSchema?, ({ args, ctx, sql }) => void | Promise<void>)
- Shared bodies are deterministic and DB-only. They may be
async(e.g.crypto.subtle.digestover args), but any external I/O that would re-fire on rebase belongs in a server override. sqlis aMutationExec:exec<T>(query, …params): T[]— run one parameterized statement and read its rows back. Read-then-decide (asserts, branching) runs identically optimistic on the client Replica and authoritative at the Origin.- Ids and timestamps must be client-generated and passed through
args— the rebase replays the body on every poke.
const create = defineMutation(
z.object({ id: z.string(), title: z.string() }),
({ args, ctx, sql }) => {
if (ctx === null) throw new Error('auth required')
sql.exec(
'INSERT INTO task (id, title, owner_id) VALUES (?, ?, ?)',
args.id,
args.title,
ctx.userId,
)
},
)defineMutations(shared, serverOverrides?) — shared tree + server-only overrides
Compose a shared (client-safe) mutation tree with optional server-only replacements for the same dotted names — external I/O and secrets stay out of the client bundle (the prior-art shape popularized by Zero’s .server.ts split).
// shared/mutations.ts — imported by clients AND the server
export const sharedMutations = defineMutations({
tasks: { create, complete },
})
// server/mutations.ts — Worker-only entry
import { defineMutation, defineMutations } from '../shared/data'
import { sharedMutations } from '../shared/mutations'
export const mutations = defineMutations(sharedMutations, {
tasks: {
// Same dotted name ⇒ replaces the shared body on the server only.
create: defineMutation(createArgs, async (args, ctx, tx) => {
await notifySlack(args) // external I/O OK here
// …authoritative write…
}),
},
})- Client entry:
defineMutations(shared)only — override modules never enter the client bundle. - Server entry:
defineMutations(shared, overrides)— each override replaces a same-named shared body. An override naming a mutation the shared tree does not hold is a loud config error (a server-only mutation has no optimistic counterpart).
Pass the resulting tree to every surface that needs it (createClient / createWorker, DoyncOriginConfig.mutations, …). Each consumer flattens (and the Origin ports the wire-boundary runners) internally; there is no consumer-facing flatten step.
Args value-space
Args leaf types are null | boolean | number | string | ArrayBuffer (TypedArray / DataView accepted at the boundary and canonicalized to ArrayBuffer), nested in plain arrays / objects.
- The
__doync_blobkey is reserved — present in consumer data, validation rejects (never rewrites). - Number rule: see Schema above.
- Encoding runs at the client boundary; decoding runs server-side before standard-schema validation so resolvers always see live
ArrayBuffers. - Booleans also bind directly in SQL —
sql`… WHERE done = ${args.done}`andsql.exec('…', args.done)canonicalizetrue/falseto1/0before the statement is built (SQLite has no boolean affinity; rows read back as0/1).
Types you will import
| Symbol | Role |
| --- | --- |
| createDoync, CreateDoyncOptions, CreateDoyncResult | Factory front door |
| AuthContext, CtxValidationSchema, AuthData | Auth-context shape, its Standard Schema type, and the client identity |
| AuthData | Client identity { userId, token?, ctx } |
| DoyncSchema, SchemaMigration, TableSchema | Schema shape |
| QueryDefinition, QueryCallbackInput, QueryResolver, QueryTree | Query authoring |
| RegisteredQuery, RegisteredQueryCallable, RegisteredQueryTree, BoundQuery | Registration / client call surface |
| ResolvedStatement, ResolvedQuery, SqlTag, RowDecoder | Statement / identity pieces |
| MutationDefinition, MutationTree, MutationBody, MutationExec | Mutation authoring |
| SqlRow, SqlValue, SqlBindable | Row / cell types on the SQL surface |
Internal surface
The main entry (. / @doync/core) is the semver-governed public API this README documents.
Anything imported from @doync/core/internal may change in any release, including patches, with no notice. Sibling @doync/* packages use that subpath; application code should not.
