npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sveltebase/sync

v2.0.1

Published

Local-first sync for Svelte 5. The browser keeps rows in IndexedDB (Dexie), applies writes optimistically, queues them while offline, and stays in sync over one WebSocket. Production uses a Cloudflare Durable Object as the broker; Vite dev uses an in-memo

Readme

@sveltebase/sync

Local-first sync for Svelte 5. The browser keeps rows in IndexedDB (Dexie), applies writes optimistically, queues them while offline, and stays in sync over one WebSocket. Production uses a Cloudflare Durable Object as the broker; Vite dev uses an in-memory one.

Install

bun add @sveltebase/sync

Peer deps: svelte, @sveltejs/kit. Optional: zod for server mutation validation.

Entry points

| Import | What it’s for | | --- | --- | | @sveltebase/sync/client | Client DB, live queries, dynamic clients | | @sveltebase/sync/server | Handlers, definePolicySync, and publish helpers | | @sveltebase/sync/cloudflare | Worker + Durable Object for production | | @sveltebase/sync/sveltekit | Sync route on a SvelteKit endpoint | | @sveltebase/sync/vite | Dev WebSocket plugin | | @sveltebase/sync | Shared errors and types |

How it fits together

browser  →  Worker /api/sync  →  SyncEngine Durable Object
app code →  publish helpers   →  SyncEngine Durable Object

The Worker authenticates the WebSocket, attaches trusted identity/topics, and forwards to the Durable Object. The DO owns subscriptions, mutations, and broadcasts.

In Vite, syncDevPlugin handles the upgrade and runs the same handlers in-process.


Client

Create a database

import { SyncClient } from "@sveltebase/sync/client";

type Todo = {
  id: string;
  title: string;
  completed: boolean;
  updatedAt: number;
};

type AppDatabase = {
  todos: Todo;
};

export const db = new SyncClient<AppDatabase>({
  name: "app-sync",
  url: "/api/sync",
  batchSubscriptions: true,
  tables: {
    todos: {
      indexes: "id, completed, updatedAt",
      channel: "todos",
      updatedAtField: "updatedAt" // default
    }
  }
});
  • name — IndexedDB database name
  • url — absolute ws:// / wss:// or a relative path like /api/sync
  • tables — each table needs Dexie indexes, a server channel, and optionally updatedAtField for delta sync (defaults to updatedAt)
  • batchSubscriptions — opt into concurrent initial channel fetches when the server uses @sveltebase/sync 1.8+; defaults to false for older-server compatibility

The WebSocket opens in the browser only, not during SSR.

Read and write

Tables are normal Dexie tables with sync-aware writes:

await db.todos.toArray();
await db.todos.where("completed").equals(false).toArray();

await db.todos.add({
  title: "Write docs",
  completed: false,
  updatedAt: Date.now()
});

await db.todos.update(todoId, {
  completed: true,
  updatedAt: Date.now()
});

await db.todos.delete(todoId);

What happens on write:

  1. Update IndexedDB immediately (optimistic)
  2. Persist the mutation to a durable outbox (survives refresh)
  3. Send over the socket (or keep in outbox until connected)
  4. On reject — roll back the local change and drop the outbox row
  5. On success — drop the outbox row; optional canonical row replaces the local copy

If the user refreshes before the server acks, the outbox is reloaded on next connect and mutations are re-sent automatically.

Missing server handlers for create/update/delete reject and roll back.

Use UTC milliseconds (Date.now()) for updatedAt. On reconnect the client sends the newest local timestamp as since so the server can return only newer rows.

Connection and activity

db.status;               // "connecting" | "connected" | "disconnected"
db.isSyncing;            // true while uploads or snapshot fetches are in flight
db.pendingMutationCount; // mutations waiting for server ack
db.pendingFetchCount;    // channels waiting for a snapshot
db.reconnect();          // safe: no-op while already connecting (use { force: true } to abort)
await db.whenConnected();
await db.whenIdle();
await db.resyncTables(["roles", "schools"], { reconnect: true });
db.disconnect();         // stop auto-reconnect; local data stays

isSyncing is true when:

  • a write is waiting for ack / reject, or
  • a channel subscribe is waiting for a snapshot (initial load, resync, channel-change)

It can flash briefly for fast ops — that’s expected for a “syncing” indicator.

Unexpected closes retry after ~2 seconds. Heartbeats keep the socket alive.

Force a full resync

await db.resyncTable("todos");
await db.resyncChannel("todos", { reconnect: true });

A full snapshot replaces the local table. Delta snapshots merge by timestamp and don’t delete rows that weren’t returned.

Live queries

import { createLiveQuery } from "@sveltebase/sync/client";

const todos = createLiveQuery(() =>
  db.todos.where("completed").equals(false).toArray()
);

// todos.data, todos.isLoading, todos.error

Dexie changes re-run the query automatically. Pass a second getter when non-Dexie inputs should also re-run it:

const filter = $state("open");

const visible = createLiveQuery(
  () =>
    filter === "open"
      ? db.todos.where("completed").equals(false).toArray()
      : db.todos.toArray(),
  () => [filter]
);

Per-tenant / dynamic clients

createSyncClient does not open a database until context is set. Until then sync.client is undefined and table access throws.

When the DB name or channels depend on context (org, user, …):

import { createSyncClient } from "@sveltebase/sync/client";

export const sync = createSyncClient<AppDatabase, { orgId: string }>(
  (context) => ({
    name: "app-sync-" + context.orgId,
    url: "/api/sync",
    tables: {
      todos: {
        indexes: "id, completed, updatedAt",
        channel: "org:" + context.orgId + ":todos"
      }
    }
  })
);
<script lang="ts">
  import { sync } from "$lib/sync-client.svelte";

  let { data } = $props();

  // Return null/undefined to wait until everything is ready
  sync.setContext(() => {
    if (!data.org?.id) return null;
    return { orgId: data.org.id };
  });
</script>
  • No setContext / no initial context option → no client
  • Getter returns null or undefined → tear down any existing client and wait
  • Real context → create (or rebuild) the inner client
  • Context is compared structurally; same values do not reconnect

setData is an alias for setContext. Guard UI with if (sync.client) before querying tables.

For live queries with a dynamic client, depend on the inner client:

const todos = createLiveQuery(
  () => sync.todos.toArray(),
  () => [sync.client]
);

Custom errors

import { SerializableError } from "@sveltebase/sync";

export class TranslatedError extends SerializableError {
  static readonly code = "TranslatedError";
  constructor(message: string) {
    super(message);
  }
}

const db = new SyncClient({
  name: "app-sync",
  url: "/api/sync",
  errorClasses: [TranslatedError],
  tables: { todos: { indexes: "id, updatedAt", channel: "todos" } }
});

Only code and message travel over the socket.


Server handlers

One handler per channel:

// src/lib/server/sync-handlers.ts
import { defineSync } from "@sveltebase/sync/server";

export const todoSync = defineSync<Todo, User>({
  channel: "todos",

  authorize: async (ctx) => {
    if (!ctx.auth) throw new Error("Login required");
  },

  fetch: async (ctx, since) => {
    // since is the client’s latest updatedAt for delta sync
    return listVisibleTodos(ctx.identity, since);
  },

  create: async (ctx, data) => insertTodo(ctx.identity!, data),
  update: async (ctx, key, changes) =>
    updateTodo(ctx.identity!, key, changes),
  delete: async (ctx, key) => {
    await deleteTodo(ctx.identity!, key);
  },

  broadcastTopics: (_ctx, _action, row) => ["user:" + row.userId]
});

export const handlers = [todoSync];

What each field does

| Field | Role | | --- | --- | | channel | Static string or (ctx) => string — must match the client table config | | fetch | Rows this connection may cache. This is the auth boundary for local data. | | create / update / delete | Mutations; return the canonical row (or void for delete) | | authorize | Runs before fetch and every mutation; throw to reject | | validate | Optional Zod schemas for create/update | | broadcast | "public" | "scoped" (default) | "none" | | broadcastTopics | Who gets live row payloads (delivery only — not authorization) | | viewVersion | When the visible set can change without a row event; mismatch forces a full snapshot |

fetch with no since should return the full visible set. Broadcast topics control who hears about changes; they never make unauthorized rows safe.


Publishing from your app

After you change the database in normal server code, notify clients:

import {
  publishEvent,
  publishBulkEvent,
  publishChangeEvent,
  publishResetEvent
} from "@sveltebase/sync/server";

await publishEvent("todos", "update", todo.id, {
  title: todo.title,
  updatedAt: Date.now()
});

await publishBulkEvent("todos", [
  { action: "update", key: todo.id, data: todo },
  { action: "delete", key: removedId }
]);

// “Something changed” — clients refetch with a delta
await publishChangeEvent("todos");

// Visible set changed (membership, permissions) — full resync
await publishResetEvent("todos", ["org:acme"]);

These only notify; they don’t write to your database. The Worker or Vite plugin must be initialized first.

Typed helpers: createPublisher, createBulkPublisher, createPublishChangeEvent, createPublishResetEvent.


Production (Cloudflare)

// src/worker/app.ts
import app from "../../.svelte-kit/cloudflare/_worker.js";
import { createSyncAppWorker, SyncEngine } from "@sveltebase/sync/cloudflare";
import { handlers } from "$lib/server/sync-handlers";

export default createSyncAppWorker(app, {
  handlers,
  websocketPath: "/api/sync",
  syncEngineBinding: "SYNC_ENGINE"
  // auth: sessionCookieAuth<User>(),
  // allowUnauthenticated: false
});

export { SyncEngine };

Export SyncEngine and bind it as a Durable Object. Auth options:

  • auth — resolve the user from the upgrade request
  • identity — stable id for the default user:… topic
  • topics — extra topic tags for the connection
  • allowUnauthenticated — default true unless your auth helper says otherwise

Wrangler

{
  "name": "my-app",
  "main": "src/worker/app.ts",
  "compatibility_date": "2026-06-07",
  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {
    "bindings": [
      { "name": "SYNC_ENGINE", "class_name": "SyncEngine" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SyncEngine"] }
  ]
}

SvelteKit route alternative

If the app route should own the WebSocket instead of the Worker wrapper:

// src/routes/api/sync/+server.ts
import { syncEngineRoute } from "@sveltebase/sync/sveltekit";
import { handlers } from "$lib/server/sync-handlers";

export const { GET } = syncEngineRoute({
  handlers,
  websocketPath: "/api/sync",
  syncEngineBinding: "SYNC_ENGINE"
});

Local development (Vite)

// vite.config.ts
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { syncDevPlugin } from "@sveltebase/sync/vite";

export default defineConfig({
  plugins: [
    syncDevPlugin({
      handlersPath: "/src/lib/server/sync-handlers.ts",
      path: "/api/sync"
    }),
    sveltekit()
  ]
});

The handlers module must export handlers. Edits reload through Vite SSR without a full rebuild.

For Cloudflare platform bindings in dev, configure platformProxy in svelte.config.js as usual.


Conflicts and consistency

  • When both local and incoming rows have numeric timestamps, older incoming rows are ignored (last-write-wins). That doesn’t replace real server conflict logic.
  • Always authorize in fetch and authorize.
  • Prefer scoped or none broadcasts for private data.
  • Use viewVersion or publishResetEvent when membership/permissions change so clients drop rows they shouldn’t see.
  • Clear local tables on logout when they hold user-specific data.

License

ISC