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

@ebbjs/client

v0.3.0

Published

Local-first sync client for ebbjs: handshake, live sync, typed query engine, write verbs, presence, and collaborative text.

Readme

@ebbjs/client

Local-first sync client for the ebbjs stack. Performs handshake, catch-up, and live SSE streaming against an ebb_server, with an outbox for offline writes and a query layer over materialized entities.

Install

npm install @ebbjs/client

Node >=22.8.0. The package ships one entry point:

import { createClient } from "@ebbjs/client";

Status

Working in development. The README will fill out as the SDK stabilizes. Today: handshake, catch-up, live SSE, outbox, presence, and the local query engine (typed query() reads with one-level with joins and cached reactive results). The React bindings live in @ebbjs/react; server functions are still being designed.

Reads and writes

client.<entity> is the schema-bound public API; a schema-bound client also carries the client.group / client.membership system namespaces. Reads go through query(); writes go through the namespace's verbs. The engine's single-row read (get(id)), its async row accessors, and the collection subscribe(filter, cb) are internal — they are not public read paths.

Reads — client.<entity>.query()

query() returns a typed, immutable chain. Each mutator returns a new chain; awaiting it resolves to the projected rows (the schema's fields plus the entity id; a replica-backed query row also carries its live groupIds).

groupIds is read from the replica's membership index — one lookup per row, no entityGroup scan. Use it when a row only needs the ids it belongs to; .with({ groups: true }) projects the group rows themselves, which matters because a group id can name a row that is not materialized. Joined target rows are plain projections (fields plus id), and a chain over already-materialized candidates (buildQueryBuilder / row accessors) has no membership index, so neither carries groupIds.

const issues = await client.issue
  .query()
  .where("status", "open")
  .or("priority", "urgent")
  .orderBy("updated_at", "desc")
  .limit(50)
  .with({ assignee: true });

Grammar:

  • where(key, value) — equality conjunct. A key may be a shape field, a registered relationship as (filters that edge), or the reserved groups key (filters built-in membership).
  • or(key, value) — starts a new AND-group. Predicates are a disjunction of AND-groups, so .where(a).where(b).or(c).where(d) means (a AND b) OR (c AND d).
  • orderBy(field, "asc" | "desc") and limit(n).
  • with({ key: true, ... }) — one-level joins over the schema's relationships plus groups. Each selected key is merged onto the row; a nested selector is rejected.

Terminals: await / .then() for rows, .first(), .count(), .exists(), the async iterator, and .toRaw() for the untyped wire entities.

A chain's result is cached and reactive: .result() returns the engine store (getSnapshot() reads { data, status, loading, error }; subscribe(listener) fires on change) and the chain's own .subscribe(listener) is the shorthand. status is one of:

| Status | Meaning | | ---------- | ----------------------------------------------------------------- | | unloaded | Never run; data is undefined (never an empty list). | | stale | A usable result exists at an older revision; a re-run is pending. | | current | The result was produced at the replica's current revision. | | failed | The last run threw; sticky until a successful run. |

Two chains that lower to the same query share one cached result, so a remount reads it back instead of re-running. In React, useQuery wraps this surface.

Name the row a query returns with QueryRowOf, passing the schema and the with(...) join keys you selected. It derives the same projection the query returns, so a view type cannot drift from the schema:

type IssueRow = QueryRowOf<typeof schema, "issue", "assignee">;
const issues: readonly IssueRow[] = await client.issue.query().with({ assignee: true });

Materialization

An entity type is held (the replica materializes every row) or on-demand (a row materializes only while it is open). Declare it on the definition:

const pageContent = defineEntity(
  "page_content",
  { content: e.map(e.string()) },
  { materialization: "on-demand" },
);

A document entity generated by e.collaborativeText() is on-demand by default, so the heavy body stays out of the replica while the page, its relationships, and its memberships stay held. Open a body with row.body (or client.replica.open(id)); it is pinned in an LRU window (pinnedCapacity, default 16). client.replica.close(id) closes the pin; the row stays cached for pinnedTtlMs (default 30 s) so a remount reads it back, then leaves. A non-positive TTL releases it immediately on close, and an open row is never released by the TTL. The persisted replica record holds held rows only — a body is never part of it.

A query whose source type is on-demand is refused with OnDemandQueryError, because a scan is held-only. A held source may join an on-demand target: with({ content: true }) opens the joined rows asynchronously, and a cached result reads stale while the load is in flight and current once it settles.

Writes — namespace verbs

Single-entity writes are the namespace verbs, validated against the schema before anything reaches the wire:

const { id } = await client.issue.create(
  { title: "Buy milk", status: "todo" },
  { groups: [groupId] },
);
await client.issue.update(id, { status: "done" });
await client.issue.link(id, "assignee", profileId);
await client.issue.unlink(id, "assignee");
await client.issue.delete(id);
  • create(input, { groups }) — groups is required and non-empty; the membership rows ship in the same Action as the entity.
  • update(id, patch), delete(id), duplicate(id, opts?).
  • link / unlink / setLinks for relationships.
  • addToGroup / removeFromGroup / setGroups for membership. A membership row's id is derived from the (entity, group) pair, so a share is idempotent across devices and an unshare removes the row every device is looking at.

create accepts the entity's wire fields. To set a relationship in the same Action, create the row through client.atomic (below) and pass the target to the draft's create.

Multi-entity writes are one Action: client.atomic resolves handles created in one callback, and defineAction(schema, callback) mounts the same batch as client.actions.<name> so it can be reused.

// Two writes, one Action: the new issue and an existing profile's
// membership in the workspace.
const issue = await client.atomic((drafts) => {
  const created = drafts.issue.create(
    { title: "Ship the docs", status: "todo" },
    { groups: [groupId] },
  );
  drafts.profile.addToGroup(profileId, groupId);
  return created;
});

defineAction(schema, callback) declares the same batch once and mounts it as client.actions.<name> when the client is created with actions:

const inviteMember = defineAction(
  schema,
  ({ membership }, params: { actorId: string; groupId: string; permissions: string[] }) => {
    membership.create({
      group_id: params.groupId,
      actor_id: params.actorId,
      permissions: params.permissions,
    });
  },
);

const client = createClient({ serverUrl, actorId, schema, actions: { inviteMember } });
await client.actions.inviteMember({ actorId: personId, groupId, permissions: [] });

Sharing an entity is a write to it. Filing an entity the server already places in a group needs <type>.update in one of the entity's current groups as well as <type>.create in the target, so an invitation cannot file the invitee's profile: the inviter has no permission where that profile lives. The invitee's own client shares it, after observing the membership:

// run by a member of the group, through that member's client
await memberClient.actions.inviteMember({ actorId: personId, groupId, permissions });
await inviteeClient.handshake();
await inviteeClient.profile.addToGroup(profileIdFor(personId), groupId); // the profile's owner

One Action carries one actor's authority, so the two cannot be batched. Between them the member exists and the profile does not — the profile-pending window.

Not yet

The grammar has no id predicate: query the collection and select the row. limit(n) is the only windowing — cursor pagination is deferred.

Seeding fixtures

client.seed(data) submits a SeedData fixture as one Action through the public write path, applying the shared rejection-tolerance policy. It is schema-less: rows carry their own ids and plain field values, so a client created without a schema can still seed fixture data.

import { createClient } from "@ebbjs/client";

const client = createClient({ serverUrl, actorId: "demo-seeder" });
await client.seed({
  groups: [{ id: "grp_demo", name: "Demo Group" }],
  groupMembers: [
    {
      id: "gm_demo",
      actorId: "demo-seeder",
      groupId: "grp_demo",
      permissions: ["text_document.*", "group.*"],
    },
  ],
  entities: [],
});
client.close();

@ebbjs/core/seed is the browser-safe module behind it. It exports SeedData (and the row types), buildSeedAction, which stamps plain values into wire envelopes, and the rejection-tolerance policy (assertSeedAccepted / SeedRejectedError). @ebbjs/server.seed re-exports the same builder and adds the raw-POST seed(serverUrl, actorId, data) harness, so browser demos and the server test harness share one builder and one tolerance policy, defined in one place.

Development

Three scripts:

  • pnpm --filter @ebbjs/client test — unit tests only. Fast, no server needed. 1119 tests across src/sync/, src/fields/, src/presence/, and the storage layer. Runs in CI on every PR.
  • pnpm --filter @ebbjs/client test:watch — same as test, in watch mode.
  • pnpm --filter @ebbjs/client test:integration — round-trip integration tests under src/__tests__/integration/ against a live ebb_server. Not run by default.

Running integration tests locally

  1. Build the release once:

    bash packages/server/scripts/build-release.sh
  2. Start ebb_server on the default port (4000):

    ./packages/server/dist/ebb_server/bin/ebb_server start

    Or run from source: cd ebb_server && MIX_ENV=dev mix run --no-halt.

  3. In another shell:

    pnpm --filter @ebbjs/client test:integration

    Override the server URL with EBB_TEST_URL=http://localhost:4001 for a non-default port.

Integration test layout

src/__tests__/integration/ contains two files:

  • ebb-server.test.ts — self-seeding. Creates a unique group + members per run and exercises the full client ↔ server round-trip (handshake, catchUp, write, presence, permission rejection).
  • presence.test.ts — requires a pre-seeded grp_demo group with a demo-seeder member that this package does not bootstrap. The test probes for that fixture at load time and skips cleanly with a warning when it's missing. Seed the demo fixtures locally if you want to run it end-to-end.

The CI integration job (elixir-integration-tests in .github/workflows/ci.yml) boots the cached release and runs test:integration. presence.test.ts skips there because the demo fixture is not seeded.