@doync/client
v0.4.0
Published
doync client engine: optimistic Layer-1 savepoint/rebase over a synchronous local-DB port
Maintainers
Readme
@doync/client
Building an app? You almost certainly want one of the platform / framework packages, not this one:
@doync/web— browser client (SharedWorker + DB worker + OPFS)@doync/mobile— single-process React Native client@doync/react—DoyncProvider,useQuery,useMutation, and friends
Those packages own the host seams (workers, sockets, native SQLite) and hand your UI a ready DoyncClient. This package is the client engine they wrap: View handles, the optimistic Layer 1 (savepoint/rebase), the subscription desire map, mutate/push, schema events, reconnect coordination. App code should not import it directly unless you are writing a custom adapter.
The package splits its public surface by audience (same lockstep semver — the distinction is not stability):
| Entry | Audience | Contents |
| --- | --- | --- |
| @doync/client (.) | Apps via platform/framework packages | App-facing call-surface types only — nothing callable |
| @doync/client/adapter | Third-party adapter authors | Host seams, reconnect coordinator, createClient |
| @doync/client/internal | Sibling @doync/* packages | Engine construction; may break anytime |
What this package is
@doync/client is the engine half of every doync client:
DoyncClient+ View handles — the call surface adapters wrap (subscribe/once/local/mutate, connection + schema status, preload, warmup); shared reactive reads withretain/releaseownership.- OnceView — per-call cache-and-network reads.
- Mutation invocation types —
MutationResult/MutationOptions; definitions themselves come from@doync/coreand are the registry entries on both ends. - Host seams + reconnect +
createClient— on@doync/client/adapter; environment packages implementLocalDb/SyncSocketand construct clients there.
Schema, queries, and mutations themselves are defined in @doync/core. The concrete engine class and construction helpers live under @doync/client/internal.
What's on the main entry
The main entry exports types only — the symbols apps meet through @doync/web / @doync/mobile / @doync/react. Nothing callable remains here (createClient moved to /adapter).
| Symbol | Role |
| --- | --- |
| DoyncClient | The call surface adapters wrap |
| View / OnceView / ViewStatus / QueryStatus | Reactive + once handle types |
| SubscribeOptions / PreloadHandle / PreloadOptions / WarmupHandle / WarmupOptions / FalsyQuery | Subscribe / preload / warmup options |
| MutationResult / MutationOptions | mutate() return + options |
| ConnectionStatus / SchemaEvent / SchemaEventKind | Connection + schema status |
| LogoutBehavior | 'keep' \| 'forget' durable logout policy (here only — /adapter references it, no double-listing) |
Writing a host / framework adapter
Host adapters (@doync/web, @doync/mobile, a community Electron or Node one) implement the engine's ports and construct a client. Framework adapters (@doync/react, a community Svelte/Vue/Solid one) are a thin layer over the DoyncClient call surface.
Import seams, reconnect, and construction from @doync/client/adapter. App-facing types stay on @doync/client.
import type { DoyncClient, View, OnceView, LogoutBehavior } from '@doync/client'
import {
createClient,
createReconnectingSocket,
type CreateClientOptions,
type LocalDb,
type LocalRow,
type SyncSocket,
type SyncSocketHandlers,
type SeamStatus,
type ReconnectSocket,
type ReconnectingSocket,
type ReconnectingSocketConfig,
type SocketCloseInfo,
} from '@doync/client/adapter'Our own platform packages use createClientEngine from /internal rather than createClient — the latter is the construction entry a third-party adapter author reaches for.
The surface adapters wrap
import type { DoyncClient, View, OnceView } from '@doync/client'
interface DoyncClient {
// Bound form only: pass queries.issues.open(args), or falsy to skip.
subscribe(bound | FalsyQuery, options?): View // reactive; SHARED
local(sql, ...params): View // reactive; SHARED
once(bound | FalsyQuery): OnceView // cache-and-network; PER-CALL
mutate(mutation, args, options?): MutationResult // { client, server }
preload(bound | FalsyQuery, options?): PreloadHandle
warmup(bound | FalsyQuery, options?): WarmupHandle // held ahead of a mount
readonly userId: string | null
readonly connectionStatus: ConnectionStatus
onConnectionChange(listener): () => void
readonly schemaStatus: SchemaEvent | null
onSchemaChange(listener): () => void
}Lifecycle for framework adapters lives here: the adapter mints handles, retains for a mount, releases on unmount. Everything else — sharing, the Warm pool, wire frames, once's deferred dispose — is the client's job.
The rule: render may compute, only commit may own. Creation is pure: a handle you never retain owns nothing and is GC'd like any object. retain() is the ownership act; release() is its cleanup.
Query-taking methods accept a Bound query — queries.issues.open(args) — or falsy (false | null | undefined) to mean "no query". Binding is pure; validation and resolve happen inside the engine. options.skip remains supported.
Host seams on /adapter
import type {
LocalDb,
LocalRow,
SyncSocket,
SyncSocketHandlers,
SeamStatus,
} from '@doync/client/adapter'
// LocalDb — synchronous local-DB port (wa-sqlite / op-sqlite / node:sqlite)
// SyncSocket — Mirror socket seam the engine drives (send / setHandlers / reconnect)
// SeamStatus — 'connecting' | 'error' channel for truthful connectionStatusReconnect coordinator on /adapter
import {
createReconnectingSocket,
type ReconnectSocket,
type ReconnectingSocket,
type ReconnectingSocketConfig,
type SocketCloseInfo,
} from '@doync/client/adapter'Shared reconnect machinery (backoff, buffer-until-open outbox, re-handshake hook, keepalive). Wrap a transport-shaped ReconnectSocket and hand the resulting transcript to the engine as a SyncSocket.
createClient on /adapter
import { createClient, type CreateClientOptions } from '@doync/client/adapter'
import type { DoyncClient, LogoutBehavior } from '@doync/client'
const client: DoyncClient = createClient({
db, // LocalDb
socket, // SyncSocket
schema,
mutations,
token,
ctx,
userId,
logoutBehavior, // optional LogoutBehavior ('keep' | 'forget')
})View — shared handle
interface View<Row> {
current(): readonly Row[] // last decoded rows; STABLE until they move
status(): ViewStatus // { status: 'unknown' | 'complete' | 'error', error? }
onChange(listener): () => void
readonly one?: boolean // runtime one-ness (set when known)
retain(): void // commit/mount — the only ownership act
release(): void // cleanup — idempotent; last release parks Warm pool
}Lifecycle:
- Create in your setup/state slot. Pure: resolve, local SQL, decode. A discarded setup result owns nothing.
retain()at mount / after the host commits the component. Never during render.release()at unmount / cleanup. The last release of a key fires the wireunsubscribeimmediately (the Warm pool is a client paint budget, not a server cost) and parks the read for one policy tick.- Key change → create the new handle, retain it, release the old.
current() stays stale-but-stable through any backing teardown (Warm-pool window, remount, server error status): it keeps answering the last snapshot until a new recompute notifies. Server rejection is status-only — last rows keep serving with status: 'error' alongside; the component decides.
Sharing is per statement identity at the engine desire map. Decode and one-ness are per-handle. Don't re-count, don't re-decode for sharing, don't invent a second store above DoyncClient.
OnceView — commit-owned, not shared
interface OnceView<Row> {
current(): readonly Row[]
onChange(listener): () => void
dispose(): void // deferred one Warm-pool tick; re-use cancels
readonly server: Promise<readonly Row[]> // starts the network half
}once() is per-call: promise identity, no sharing, no Warm pool of rows. Create in setup; the network half starts on first onChange / server access (so a discarded render never dials). Cleanup calls dispose(). Cache-phase rows paint immediately (stale-but-stable).
mutate
const { client, server } = doync.mutate(mutations.issues.create, args)
await client // optimistic body applied on the local replica
await server // Origin ack (or reject → rebase)Pass the registered mutation definition object and its args. The engine resolves the body from its own registry; the object is a typed reference only.
connectionStatus — five states
| state | meaning |
| -------------- | ------------------------------------------------------ |
| connecting | a (re)connect attempt is dialing or backing off |
| connected | the socket is open — sync is live |
| disconnected | dropped and backing off (offline) |
| error | a transport-level error |
| needs-auth | the Mirror rejected auth — a token refresh is required |
Subscribe with onConnectionChange and read connectionStatus. Never throw on a status — a degraded answer beats a crash.
Schema events
schemaStatus (SchemaEvent | null) covers disruption classes. Subscribe with onSchemaChange (fires on a new state and on a silent clear) and re-read schemaStatus. Kinds: reload (terminal — reload the app), server-behind (transient), resync / forget (transient recovery notices — surface as dismissible banners so a heal never looks like silent data loss).
/adapter surface
| Symbol | Role |
| --- | --- |
| createClient / CreateClientOptions | Construct a DoyncClient over a LocalDb + SyncSocket |
| LocalDb / LocalRow | Synchronous local-DB port |
| SyncSocket / SyncSocketHandlers / SeamStatus | Mirror socket seam |
| createReconnectingSocket (+ reconnect types) | Shared reconnect coordinator |
LogoutBehavior is not re-exported here — import it from @doync/client.
