@cero-base/cero
v1.0.1
Published
The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.
Readme
cero
Define your data. Use it. cero handles storage, sync between devices, and sharing with others — no server.
Describe your data, compile it to a spec once, then open a cero against the built spec:
// schema.js
import { cero } from '@cero-base/cero'
export const schema = cero.schema({
profile: cero.t.single({ name: cero.t.string }),
room: { messages: cero.t.collection({ text: cero.t.string }) }
})// build.js — run once; re-run when the schema changes
import { build } from '@cero-base/cero/build'
import { schema } from './schema.js'
await build('./spec', schema)import { cero } from '@cero-base/cero'
import { spec } from './spec/index.js'
const me = await cero('./data', spec)
await cero.set(me.profile, { name: 'Alice' })
const room = await cero.open(me.room, { name: 'general' })
const invite = await room.invite()
// room.revoke(invite) ← invalidate it before anyone joinsOn another device — same built spec, its own identity and data dir:
const peer = await cero('./peer-data', spec)
const joined = await cero.open(peer.room, invite)
await cero.put(joined.messages, { text: 'hi' })Ships with TypeScript declarations (.d.ts) generated from JSDoc.
cero(dir, spec, opts?)
| Opt | Meaning |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| bootstrap | Hyperswarm bootstrap nodes. |
| name / isMobile | Stamped on the device's add-writer event. |
| seed / phrase | Restore from explicit 16-/32-byte entropy or a BIP-39 mnemonic. Without either, a stored identity is loaded if present, else a fresh one is generated. |
| key | Open against an existing bee key (multi-device flow). |
| recovery | true triggers bootstrap({ recovering: true }) — for a second device opening with key + the same identity. Needed under autobee 1.0.3 so the second device swaps off the identity-keyed bootstrap-writer slot and starts applying remote appends. |
| recoveryTimeout | Bound on the recovery wait. |
| routes | Custom action handlers keyed by route name. |
| encryptionKey | Override the per-identity encryption key. |
| onerror | Called with background/async failures that would otherwise be swallowed (failed after-hooks, onApply callbacks, pairing candidate errors); the app decides how to log or report them. |
Reads me.id, me.device, me.identity for canonical metadata. me.identity.toPhrase() renders the seed phrase.
cero.peek(dir, spec)
const initialized = await cero.peek('./data', def) // true if an identity is storedCheap probe — opens the local store, reads the master row, closes. Use it before deciding whether to show a setup screen or jump straight in.
cero.restore(me, phrase)
Wipe the current data and re-open with a different identity from a BIP-39 mnemonic. Used when a user signs back in on a fresh device or recovers from a phrase backup.
const me = await cero('./data', def)
const recovered = await cero.restore(me, 'twelve words …')Schema
| Shape | Meaning |
| -------------------------- | ----------------------------------------------- |
| cero.t.single({ … }) | one row (e.g. profile, settings) |
| cero.t.collection({ … }) | many rows with auto ids (e.g. messages) |
| { … } (plain object) | a child handle — its own scope, share by invite |
| local: { … } | device-only — never leaves this device |
A collection can declare secondary indexes for fast exact-field lookups:
cero.t.collection(
{ text: cero.t.string },
{ indexes: { 'by-text': ['text'] } } // name → field(s); query via `cero.get(ref, { text: '…' })`
)Operators
Every operator takes a ref (e.g. me.profile, room.messages) as the first arg.
cero.put(ref, row)
Append a row to a collection. Generates an id, createdAt, and updatedAt if you don't provide them.
const { data } = await cero.put(room.messages, { text: 'hi' })
data.id // → 'abc…' (plus createdAt / updatedAt)
// `memberId` (the writer→member backlink) is stamped in the apply layer, so it
// surfaces on a later `cero.get`, not on the row this call returns.cero.set(ref, row)
Insert-or-update. For a single ref it replaces; for a collection it upserts by id.
await cero.set(me.profile, { name: 'Alice' }) // single
await cero.set(room.messages, { id: 'abc', text: 'edited' }) // upsert by idcero.get(ref, q?)
Read. The shape of q decides what comes back:
const { data } = await cero.get(me.profile) // single → the row (or null)
const { data } = await cero.get(room.messages, 'abc') // by id → the row (or null)
// collection → paginated, with range filters
const { data, total, size } = await cero.get(room.messages, {
gt: 'm-2025', // range: gt / gte / lt / lte
limit: 20,
reverse: true
})
// search → case-insensitive substring across string fields
const { data } = await cero.get(room.messages, { search: 'jo sm' })
// every space-separated term must match (AND); folds case + diacritics ('jose' ⇢ 'José').
// `fields` restricts which fields are searched (default: all string fields):
const { data } = await cero.get(room.guests, { search: 'jose', fields: ['name'] })
// exact field match → routed through a declared secondary index when the field has one
const { data } = await cero.get(room.messages, { text: 'alpha' })total is the unfiltered row count; size is what came back after limit. search and exact-field filters compose with limit / reverse, and cero.count(ref, q) honors them too.
cero.del(ref, id)
Delete by id (collection) or clear (single).
await cero.del(room.messages, 'abc')
await cero.del(me.profile)cero.count(ref, q?)
const { data: n } = await cero.count(room.messages)
const { data: n } = await cero.count(room.messages, { gt: 'm-2025' })cero.watch(ref, q?, opts?)
A Readable stream that re-emits the latest snapshot on every change. Always emits an initial snapshot. The stream is tied to the ref's handle — closing the handle destroys it, so a watch never outlives its store and you don't track cleanup. Pass { signal } to bind it to a finer scope instead.
const stream = cero.watch(room.messages, { limit: 50, reverse: true })
for await (const { data, total, size } of stream) {
/* render */
}
// stops on room.close(), on stream.destroy(), or on `signal` abort:
const ac = new AbortController()
cero.watch(room.messages, null, { signal: ac.signal }) // ac.abort() ⇒ destroyedcero.call(actionRef, data)
Invoke an action defined in your schema with t.action({ … }). Custom write paths; the dispatcher handles encoding/decoding.
await cero.call(room.promote, { memberId: 'xyz', role: 'admin' })cero.open(handleRef, arg)
The universal handle entry point. Dispatch is based on arg:
await cero.open(me.room, { name: 'general' }) // create — opts go through to _create
await cero.open(me.room, inviteString) // join — `arg` is the invite string
await cero.open(me.room, { invite: inviteString }) // join — explicit form
await cero.open(me.room, { id: existingRoomId }) // load an existing room by idReturns the child handle, ready to operate on (cero.put(joined.messages, …) etc.).
cero.before(ref, fn, opts?) / cero.after(ref, fn, opts?)
Hook into writes to a ref. before runs in-path before the write commits — return false to cancel, or mutate ctx.row. after is a non-blocking event that fires once the write has committed. Both return an unsubscribe fn — call it to stop early, ignore it for a subscription that lives as long as the handle, or pass { signal } to unsubscribe when an AbortSignal fires (e.g. { signal: me.signal } to stop on close).
const off = cero.before(room.messages, (ctx) => {
if (!ctx.row.text?.trim()) return false // reject empty messages
})
cero.after(me.profile, ({ row }) => {
/* react to your profile changing */
})ctx is { op, name, row } (plus result in after).
Files
Store a file (avatar, image, attachment) and get a ready-to-render URL. Bytes live in a per-handle blob core — replicated to members on demand, never inlined into the log — so rows carry only a small self-describing id.
Every handle has a builtin files collection. cero.put(handle.files, …) uploads bytes (a Buffer or a Readable) and returns a file with a .url:
const { data: file } = await cero.put(me.files, {
data: bytes, // Buffer | Readable
name: 'cat.jpg',
type: 'image/jpeg'
})
img.src = file.urlRead them back — every row carries a fresh .url:
const { data: files } = await cero.get(me.files) // list all
const { data: one } = await cero.get(me.files, file.id) // one, by id
cero.watch(me.files).on('data', ({ data }) => render(data)) // liveThe file() column type
For a file referenced from a row — an avatar, a room icon, a message attachment — declare the field cero.t.file(). Store the file's id; read it back already resolved to { id, name, type, size, url }, with no extra lookup:
// schema.js
profile: cero.t.single({ name: cero.t.string, avatar: cero.t.file() })
// save: upload, then store the id on the row
const { data: pic } = await cero.put(me.files, { data: bytes, type: 'image/png' })
await cero.set(me.profile, { avatar: pic.id })
// read: the avatar comes back url-ready
const { data: profile } = await cero.get(me.profile)
img.src = profile.avatar.urlcero.t.file({ embed: true }) also stores the file's name inline — handy for a list of named attachments, so rendering needs no per-item lookup.
URLs are local and ephemeral
A .url points at a localhost server this device runs, with a per-session token — it changes across restarts and is not shareable to other peers. Never persist a .url: store the id (cero does), and re-read to get a current one. Each member derives their own url from the same id, and the bytes download on demand when the url is first fetched.
Custom operators
Your app's business logic lives as custom operators — plain functions whose first arg is the handle they act on, composing the built-ins. Because the built-ins resolve through the handle (a real DB on the core, an RPC proxy on the client), custom operators are symmetric over RPC for free.
// guest.js — pure functions, one per export
import { put, del } from '@cero-base/cero'
import { ensureGuestId } from './ids.js'
export const create = (room, data) => put(room.guests, { ...data, id: ensureGuestId(data.id) })
export const remove = (room, id) => del(room.guests, id)cero.define(map)
Register custom operators by scope, once, and cero puts them on every matching handle — the root and each child as it opens, on both the core and the client. A bare key binds on the root handle; a key that names a child-handle type binds on each handle of that type:
import * as user from './user.js'
import * as guest from './guest.js'
import * as station from './station.js'
cero.define({
user, // bound on the root → me.user.rename(…)
room: { guest, station } // bound on each room → room.guest.create(…)
})
const room = await cero.open(me.room, { id })
await room.guest.create({ name: 'Bob' }) // no binding at the call sitecero reads the spec to tell root namespaces from child-handle types, so there is no root wrapper. Call cero.define(...) once at startup — in the build/server process and the client — the same as extensions.
cero.bind(handle, map)
The primitive define uses. Curry a handle onto a { ns: module } map yourself — for a handle you opened manually, or to bind operators you don't want globally registered:
import * as guest from './guest.js'
cero.bind(room, { guest })
await room.guest.create({ name: 'Bob' }) // → guest.create(room, …)Non-functions in the map are skipped; the handle is returned.
Extensions
An extension bundles schema (build-time) with behavior (runtime). Register it with cero.use() — build() folds in its schema and cero() runs its setup once the handle is ready. cero core stays schema-agnostic; apps opt into the behaviors they want.
import { profileSync } from '@cero-base/cero/extensions'
cero.use(profileSync()) // before build() (for the schema) and before cero() (for the behavior)
await build('./spec', schema)An extension is two optional parts:
function myExtension() {
return {
schema: { members: cero.t.extend({ status: cero.t.string }) }, // merged into your schema
setup(me) {
const onHandle = (room, opts) => {
/* opts carries the open args (e.g. opts.name on create); me.children is the set of open rooms */
}
me.on('handle', onHandle, { signal: me.signal }) // dropped on me.close()
}
}
}For a behavior-only extension (no schema), pass a function directly — it's shorthand for { setup }:
cero.use((me) => {
me.on('handle', (room) => {
/* … */
})
})Build and the running app are separate processes, so cero.use() runs in both — your build script (for the schema) and once at app startup before cero() (for the behavior).
Cleanup
You rarely write teardown — a handle's subscriptions follow the same "dies with the node" model as DOM events:
cero.watch(ref)streams are destroyed automatically when the ref's handle closes.me.on(...),before,aftertake{ signal }to drop themselves when anAbortSignalfires.me.signalis anAbortSignalthat aborts onme.close(). Pass it to tie a subscription to the handle's life, or use your ownAbortControllerfor a finer scope (drop it before the handle closes):
setup(me) {
me.on('handle', onHandle, { signal: me.signal }) // dropped when me closes
cero.after(me.profile, onProfile, { signal: me.signal })
}For anything the close cascade won't reach — a timer, an external connection, a stream you made yourself — hand it to me.own(resource) (destroyed on close) or return a disposer from setup:
setup(me) {
const timer = setInterval(() => ping(me), 30_000)
me.own({ destroy: () => clearInterval(timer) }) // tied to me.close()
// …or: return () => clearInterval(timer)
}profileSync (bundled)
Mirrors your profile onto your member row in every room, so others see your name and avatar. It declares its own profile single (name + the synced fields) and extends member with them — no app schema required. Defaults to syncing avatar:
cero.use(profileSync())
cero.use(profileSync({ fields: { status: cero.t.string } })) // sync extra fieldsWant a richer profile (e.g. a bio that doesn't sync)? Declare your own profile single — the app schema wins, and profileSync still adds the member side.
handleSync (bundled)
Mirrors a child handle's profile (name + avatar) onto its row in the parent's handles list — so a handle/room list renders names and photos without opening each handle. Adds avatar (or your fields) to the handle builtin and reflects the handle's own profile (which your app owns) onto the row.
cero.use(handleSync())
// your handle type declares a `profile`; your app sets it:
const room = await cero.open(me.room)
await cero.set(room.profile, { name: 'general', avatar: 'pic.png' })
// → the me.handles row now carries name + avatar — render the room list, no opensRequires your handle types to declare a profile single; handles without one are left untouched.
RPC
cero runs in one process; your UI runs in another. Connect them with any duplex stream (Bare IPC, Electron contextBridge, a worker port, even a TCP socket).
The client side gets the same operator API as a local cero — cero.put, cero.set, cero.open, etc. all work over the wire.
Server (the process that owns the data)
// workers/main.js — e.g. a Bare worker or Node background process
import { serve } from '@cero-base/cero/server'
import { spec } from './spec/index.js'
// serve builds and owns the root cero — pass storage + the built spec, not a handle.
// Returns a Server instance (call server.close() to shut down).
const server = await serve(Bare.IPC, { storage: './data', spec, seed: '…' })Client (the UI process)
// renderer.js — runs in Electron renderer, React Native, etc.
import { cero } from '@cero-base/cero'
import { connect } from '@cero-base/cero/client'
import { spec } from './spec/index.js'
const me = await connect(ipcStream, spec)
await cero.set(me.profile, { name: 'Alice' })
const room = await cero.open(me.room, { name: 'general' })
const inviteStr = await room.invite()
cero.watch(room.messages).on('data', ({ data }) => render(data))The same spec/index.js is imported on both sides. @cero-base/cero/build emits one file; its imports (hyperdb/runtime, hyperdispatch/runtime, hyperschema/runtime) are all pure JS — no native deps — so bundlers like vite handle the renderer bundle fine.
What you can do over RPC
Everything the local API offers, plus lifecycle methods on returned handles:
| Operator / method | Works over RPC |
| -------------------------------------------------------------------------------------------------------------------- | -------------- |
| cero.put / set / get / del / count / watch / call | ✓ |
| cero.open(ref, …) — create, join, load by id | ✓ |
| room.invite({ role, expiresIn, multiUse }) — mint an invite (multiUse: true keeps it alive after the first join) | ✓ |
| room.revoke(invite) — invalidate an outstanding invite; true if it existed | ✓ |
| room.close() — release the handle on the server | ✓ |
| room.leave() — remove yourself from the room and from your handles list | ✓ |
cero.watch(ref) returns a Readable on both sides; snapshots flow as a server-streamed RPC.
Wiring up a stream
A duplex stream is anything with write(buf, cb) and that emits data. Some real cases:
// Bare worker → renderer
import { Duplex } from 'streamx'
const ipc = new Duplex({
write(data, cb) {
window.bridge.writeWorkerIPC('/main.js', data)
cb()
}
})
window.bridge.onWorkerIPC('/main.js', (data) => ipc.push(data))
const me = await connect(ipc, spec)
// Two ceros in the same process (tests)
function streamPair() {
let a, b
a = new Duplex({
write(d, cb) {
b.push(d)
cb()
}
})
b = new Duplex({
write(d, cb) {
a.push(d)
cb()
}
})
return [a, b]
}
const [s, c] = streamPair()
await serve(s, { storage: './data', spec })
const remote = await connect(c, spec)Exports
| Path | What you get |
| ---------------------------- | ------------------------------------------------------------------------ |
| @cero-base/cero | factory + operators + schema DSL + define/bind |
| @cero-base/cero/client | connect(ipc, spec) — talk to a cero running in another process |
| @cero-base/cero/server | serve(ipc, { storage, spec }) — run + expose a cero over an IPC stream |
| @cero-base/cero/build | build(specDir, schema) — generate the on-disk spec |
| @cero-base/cero/extensions | bundled extensions (profileSync, …) |
Tests
npm testRuns the test files in parallel via xargs -P2 -n1 brittle-node (brittle v4). npm run build:test regenerates the test fixture spec.
Types
npm run build:typesEmits .d.ts from JSDoc into types/. Runs automatically on npm publish via prepublishOnly.
