@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/clientNode >=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 relationshipas(filters that edge), or the reservedgroupskey (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")andlimit(n).with({ key: true, ... })— one-level joins over the schema's relationships plusgroups. 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 })—groupsis 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/setLinksfor relationships.addToGroup/removeFromGroup/setGroupsfor 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 ownerOne 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 acrosssrc/sync/,src/fields/,src/presence/, and the storage layer. Runs in CI on every PR.pnpm --filter @ebbjs/client test:watch— same astest, in watch mode.pnpm --filter @ebbjs/client test:integration— round-trip integration tests undersrc/__tests__/integration/against a liveebb_server. Not run by default.
Running integration tests locally
Build the release once:
bash packages/server/scripts/build-release.shStart
ebb_serveron the default port (4000):./packages/server/dist/ebb_server/bin/ebb_server startOr run from source:
cd ebb_server && MIX_ENV=dev mix run --no-halt.In another shell:
pnpm --filter @ebbjs/client test:integrationOverride the server URL with
EBB_TEST_URL=http://localhost:4001for 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-seededgrp_demogroup with ademo-seedermember 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.
