@jsm-mit/chat-sharded-package
v0.4.0
Published
Wrapper TypeScript package for the chat group (coordinator, core, workers): projects, rooms, multi-party chats, messages and a poller for agent hosts. Takes the coordinator's id and finds the canister of every call.
Readme
@jsm-mit/chat-sharded-package
TypeScript wrapper for the chat group — a generic, multi-project, multi-party chat on the Internet Computer, spread over several canisters — plus the integration tests that prove it.
| Canister | Part of chat-sharded | Holds |
|---|---|---|
| coordinator | coordinator/ (Rust) | where each chat is, the workers and their load |
| core | core/ (Motoko) | projects, rooms, admins |
| workers | worker/ (Motoko) | chats and messages |
A consumer knows ONE id: the coordinator's. Every actor takes it and works as if the group were one canister: how many canisters hold the chats, and which one holds a chat, is the package's own business. No method of the main entry takes or returns the id of a worker.
import { RoomsActor, ChatsActor, MessagesActor, ChatPoller } from "@jsm-mit/chat-sharded-package";
// A canister admin creates the project with its accounts canister id — the consumer
// group's COORDINATOR, asked WHERE: before a non-admin opens a chat the core asks it
// `accountsCanisterFor(principal)` and asks `isUser(principal)` on the canister it names
// (Sultana: the sultana-sharded coordinator, which names its core).
// A project admin defines a room: who receives the customer's messages.
// One room per thing the app wants a conversation about (Sultana: per salon), closed to
// the people who may open it — an empty allowedCustomers means everybody with an account.
await new RoomsActor(coordinatorId, adminIdentity).createRoomAsyncUnsafe({
projectId: "sultana-stage", id: `salon-${salonId}`, name: "Asystent salonu",
recipients: [{ principal: villagePrincipal, role: "agent", displayName: "Asystentka Ola" }],
allowedCustomers: [ownerPrincipal],
});
// The customer opens the room; the same call returns the same chat next time, whichever
// worker holds it.
const chats = new ChatsActor(coordinatorId, identity);
const chat = await chats.openChatAsyncUnsafe({ projectId: "sultana-stage", roomId: `salon-${salonId}`, customerName: "Michał" });
const messages = new MessagesActor(coordinatorId, identity);
await messages.postAsyncUnsafe({ chatId: chat.id, text: "dodaj usługę strzyżenie" });
// The page carries the thread AND what the others are doing: `presence[].typing` while
// somebody is writing (a room's recipients report it with setTypingAsyncUnsafe, and their
// own post clears it), `presence[].lastReadSeq` for "read up to here".
const page = await messages.getMessagesAsyncUnsafe({ chatId: chat.id, sinceSeq: 0 });
// A host that sends receipts and typing as a courtesy skips the proof: the call is taken
// in by the IC, the certified reply is never verified (that check is the expensive part
// on a small CPU), and a refusal goes unnoticed.
await messages.markReadUnverifiedAsyncUnsafe(chat.id, page.lastSeq);
await messages.setTypingUnverifiedAsyncUnsafe(chat.id);
// Every actor takes `{ blsVerify }` as a third argument: a faster check of the certified
// replies than agent-js's pure-JS one, for a host that makes many update calls.
const fast = new MessagesActor(coordinatorId, identity, { blsVerify: myVerifier });
// `{ host }` points an actor at another gateway or replica (default: mainnet, icp0.io). For a
// host on this machine — and only then — the agent fetches the replica's root key.
const local = new MessagesActor(localCoordinatorId, identity, { host: "http://127.0.0.1:4943" });
// An agent host: every new message in every chat this identity is a recipient of, each
// once, with the room and the customer on each message — route by (projectId, roomId).
const poller = new ChatPoller(messages, 1000, { onError: console.error });
poller.messages$.subscribe((m) => console.log(m.roomId, m.customer, m.kind, m.text));
poller.run();
// To go on after a restart where the poller stopped: keep its cursor (a text), hand it back.
const saved = poller.cursor;
const resumed = new ChatPoller(messages, 1000, { startAt: saved });
// The same by hand. No cursor = from the very start; { limit: 0 } = the cursor of now.
let { nextCursor } = await messages.pollNewMessagesAsyncUnsafe({ limit: 0 });
const news = await messages.pollNewMessagesAsyncUnsafe({ cursor: nextCursor });Coming from @jsm-mit/chat-motoko-package 0.8
This package is the successor of the single-canister @jsm-mit/chat-motoko-package
(its last version 0.8.0) and starts again at 0.1.0. What a consumer changes:
| chat-motoko-package 0.8 | chat-sharded-package |
|---|---|
| Every actor takes the id of THE chat canister. | Every actor takes the id of the coordinator. |
| pollNewMessagesAsyncUnsafe({ sinceGlobalSeq }) → { messages, nextSinceGlobalSeq, headGlobalSeq } | pollNewMessagesAsyncUnsafe({ cursor }) → { messages, nextCursor, errors }. The cursor is a text to keep, not to read. |
| ChatPoller.cursor: number, startAt: number | ChatPoller.cursor: string, startAt: string |
| MessageView.globalSeq | gone. A message is named by chatId + seq; both stay when the chat moves. Use seq for order, keys and "seen up to". |
| ChatSummaryView.messageCount, lastMessageAt | gone: the core holds no messages. |
| — | new error keys: ChatIsMoving (a write during a move that outlasted the tries), NoWorkerAvailable (no worker takes new chats and none can be created — when one is being created, openChat waits for it, up to a minute). |
Env names on the consumers' side: CHAT_CANISTER_ID → CHAT_COORDINATOR_ID,
VITE_CHAT_CANISTER_ID → VITE_CHAT_COORDINATOR_ID.
How a call finds its canister
- The core (
AdminActor,ProjectsActor,RoomsActor, the two admin methods ofChatsActor): its id is asked from the coordinator before the first call and kept. - A chat (
getChat,post,markRead,setTyping,getMessages): the coordinator names the worker; the package keeps the answer until that worker saysNotFound. Then the coordinator is asked again and the call goes to the worker it names now — a chat that moved is followed without the consumer seeing it. A write during the move waits (5 tries, 2 s apart). - A new chat (
openChat): the coordinator names a worker that takes new chats; when the chat exists in another worker, that worker is named and the call goes there. - Lists over the group (
getMyChats,pollNewMessages,ChatPoller): every worker is asked and ONE answer is made of it. One worker that does not answer failsgetMyChats(a list is never silently short). A poll goes on with the others: the messages of the silent one come with a later poll, and what it failed with is inerrors(ChatPollerhands it toonError). - The coordinator's answers are queries without a certificate. Every worker it names must be on its list of workers, and a worker refuses a chat it does not hold.
Actors built with the same identity object, coordinator and host share what they learned
(ChatGroup). An identity that was renewed (a new delegation) is a new object and starts
with nothing in hand.
The cursor of a poll
One text stands for the place in every worker and for the newest message read of every chat of the last ten minutes. So:
- A worker that joins the group later is read from its start: everything in it is new.
- A chat that moves brings its messages of the last two minutes along as news in the new worker (a reader may not have read them in the old one). The cursor knows what was read, and the poll brings each message ONCE — also after a restart, when the poller starts again with a cursor it kept.
- Two limits. A poller that did not run for more than two minutes before a move misses
what was written in that time. A poller that begins "from now" (
{ limit: 0 }, or aChatPollerwithoutstartAt) right after a move may get messages of that chat that are up to two minutes older than its start. - The messages of one chat come in the order of the chat; all of them about in the order of their time.
Classes
- Main entry, for consumers:
AdminActor,ProjectsActor,RoomsActor,ChatsActor,MessagesActor,ChatPoller. @jsm-mit/chat-sharded-package/group, for the superadmin's scripts and for tests — the canisters one by one:CoordinatorActor(routing queries for everybody; config, the worker wasm, workers, moves and the status for superadmins),CoreAdminActor,CoreProjectsActor,CoreRoomsActor,CoreChatsActor,WorkerChatsActor,WorkerMessagesActor(a worker's own poll, with itsglobalSeq),ChatGroup, the mappers and the candid types.
Every method is …AsyncUnsafe (throws one of CanisterError, CriticalCanisterError,
CallRefusedAtInspectionStage) or …AsyncSafe (never throws). Views translate
Principal → text, opt → optional, variants → string unions; sequence numbers are plain
numbers, timestamps bigint nanoseconds.
The coordinator is a Rust canister: it answers { Ok } | { Err }, and the package throws
its Err as the same CanisterError (cause.errorKey = NotAuthorized, NotFound,
AlreadyExists, InvalidData, NoCycles, RemoteCanisterError; no logs). An UPDATE of
somebody who is neither a superadmin nor a worker is refused before it runs:
CallRefusedAtInspectionStage.
Deleting: RoomsActor.deleteRoomAsyncUnsafe (project admins) takes a room with every chat
and message in it; ProjectsActor.deleteProjectAsyncUnsafe (canister admins only) takes a
project with all its rooms. The core deletes in bounded calls (200 chats each, purged in
the workers that hold them), and these methods repeat the call until it is done — pass
onStep to see the progress, or call deleteRoomStepAsyncUnsafe /
deleteProjectStepAsyncUnsafe for one call at a time.
Usage billing (0.4.0, ../high-level-design/billing.md): ProjectsActor.getProjectBillAsyncUnsafe
(project admins and the project's accounts canister) reads a project's account — the
bill, its part per room, the usage not billed yet, the grace. AdminActor lists the
accounts and sets the fees, the switch and the exempt flag (listBillingAccountsAsyncUnsafe,
getBillingStatusAsyncUnsafe, setBillingSettingsAsyncUnsafe,
setProjectExemptAsyncUnsafe); CoordinatorActor has the switch of the group, the
thresholds of the multiplier and the daily run (getBillingAsyncUnsafe,
setBillingEnabledAsyncUnsafe, setBillingThresholdsAsyncUnsafe,
runBillingNowAsyncUnsafe). The payment itself, payProjectBill, carries cycles: it is a
call of the payer canister, not of an agent, so the package has no method for it. A
post in a project whose bill is unpaid for 14 days throws CanisterError with
errorKey NotAuthorized (the text names the unpaid bill); reading goes on.
src/ is browser-safe — the same package serves the agent host and a web panel.
Scripts
build, typecheck, sync-declarations (-- worker | core | coordinator for one),
test-unit, test-core-local, test-suite-local, test-routing-local,
test-access-restrictions-local, test-smoke-local, user1 / user2 (an interactive
admin/chat console, one person per terminal: the key comes from USER1_PEM / USER2_PEM
in .env, the group from COORDINATOR_CANISTER_ID; another group:
npm run user2 -- <coordinator-id>), stamp-canister, publish-public. See CLAUDE.md
for who runs what.
Integration tests
The suites run on the local dfx network only, with no cycles and no lease — wrapper packages are never tested on mainnet (decision of the user, 2026-10-17), and a worker is installed by its coordinator only:
npm run test-suite-local # or test-core-local, test-routing-local,
# test-access-restrictions-local, test-smoke-localscripts/run-tests-local.sh builds a whole group there from the current builds of the
three sibling repos (npm run local:group in the coordinator repo: a fresh coordinator, a
fresh core, one worker the coordinator creates), makes the test superadmin (an identity of
a public seed, tests/support/superadmin.ts) a superadmin of that coordinator, and runs
the entry with COORDINATOR_CANISTER_ID, CORE_CANISTER_ID, CANISTER_ID (the first
worker) and HOST. The actors under test take the coordinator's id only. The network
must already run (dfx start --background > /tmp/dfx-local.log 2>&1 — never --clean:
every project on the machine keeps its local canisters there).
npm run load-local loads one worker for a minute and moves a chat of a thousand
messages (scripts/load.ts); it prints the worker's own account: instructions and
bytes per message, messages in one round, the time of a move.
npm run load-dev runs the same test against the DEV group on mainnet (coordinator1 of
the crafting table) as the dfx identity in use, a deploy admin there — the user runs it,
it spends cycles. It needs exactly ONE worker in the group, gives the project a fresh id,
puts the daily quota back and deletes the second worker with its chat at the end (its
cycles go back to the coordinator; the create fee of 0.5 TC is burned). Before a run the
coordinator holds 2 × cyclesPerWorker + 0.5 TC + cyclesPerWorker (4.5 TC with 1 TC).
LOAD_CUSTOMERS, LOAD_PARALLEL, LOAD_SECONDS change the shape (both commands);
LOAD_NO_MOVE=1 skips the move and the second worker — e.g. one post at a time:
LOAD_CUSTOMERS=1 LOAD_PARALLEL=1 LOAD_NO_MOVE=1 npm run load-dev.
LOAD_KEEP_QUOTA=1 leaves the daily quota alone — for a light probe started in a second
terminal next to a heavy run (on DEV only: locally every run builds its own group). The
script prints the CPU its client used: near 100% of one core, the client is the queue.
npm run load-users-local / npm run load-users-dev (scripts/load-users.ts): many
independent users, as in life — each with its own identity and chat posts, waits for the
answer, pauses (LOAD_PAUSE_S, 10 s on average) and posts again. The users run in child
processes, one per core less one (LOAD_PROCS), so no client is the queue. Steps of
users (LOAD_STEPS, default 50,100,200,400), each a whole minute and a quiet minute
after it; for every step the users' p50/p95/p99/max, the failures, the busiest client's
CPU and the worker's own minute. The daily quota is not touched.
npm run test-unit runs tests/unit/ — the router, the poller and the retry, with no
canister and no network.
Publishing
Bump the version in package.json by hand, start the local network, then:
npm run publish-publicscripts/publish-public.sh refuses unless this repo is on master equal to
origin/master with nothing uncommitted but the bump, the version is not on the registry
yet, and the three canister repos are on master, clean and equal to their
origin/master. It then builds a group from those masters on the local network, reads
the interface each canister answers with (candid:service) and refuses if one differs
from the committed declarations/ (scripts/check-interfaces.sh; comments, white space
and install arguments aside) — sync the declarations, commit, push, publish again. On the
same group it runs the smoke test (tests/test-smoke.ts) and refuses if it fails. Next
scripts/stamp-canister-commit.sh repeats the repo checks, rebuilds the canisters,
regenerates the declarations and refuses if that changes what is committed. Only then it
writes the three stamps into package.json — chatMotoko, chatCoreMotoko,
chatPlatformCanisterCoordinator, each {repo, repoUrl, commit} — publishes, and commits
and pushes Version bump to <version> (when there is nothing to commit, it stops there
without an error). A failed publish takes the stamps back out.
