@zykeco/sync-client
v0.12.0
Published
Tiny, fetch-based client for pushing data to a Zyke sync server.
Readme
@zykeco/sync-client
Tiny, dependency-light TypeScript client for pushing time-series health data to a Zyke sync server. Built on the standard fetch API — works in Node 20+, modern browsers, React Native (with a fetch polyfill), Bun, Deno, and edge runtimes.
- Pure ESM, fully typed
- No runtime dependencies apart from
@zykeco/sync-protocol(TypeBox schemas) - One method:
client.sync({ url, secret, data?, deletions? }) - Handshake + auth are short-circuited between calls when nothing changed
- Concurrent
sync()calls are serialized automatically - Deletions run first; per-collection requests within each phase fan out in parallel
Install
npm install @zykeco/sync-client @sinclair/typebox@sinclair/typebox is a peer dependency so you can keep a single copy in your bundle.
Quick start
import { createSyncClient, SyncClientError } from '@zykeco/sync-client';
const client = createSyncClient();
try {
const result = await client.sync({
url: 'https://sync.example.com',
secret: process.env.SYNC_SECRET!,
data: {
daily_metrics: [
{
id: 172,
updatedAt: Date.now(),
payloadVersion: 1,
payload: { isoDate: '2026-05-18', metricKey: 'steps', value: 8421 },
},
],
},
// Hard-delete by id, runs before upserts. Missing ids are silent successes.
deletions: {
daily_metrics: [171, 178],
},
});
console.log(result.stored.daily_metrics); // [172]
console.log(result.deleted.daily_metrics); // [171, 178]
console.log(result.serverNowMs); // server clock at ack time
} catch (err) {
if (err instanceof SyncClientError) {
console.error(err.code, err.namespace, err.status, err.message);
} else {
throw err;
}
}API
createSyncClient(options?): SyncClient
Returns a stateful client. Reuse the same instance across calls — it remembers the last verified url and secret and skips the handshake/auth round-trips when they haven't changed.
| Option | Type | Default | Description |
| ------- | -------------- | ------------------ | ---------------------------------------------------------------------- |
| fetch | FetchAdapter | globalThis.fetch | Inject a custom fetch (testing, retries, telemetry, proxying, etc.). |
client.sync(options): Promise<PushResult>
| Option | Type | Required | Description |
| ------------ | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------- |
| url | string | no* | Base URL of the sync server. Trailing slashes are normalized away. Optional when set via setDefaultOptions. |
| secret | string | no* | Write-secret configured on the sync server. Optional when set via setDefaultOptions. |
| data | SyncData | no | Records to upsert, keyed by collection (see below). |
| deletions | SyncDeletions | no | Per-collection ids to hard-delete. Runs before upserts. Missing ids are silent successes. |
| onProgress | (event: SyncProgressEvent) => void | no | Receives lifecycle events: verifying, authenticating, deleting, deleted, pushing, pushed, done. |
token is reserved for a future SaaS auth flow and currently rejected.
SyncData / RecordWrite
Storage is schemaless: every entity is a generic envelope keyed by collection. The domain
shape lives inside payload, so the app can evolve fields without a server release.
type SyncData = Record<string, RecordWrite[]>; // keyed by collection name
interface RecordWrite {
id: number; // the local integer PK; stable & unique per (collection, id)
updatedAt: number; // epoch ms, used as last-write-wins clock
payloadVersion: number; // starts at 1
payload: Record<string, unknown>; // the domain object — owned by the client
attachment?: Record<string, unknown> | null; // optional heavy blob (e.g. activity HR)
}RecordWrite is re-exported from @zykeco/sync-protocol. Empty arrays (or omitted collections)
are skipped — no network call is made for them. Collection names: daily_metrics,
weekly_metrics, sleep_sessions, user_timezones, user_profile, daily_stress_burden,
heart_rate_minute, hr_zone_history, activities.
await client.sync({
url,
secret,
data: {
daily_metrics: [
{
id: 41,
updatedAt: Date.now(),
payloadVersion: 1,
payload: { isoDate: '2026-05-17', metricKey: 'rhr', value: 58 },
},
],
activities: [
{
id: 700,
updatedAt: Date.now(),
payloadVersion: 1,
payload: { type: 'run', source: 'watch', startedAtUtc: 1747459200000 },
attachment: { heartRate: [120, 130, 140] }, // kept out of list reads
},
],
},
});The server applies last-write-wins: an incoming row with updatedAt <= stored.updatedAt is silently ignored. Always send the complete payload — the server replaces the whole record (no field-level merge).
SyncDeletions
type SyncDeletions = Record<string, number[]>; // collection → ids to hard-deletePer-collection ids to hard-delete. Deletions are sent before upserts and are silently idempotent — deleting a non-existent id is not an error.
PushResult
interface PushResult {
stored: Record<string, number[]>; // collection → ids accepted
deleted: Record<string, number[]>; // collection → ids deleted
serverNowMs: number;
}Only collections that were pushed/deleted appear in the maps. stored[collection] and deleted[collection] are the ids the server accepted (idempotent — duplicates are deduped).
client.setDefaultOptions(options): Promise<void>
Pin defaults for url (and optionally secret/token/onProgress) so subsequent sync() / wipe() calls can omit them. Validates the inputs immediately by running the full handshake (GET /v1/health + credential probe) — throws SyncClientError if either fails, and only commits the defaults on success.
| Option | Type | Required | Description |
| ------------ | ------------------------------------ | -------- | --------------------------------------------------------- |
| url | string | yes | Base URL of the sync server. |
| secret | string | yes* | Write-secret. token is reserved and currently rejected. |
| token | string | no | Reserved for a future SaaS auth flow. |
| onProgress | (event: SyncProgressEvent) => void | no | Default progress callback for subsequent calls. |
* secret is effectively required today; token-only auth is reserved.
Call-site options on sync() / wipe() override the pinned defaults.
client.defaults exposes { url: string | null } (credentials are intentionally not exposed).
const client = createSyncClient();
await client.setDefaultOptions({
url: 'https://sync.example.com',
secret: process.env.SYNC_SECRET!,
});
// url + secret now implicit
await client.sync({ data: { daily_metrics: [...] } });
await client.wipe();client.wipe(options): Promise<WipeResult>
Destructive. Deletes every record across all collections on the server. Requires the write secret. The client sends the protocol-defined confirmation token ("wipe-all-data") in the body so that accidental requests via curl/fetch are rejected by the server.
| Option | Type | Required | Description |
| ------------ | ------------------------------------ | -------- | -------------------------------------- |
| url | string | yes | Base URL of the sync server. |
| secret | string | yes | Write-secret configured on the server. |
| onProgress | (event: SyncProgressEvent) => void | no | Emits wiping / wiped phases. |
const { deleted, serverNowMs } = await client.wipe({
url: 'https://sync.example.com',
secret: process.env.SYNC_SECRET!,
});
// deleted === 6 (total records removed across all collections)client.sync() and client.wipe() share the same serialization queue, so a wipe issued while a sync is in flight waits for the sync to settle (and vice versa).
client.state
Readonly snapshot: 'idle' | 'verifying' | 'authenticating' | 'syncing' | 'ready' | 'error'. Useful for UI indicators.
SyncClientError
Thrown for every failure mode. Inspect error.code:
| Code | Meaning |
| -------------------- | ------------------------------------------------------------------------ |
| server_unreachable | GET /v1/health didn't return { ok: true }. URL wrong or server down. |
| unauthorized | Missing/invalid secret, or HTTP 401. |
| bad_request | HTTP 400 — payload rejected by the server. |
| server_error | HTTP 5xx. |
| fetch_failed | Network/transport failure or response schema mismatch. |
| aborted | Request was aborted. |
Extra fields where applicable: status (HTTP code), namespace (the collection whose batch failed), cause (original error).
Behaviour details
Handshake short-circuit
The first call to sync() for a new url does GET /v1/health; the first call with a new secret does an auth probe (an empty POST /v1/sync/daily_metrics/batch with { rows: [] } — the server short-circuits, no rows written). Subsequent calls with the same url + secret skip both and go straight to pushing. Switching either resets and re-handshakes.
Concurrency
Calls to sync() are serialized inside the client — if you fire two at once, the second waits for the first to settle (success or failure). A prior failure does not block subsequent calls.
Two-phase request flow
Within one sync() call:
- Deletions phase — one
POST /v1/sync/{collection}/deleteper non-empty collection, fanned out viaPromise.allSettled. If any collection fails, the others still complete; the thrownSyncClientErrorlists the affected collections. If any failed, the upsert phase is skipped. - Upserts phase — one
POST /v1/sync/{collection}/batchper non-empty collection, again fanned out viaPromise.allSettledwith the same failure semantics.
Collections with empty/omitted arrays are skipped — no network call is made for them.
Custom fetch
The fetch adapter has the standard signature (input, init) => Promise<Response>. Use it to add retries, request signing, logging, or to plug in undici / node-fetch on older runtimes.
const client = createSyncClient({
fetch: async (input, init) => {
const res = await fetch(input, init);
if (!res.ok) console.warn('sync request failed', res.status);
return res;
},
});Compatibility
| Runtime | Status |
| --------------- | ----------------------------------------------------------- |
| Node ≥ 20 | ✅ Built-in fetch. |
| Bun / Deno | ✅ |
| Modern browsers | ✅ Same-origin, or CORS-enabled sync server. |
| React Native | ✅ With a fetch implementation that supports AbortSignal. |
| Edge / Workers | ✅ |
License
AGPL-3.0-or-later — see LICENSE.
