@cero-base/cero 1.18.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -644
- package/package.json +16 -12
- package/src/build/index.js +21 -50
- package/src/build/internal.js +121 -0
- package/src/build/schemas.js +2 -8
- package/src/extensions/handle-sync.js +3 -10
- package/src/extensions/index.js +6 -0
- package/src/extensions/profile-sync.js +0 -5
- package/src/handle/index.js +255 -300
- package/src/index.js +65 -112
- package/src/lib/bluetooth.js +25 -56
- package/src/lib/constants.js +0 -15
- package/src/lib/operators.js +24 -74
- package/src/lib/peek.js +4 -8
- package/src/lib/refs.js +46 -0
- package/src/lib/spec.js +2 -3
- package/src/local/index.js +4 -5
- package/src/rpc/client.js +27 -39
- package/src/rpc/index.js +3 -3
- package/src/rpc/server.js +29 -40
- package/types/build/index.d.ts +19 -7
- package/types/build/internal.d.ts +78 -0
- package/types/build/schemas.d.ts +3 -3
- package/types/extensions/handle-sync.d.ts +4 -9
- package/types/extensions/index.d.ts +24 -2
- package/types/extensions/profile-sync.d.ts +2 -6
- package/types/handle/index.d.ts +218 -254
- package/types/index.d.ts +78 -102
- package/types/lib/bluetooth.d.ts +24 -46
- package/types/lib/constants.d.ts +5 -16
- package/types/lib/operators.d.ts +49 -77
- package/types/lib/peek.d.ts +3 -4
- package/types/lib/refs.d.ts +36 -0
- package/types/lib/spec.d.ts +5 -1
- package/types/local/index.d.ts +24 -23
- package/types/rpc/client.d.ts +107 -127
- package/types/rpc/index.d.ts +7 -2
- package/types/rpc/server.d.ts +62 -76
- package/src/build/builtins.js +0 -174
- package/src/lib/internal.js +0 -9
- package/src/lib/utils.js +0 -67
- package/types/build/builtins.d.ts +0 -100
- package/types/lib/internal.d.ts +0 -24
- package/types/lib/utils.d.ts +0 -55
package/README.md
CHANGED
|
@@ -1,682 +1,76 @@
|
|
|
1
|
-
# cero
|
|
1
|
+
# @cero-base/cero
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> [!IMPORTANT]
|
|
4
|
+
> This project is experimental. The API is subject to change and may break at any time.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
A simple peer-to-peer SDK on top of the [Pear](https://pears.com) stack by [Holepunch](https://holepunch.to).
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
// schema.js
|
|
9
|
-
import { cero } from '@cero-base/cero'
|
|
10
|
-
|
|
11
|
-
export const schema = cero.schema({
|
|
12
|
-
profile: cero.t.single({ name: cero.t.string }),
|
|
13
|
-
room: { messages: cero.t.collection({ text: cero.t.string }) }
|
|
14
|
-
})
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
```js
|
|
18
|
-
// build.js — run once; re-run when the schema changes
|
|
19
|
-
import { build } from '@cero-base/cero/build'
|
|
20
|
-
import { schema } from './schema.js'
|
|
21
|
-
|
|
22
|
-
await build('./spec', schema)
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
```js
|
|
26
|
-
import { cero } from '@cero-base/cero'
|
|
27
|
-
import { spec } from './spec/index.js'
|
|
28
|
-
|
|
29
|
-
const me = await cero('./data', spec)
|
|
30
|
-
await cero.set(me.profile, { name: 'Alice' })
|
|
31
|
-
|
|
32
|
-
const room = await cero.open(me.room, { name: 'general' })
|
|
33
|
-
const invite = await room.invite()
|
|
34
|
-
// room.revoke(invite) ← invalidate it before anyone joins
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
On another device — same built spec, its own identity and data dir:
|
|
38
|
-
|
|
39
|
-
```js
|
|
40
|
-
const peer = await cero('./peer-data', spec)
|
|
41
|
-
const joined = await cero.open(peer.room, invite)
|
|
42
|
-
await cero.put(joined.messages, { text: 'hi' })
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Ships with TypeScript declarations (`.d.ts`) generated from JSDoc.
|
|
46
|
-
|
|
47
|
-
## `cero(dir, spec, opts?)`
|
|
48
|
-
|
|
49
|
-
| Opt | Meaning |
|
|
50
|
-
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
51
|
-
| `bootstrap` | Hyperswarm bootstrap nodes. |
|
|
52
|
-
| `channel` | Optional network-isolation label. Peers connect only to peers on the **same** channel (it salts every swarm topic); omit it for the global network. A storage remembers its channel and refuses to reopen under a different one. Any string works. |
|
|
53
|
-
| `name` / `isMobile` | Stamped on the device's `add-writer` event. |
|
|
54
|
-
| `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. |
|
|
55
|
-
| `key` | Open against an existing bee key (multi-device flow). |
|
|
56
|
-
| `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. |
|
|
57
|
-
| `recoveryTimeout` | Bound on the recovery wait. |
|
|
58
|
-
| `routes` | Custom action handlers keyed by route name. |
|
|
59
|
-
| `encryptionKey` | Override the per-identity encryption key. |
|
|
60
|
-
| `storageKey` | 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it, and the same key must be passed on every open. Without it, key material sits plaintext on disk: rely on full-disk encryption. |
|
|
61
|
-
| `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. |
|
|
62
|
-
|
|
63
|
-
Reads `me.id`, `me.device`, `me.identity` for canonical metadata. `me.identity.toPhrase()` renders the seed phrase.
|
|
64
|
-
|
|
65
|
-
## `cero.peek(dir, spec)`
|
|
66
|
-
|
|
67
|
-
```js
|
|
68
|
-
const initialized = await cero.peek('./data', def) // true if an identity is stored
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Cheap probe — opens the local store, reads the master row, closes. Use it before deciding whether to show a setup screen or jump straight in.
|
|
72
|
-
|
|
73
|
-
## `cero.restore(me, phrase)`
|
|
74
|
-
|
|
75
|
-
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.
|
|
76
|
-
|
|
77
|
-
```js
|
|
78
|
-
const me = await cero('./data', def)
|
|
79
|
-
const recovered = await cero.restore(me, 'twelve words …')
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
## Schema
|
|
83
|
-
|
|
84
|
-
| Shape | Meaning |
|
|
85
|
-
| -------------------------- | ----------------------------------------------- |
|
|
86
|
-
| `cero.t.single({ … })` | one row (e.g. profile, settings) |
|
|
87
|
-
| `cero.t.collection({ … })` | many rows with auto ids (e.g. messages) |
|
|
88
|
-
| `{ … }` (plain object) | a child handle — its own scope, share by invite |
|
|
89
|
-
| `local: { … }` | device-only — never leaves this device |
|
|
90
|
-
|
|
91
|
-
A collection can declare **secondary indexes** for fast exact-field lookups:
|
|
92
|
-
|
|
93
|
-
```js
|
|
94
|
-
cero.t.collection(
|
|
95
|
-
{ text: cero.t.string },
|
|
96
|
-
{ indexes: { 'by-text': ['text'] } } // name → field(s); query via `cero.get(ref, { text: '…' })`
|
|
97
|
-
)
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
## Operators
|
|
101
|
-
|
|
102
|
-
Every operator takes a `ref` (e.g. `me.profile`, `room.messages`) as the first arg.
|
|
103
|
-
|
|
104
|
-
### `cero.put(ref, row)`
|
|
105
|
-
|
|
106
|
-
Append a row to a collection. Generates an `id`, `createdAt`, and `updatedAt` if you don't provide them.
|
|
107
|
-
|
|
108
|
-
```js
|
|
109
|
-
const { data } = await cero.put(room.messages, { text: 'hi' })
|
|
110
|
-
data.id // → 'abc…' (plus createdAt / updatedAt)
|
|
111
|
-
// `memberId` (the writer→member backlink) is stamped in the apply layer, so it
|
|
112
|
-
// surfaces on a later `cero.get`, not on the row this call returns.
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
### `cero.set(ref, row)`
|
|
116
|
-
|
|
117
|
-
Insert-or-update. For a `single` ref it replaces; for a `collection` it upserts by `id`.
|
|
118
|
-
|
|
119
|
-
```js
|
|
120
|
-
await cero.set(me.profile, { name: 'Alice' }) // single
|
|
121
|
-
await cero.set(room.messages, { id: 'abc', text: 'edited' }) // upsert by id
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
### `cero.get(ref, q?)`
|
|
125
|
-
|
|
126
|
-
Read. The shape of `q` decides what comes back:
|
|
127
|
-
|
|
128
|
-
```js
|
|
129
|
-
const { data } = await cero.get(me.profile) // single → the row (or null)
|
|
130
|
-
const { data } = await cero.get(room.messages, 'abc') // by id → the row (or null)
|
|
131
|
-
|
|
132
|
-
// collection → paginated, with range filters
|
|
133
|
-
const { data, total, size } = await cero.get(room.messages, {
|
|
134
|
-
gt: 'm-2025', // range: gt / gte / lt / lte
|
|
135
|
-
limit: 20,
|
|
136
|
-
reverse: true
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
// search → case-insensitive substring across string fields
|
|
140
|
-
const { data } = await cero.get(room.messages, { search: 'jo sm' })
|
|
141
|
-
// every space-separated term must match (AND); folds case + diacritics ('jose' ⇢ 'José').
|
|
142
|
-
// `fields` restricts which fields are searched (default: all string fields):
|
|
143
|
-
const { data } = await cero.get(room.guests, { search: 'jose', fields: ['name'] })
|
|
144
|
-
|
|
145
|
-
// exact field match → routed through a declared secondary index when the field has one
|
|
146
|
-
const { data } = await cero.get(room.messages, { text: 'alpha' })
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
`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.
|
|
150
|
-
|
|
151
|
-
### `cero.del(ref, id)`
|
|
152
|
-
|
|
153
|
-
Delete by id (collection) or clear (single).
|
|
154
|
-
|
|
155
|
-
```js
|
|
156
|
-
await cero.del(room.messages, 'abc')
|
|
157
|
-
await cero.del(me.profile)
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
### `cero.count(ref, q?)`
|
|
161
|
-
|
|
162
|
-
```js
|
|
163
|
-
const { data: n } = await cero.count(room.messages)
|
|
164
|
-
const { data: n } = await cero.count(room.messages, { gt: 'm-2025' })
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
### `cero.watch(ref, q?, opts?)`
|
|
168
|
-
|
|
169
|
-
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.
|
|
170
|
-
|
|
171
|
-
```js
|
|
172
|
-
const stream = cero.watch(room.messages, { limit: 50, reverse: true })
|
|
173
|
-
for await (const { data, total, size } of stream) {
|
|
174
|
-
/* render */
|
|
175
|
-
}
|
|
176
|
-
// stops on room.close(), on stream.destroy(), or on `signal` abort:
|
|
177
|
-
const ac = new AbortController()
|
|
178
|
-
cero.watch(room.messages, null, { signal: ac.signal }) // ac.abort() ⇒ destroyed
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
### `cero.changes(ref, q?, opts?)`
|
|
182
|
-
|
|
183
|
-
Delta subscription: instead of full snapshots, batches of `{ prev, next }` row pairs — insert (`prev: null`), update (both), delete (`next: null`). Self-contained: the first batch (and any batch after recovery or an upgrade rebuild) replays the current matching rows as inserts with `reset: true`, so folding every batch into a Map always reconstructs current state — no separate `get`, no attach race:
|
|
184
|
-
|
|
185
|
-
```js
|
|
186
|
-
const rows = new Map()
|
|
187
|
-
for await (const { changes, reset } of cero.changes(room.messages)) {
|
|
188
|
-
if (reset) rows.clear()
|
|
189
|
-
for (const { prev, next } of changes) {
|
|
190
|
-
if (next) rows.set(next.id, next)
|
|
191
|
-
else rows.delete(prev.id)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
Lossless under backpressure — a slow consumer gets fewer, bigger batches, never dropped ones. Query `gt/gte/lt/lte`, equality fields, and `search` scope the deltas (a row updated out of the filter arrives as `prev`-only); `limit`/`reverse` are not applied — deltas are unwindowed, windowing is `watch`'s job. Over RPC the wire carries only the changed rows, not the result set. Prefer `changes` for accumulating UIs (chat logs, ever-growing lists); prefer `watch` for windowed views. File-typed fields resolve on both sides. Handle refs are not supported.
|
|
197
|
-
|
|
198
|
-
### `cero.call(actionRef, data)`
|
|
199
|
-
|
|
200
|
-
Invoke an action defined in your schema with `t.action({ … })`. Custom write paths; the dispatcher handles encoding/decoding.
|
|
201
|
-
|
|
202
|
-
```js
|
|
203
|
-
await cero.call(room.promote, { memberId: 'xyz', role: 'admin' })
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
### `cero.open(handleRef, arg)`
|
|
207
|
-
|
|
208
|
-
The universal handle entry point. Dispatch is based on `arg`:
|
|
209
|
-
|
|
210
|
-
```js
|
|
211
|
-
await cero.open(me.room, { name: 'general' }) // create — opts go through to _create
|
|
212
|
-
await cero.open(me.room, inviteString) // join — `arg` is the invite string
|
|
213
|
-
await cero.open(me.room, { invite: inviteString }) // join — explicit form
|
|
214
|
-
await cero.open(me.room, { id: existingRoomId }) // load an existing room by id
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
Returns the child handle, ready to operate on (`cero.put(joined.messages, …)` etc.).
|
|
218
|
-
|
|
219
|
-
### `cero.rotate(handle)`
|
|
220
|
-
|
|
221
|
-
Rotate the handle's encryption epoch. A fresh secret is sealed to every current
|
|
222
|
-
member and announced through the log — members removed **before** the rotation
|
|
223
|
-
cannot decrypt anything written after it (rows **and** files). Requires the
|
|
224
|
-
`remove` permission (admin or owner). Returns `{ epoch }`.
|
|
225
|
-
|
|
226
|
-
```js
|
|
227
|
-
await cero.del(room.members, memberId) // revoke write access
|
|
228
|
-
await cero.rotate(room) // revoke read access for everything that follows
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
Rotation is opt-in per room: without it, `del` alone is a soft removal (write
|
|
232
|
-
revocation only). Once a room has rotated at least once, later removals
|
|
233
|
-
self-heal — a `del` without an explicit `rotate` triggers an automatic re-key
|
|
234
|
-
from any online admin device. Standalone rotations (no removal) are valid too,
|
|
235
|
-
as periodic key hygiene. Full design: [`docs/key-rotation.md`](../../docs/key-rotation.md).
|
|
236
|
-
|
|
237
|
-
### Watching removals
|
|
238
|
-
|
|
239
|
-
There is no separate removal event — `members` is a collection, so the normal
|
|
240
|
-
streams already are the membership feed. A removal arrives as an ordinary
|
|
241
|
-
delete (`next: null`), carrying the row that was removed:
|
|
242
|
-
|
|
243
|
-
```js
|
|
244
|
-
for await (const { changes } of cero.changes(room.members)) {
|
|
245
|
-
for (const { prev, next } of changes) {
|
|
246
|
-
if (next === null) console.log('removed:', prev.name)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
To detect **your own** removal — the signal an app renders as "you were removed
|
|
252
|
-
from this room" — listen for the store losing writability:
|
|
253
|
-
|
|
254
|
-
```js
|
|
255
|
-
room.store.on('unwritable', () => onRemoved()) // freeze the UI, close the room
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
This works even when the removal is followed by a rotation: the removal lands
|
|
259
|
-
before the new key, so a removed member always receives it. After that their
|
|
260
|
-
streams stay open but go silent — reads freeze at the moment of removal and
|
|
261
|
-
writes reject with `NOT_WRITABLE`.
|
|
262
|
-
|
|
263
|
-
### Store events
|
|
264
|
-
|
|
265
|
-
Beyond the ref streams, the store reports its own lifecycle:
|
|
266
|
-
|
|
267
|
-
```js
|
|
268
|
-
room.store.on('writable', () => {}) // admitted — this device can write
|
|
269
|
-
room.store.on('unwritable', () => {}) // access ended (removed)
|
|
270
|
-
room.store.on('update', () => {}) // an apply batch committed
|
|
271
|
-
room.store.on('behind', (v) => {}) // ops from a newer app version — see below
|
|
272
|
-
room.store.on('rebuild', () => {}) // view replayed after catching up
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
`before`/`after` hooks only fire on the device performing the write. To observe
|
|
276
|
-
**every** applied op — local _and_ replicated — use `onApply`, which returns an
|
|
277
|
-
unsubscribe fn:
|
|
278
|
-
|
|
279
|
-
```js
|
|
280
|
-
const off = room.store.onApply(({ op, name, row, writerKey, seq }) => {
|
|
281
|
-
if (op === 'del' && name === 'member') auditLog(row)
|
|
282
|
-
})
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
The callback runs synchronously inside apply, so keep it cheap — enqueue and
|
|
286
|
-
return. It costs nothing when nobody is subscribed.
|
|
287
|
-
|
|
288
|
-
### `cero.before(ref, fn, opts?)` / `cero.after(ref, fn, opts?)`
|
|
289
|
-
|
|
290
|
-
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).
|
|
291
|
-
|
|
292
|
-
```js
|
|
293
|
-
const off = cero.before(room.messages, (ctx) => {
|
|
294
|
-
if (!ctx.row.text?.trim()) return false // reject empty messages
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
cero.after(me.profile, ({ row }) => {
|
|
298
|
-
/* react to your profile changing */
|
|
299
|
-
})
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
`ctx` is `{ op, name, row }` (plus `result` in `after`).
|
|
303
|
-
|
|
304
|
-
## Files
|
|
305
|
-
|
|
306
|
-
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.
|
|
307
|
-
|
|
308
|
-
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`:
|
|
8
|
+
Describe your data. cero stores it on the device, syncs it across your devices and shares it with the people you invite. No server.
|
|
309
9
|
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
data: bytes, // Buffer | Readable
|
|
313
|
-
name: 'cat.jpg',
|
|
314
|
-
type: 'image/jpeg'
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
img.src = file.url
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
Read them back — every row carries a fresh `.url`:
|
|
321
|
-
|
|
322
|
-
```js
|
|
323
|
-
const { data: files } = await cero.get(me.files) // list all
|
|
324
|
-
const { data: one } = await cero.get(me.files, file.id) // one, by id
|
|
325
|
-
cero.watch(me.files).on('data', ({ data }) => render(data)) // live
|
|
10
|
+
```sh
|
|
11
|
+
npm install @cero-base/cero
|
|
326
12
|
```
|
|
327
13
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
For a file referenced from a row — an avatar, a room icon, a message attachment — declare the field `cero.t.file` (a bare marker, not a call). Store the file's `id`; read it back already resolved to `{ id, name, type, size, url }`, with **no extra lookup**:
|
|
14
|
+
A cero app is three files.
|
|
331
15
|
|
|
332
16
|
```js
|
|
333
17
|
// schema.js
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// save: upload, then store the id on the row
|
|
337
|
-
const { data: pic } = await cero.put(me.files, { data: bytes, type: 'image/png' })
|
|
338
|
-
await cero.set(me.profile, { avatar: pic.id })
|
|
339
|
-
|
|
340
|
-
// read: the avatar comes back url-ready
|
|
341
|
-
const { data: profile } = await cero.get(me.profile)
|
|
342
|
-
img.src = profile.avatar.url
|
|
343
|
-
```
|
|
18
|
+
import { cero, t } from '@cero-base/cero'
|
|
344
19
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
## Custom operators
|
|
350
|
-
|
|
351
|
-
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.
|
|
352
|
-
|
|
353
|
-
```js
|
|
354
|
-
// guest.js — pure functions, one per export
|
|
355
|
-
import { put, del } from '@cero-base/cero'
|
|
356
|
-
import { ensureGuestId } from './ids.js'
|
|
357
|
-
|
|
358
|
-
export const create = (room, data) => put(room.guests, { ...data, id: ensureGuestId(data.id) })
|
|
359
|
-
export const remove = (room, id) => del(room.guests, id)
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
### `cero.define(map)`
|
|
363
|
-
|
|
364
|
-
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:
|
|
365
|
-
|
|
366
|
-
```js
|
|
367
|
-
import * as user from './user.js'
|
|
368
|
-
import * as guest from './guest.js'
|
|
369
|
-
import * as station from './station.js'
|
|
370
|
-
|
|
371
|
-
cero.define({
|
|
372
|
-
user, // bound on the root → me.user.rename(…)
|
|
373
|
-
room: { guest, station } // bound on each room → room.guest.create(…)
|
|
20
|
+
export const schema = cero.schema({
|
|
21
|
+
notes: t.collection({ title: t.string, body: t.string }),
|
|
22
|
+
room: { messages: t.collection({ text: t.string }) }
|
|
374
23
|
})
|
|
375
|
-
|
|
376
|
-
const room = await cero.open(me.room, { id })
|
|
377
|
-
await room.guest.create({ name: 'Bob' }) // no binding at the call site
|
|
378
24
|
```
|
|
379
25
|
|
|
380
|
-
cero 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.
|
|
381
|
-
|
|
382
|
-
### `cero.bind(handle, map)`
|
|
383
|
-
|
|
384
|
-
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:
|
|
385
|
-
|
|
386
26
|
```js
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
await room.guest.create({ name: 'Bob' }) // → guest.create(room, …)
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
Non-functions in the map are skipped; the handle is returned.
|
|
394
|
-
|
|
395
|
-
## Extensions
|
|
396
|
-
|
|
397
|
-
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.
|
|
398
|
-
|
|
399
|
-
The two bundled extensions (`profileSync`, `handleSync`) are **on by default** — no `cero.use()` needed. To reconfigure one, `cero.use(profileSync({ fields }))` replaces the default (named extensions replace by name, so double registration is harmless). To leave them out entirely, pass `{ extensions: false }` to **both** `build()` and `cero()` — the built spec and the runtime must agree.
|
|
400
|
-
|
|
401
|
-
```js
|
|
402
|
-
import { profileSync } from '@cero-base/cero/extensions'
|
|
27
|
+
// build.js, run once and after every schema change
|
|
28
|
+
import { build } from '@cero-base/cero/build'
|
|
29
|
+
import { schema } from './schema.js'
|
|
403
30
|
|
|
404
|
-
cero.use(profileSync({ fields: { status: cero.t.string } })) // reconfigure the default
|
|
405
31
|
await build('./spec', schema)
|
|
406
32
|
```
|
|
407
33
|
|
|
408
|
-
An extension is two optional parts:
|
|
409
|
-
|
|
410
|
-
```js
|
|
411
|
-
function myExtension() {
|
|
412
|
-
return {
|
|
413
|
-
schema: { members: cero.t.extend({ status: cero.t.string }) }, // merged into your schema
|
|
414
|
-
setup(me) {
|
|
415
|
-
const onHandle = (room, opts) => {
|
|
416
|
-
/* opts carries the open args (e.g. opts.name on create); me.children is the set of open rooms */
|
|
417
|
-
}
|
|
418
|
-
me.on('handle', onHandle, { signal: me.signal }) // dropped on me.close()
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
For a behavior-only extension (no schema), pass a function directly — it's shorthand for `{ setup }`:
|
|
425
|
-
|
|
426
|
-
```js
|
|
427
|
-
cero.use((me) => {
|
|
428
|
-
me.on('handle', (room) => {
|
|
429
|
-
/* … */
|
|
430
|
-
})
|
|
431
|
-
})
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
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).
|
|
435
|
-
|
|
436
|
-
### Cleanup
|
|
437
|
-
|
|
438
|
-
You rarely write teardown — a handle's subscriptions follow the same "dies with the node" model as DOM events:
|
|
439
|
-
|
|
440
|
-
- **`cero.watch(ref)` streams** are destroyed automatically when the ref's handle closes.
|
|
441
|
-
- **`me.on(...)`, `before`, `after`** take `{ signal }` to drop themselves when an `AbortSignal` fires.
|
|
442
|
-
- **`me.signal`** is an `AbortSignal` that aborts on `me.close()`. Pass it to tie a subscription to the handle's life, or use your own `AbortController` for a finer scope (drop it before the handle closes):
|
|
443
|
-
|
|
444
|
-
```js
|
|
445
|
-
setup(me) {
|
|
446
|
-
me.on('handle', onHandle, { signal: me.signal }) // dropped when me closes
|
|
447
|
-
cero.after(me.profile, onProfile, { signal: me.signal })
|
|
448
|
-
}
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
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`:
|
|
452
|
-
|
|
453
|
-
```js
|
|
454
|
-
setup(me) {
|
|
455
|
-
const timer = setInterval(() => ping(me), 30_000)
|
|
456
|
-
me.own({ destroy: () => clearInterval(timer) }) // tied to me.close()
|
|
457
|
-
// …or: return () => clearInterval(timer)
|
|
458
|
-
}
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
### `profileSync` (bundled)
|
|
462
|
-
|
|
463
|
-
On by default. 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`:
|
|
464
|
-
|
|
465
|
-
```js
|
|
466
|
-
cero.use(profileSync({ fields: { status: cero.t.string } })) // sync extra fields
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
Want 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.
|
|
470
|
-
|
|
471
|
-
### `handleSync` (bundled)
|
|
472
|
-
|
|
473
|
-
On by default. 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.
|
|
474
|
-
|
|
475
|
-
```js
|
|
476
|
-
// your handle type declares a `profile`; your app sets it:
|
|
477
|
-
const room = await cero.open(me.room)
|
|
478
|
-
await cero.set(room.profile, { name: 'general', avatar: 'pic.png' })
|
|
479
|
-
// → the me.handles row now carries name + avatar — render the room list, no opens
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
Requires your handle types to declare a `profile` single; handles without one are left untouched.
|
|
483
|
-
|
|
484
|
-
## RPC
|
|
485
|
-
|
|
486
|
-
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).
|
|
487
|
-
|
|
488
|
-
The client side gets the **same operator API** as a local cero — `cero.put`, `cero.set`, `cero.open`, etc. all work over the wire.
|
|
489
|
-
|
|
490
|
-
### Server (the process that owns the data)
|
|
491
|
-
|
|
492
|
-
```js
|
|
493
|
-
// workers/main.js — e.g. a Bare worker or Node background process
|
|
494
|
-
import { serve } from '@cero-base/cero/server'
|
|
495
|
-
import { spec } from './spec/index.js'
|
|
496
|
-
|
|
497
|
-
// serve builds and owns the root cero — pass storage + the built spec, not a handle.
|
|
498
|
-
// Returns a Server instance (call server.close() to shut down).
|
|
499
|
-
const server = await serve(Bare.IPC, { storage: './data', spec, phrase: '…' })
|
|
500
|
-
```
|
|
501
|
-
|
|
502
|
-
### Client (the UI process)
|
|
503
|
-
|
|
504
34
|
```js
|
|
505
|
-
//
|
|
35
|
+
// index.js
|
|
506
36
|
import { cero } from '@cero-base/cero'
|
|
507
|
-
import { connect } from '@cero-base/cero/client'
|
|
508
37
|
import { spec } from './spec/index.js'
|
|
509
38
|
|
|
510
|
-
const me = await
|
|
39
|
+
const me = await cero('./data', spec)
|
|
40
|
+
await cero.put(me.notes, { title: 'first', body: 'hello' })
|
|
511
41
|
|
|
512
|
-
await cero.set(me.profile, { name: 'Alice' })
|
|
513
42
|
const room = await cero.open(me.room, { name: 'general' })
|
|
514
|
-
|
|
515
|
-
cero.watch(room.messages).on('data', ({ data }) => render(data))
|
|
43
|
+
console.log(await room.invite()) // give this to a friend
|
|
516
44
|
```
|
|
517
45
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
### What you can do over RPC
|
|
521
|
-
|
|
522
|
-
Everything the local API offers, plus lifecycle methods on returned handles:
|
|
523
|
-
|
|
524
|
-
| Operator / method | Works over RPC |
|
|
525
|
-
| -------------------------------------------------------------------------------------------------------------- | -------------- |
|
|
526
|
-
| `cero.put` / `set` / `get` / `del` / `count` / `watch` / `call` | ✓ |
|
|
527
|
-
| `cero.open(ref, …)` — create, join, load by id | ✓ |
|
|
528
|
-
| `room.invite({ role, expiresIn, reuse })` — mint an invite (`reuse: true` keeps it alive after the first join) | ✓ |
|
|
529
|
-
| `room.revoke(invite)` — invalidate an outstanding invite; `true` if it existed | ✓ |
|
|
530
|
-
| `room.close()` — release the handle on the server | ✓ |
|
|
531
|
-
| `room.leave()` — remove yourself from the room and from your handles list | ✓ |
|
|
532
|
-
|
|
533
|
-
`cero.watch(ref)` returns a Readable on both sides; snapshots flow as a server-streamed RPC.
|
|
534
|
-
|
|
535
|
-
### Wiring up a stream
|
|
536
|
-
|
|
537
|
-
A duplex stream is anything with `write(buf, cb)` and that emits `data`. Some real cases:
|
|
46
|
+
Your friend, on their own machine:
|
|
538
47
|
|
|
539
48
|
```js
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const ipc = new Duplex({
|
|
543
|
-
write(data, cb) {
|
|
544
|
-
window.bridge.writeWorkerIPC('/main.js', data)
|
|
545
|
-
cb()
|
|
546
|
-
}
|
|
547
|
-
})
|
|
548
|
-
window.bridge.onWorkerIPC('/main.js', (data) => ipc.push(data))
|
|
549
|
-
const me = await connect(ipc, spec)
|
|
550
|
-
|
|
551
|
-
// Two ceros in the same process (tests)
|
|
552
|
-
function streamPair() {
|
|
553
|
-
let a, b
|
|
554
|
-
a = new Duplex({
|
|
555
|
-
write(d, cb) {
|
|
556
|
-
b.push(d)
|
|
557
|
-
cb()
|
|
558
|
-
}
|
|
559
|
-
})
|
|
560
|
-
b = new Duplex({
|
|
561
|
-
write(d, cb) {
|
|
562
|
-
a.push(d)
|
|
563
|
-
cb()
|
|
564
|
-
}
|
|
565
|
-
})
|
|
566
|
-
return [a, b]
|
|
567
|
-
}
|
|
568
|
-
const [s, c] = streamPair()
|
|
569
|
-
await serve(s, { storage: './data', spec })
|
|
570
|
-
const remote = await connect(c, spec)
|
|
49
|
+
const room = await cero.open(me.room, { invite })
|
|
50
|
+
for await (const { data } of cero.watch(room.messages)) console.log(data)
|
|
571
51
|
```
|
|
572
52
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
Sync with no internet at all. Bluetooth is a transport, not a parallel API — it only changes _how peers meet and carry bytes_; the same refs, guards, roles, and reactivity ride over it unchanged. Volunteers in range converge; hop-by-hop gossip heals partitions as people move.
|
|
576
|
-
|
|
577
|
-
```js
|
|
578
|
-
const me = await cero(dir, spec, { channel, bluetooth: true })
|
|
579
|
-
|
|
580
|
-
me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
|
|
581
|
-
await me.bluetooth.start() // runtime toggle
|
|
582
|
-
await me.bluetooth.stop()
|
|
583
|
-
me.bluetooth.peers // Map of live BLE links
|
|
584
|
-
me.bluetooth.on('update', () => {}) // state / peer changes
|
|
585
|
-
```
|
|
53
|
+
That is a working, encrypted, offline-first, peer-to-peer app. Your phrase, `me.identity.toPhrase()`, opens the same identity on any other device.
|
|
586
54
|
|
|
587
|
-
|
|
55
|
+
## Docs
|
|
588
56
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const invite = await room.invite({ role: 'member', expiresIn: 3600_000 })
|
|
594
|
-
const stop = me.bluetooth.announce(invite)
|
|
595
|
-
// … QR closed:
|
|
596
|
-
stop() // stops the rendezvous; an established link stays and carries the initial replication
|
|
597
|
-
|
|
598
|
-
// late volunteer — the exact same code as online:
|
|
599
|
-
const room = await cero.open(me.room, invite) // rendezvouses over BLE automatically
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
The advertisement auto-stops at the invite's `expiresIn` — a photographed QR must not stay an ambient admission ticket.
|
|
603
|
-
|
|
604
|
-
## App versions & rollouts
|
|
605
|
-
|
|
606
|
-
Rooms are permanent replicated logs, and after release your users will run
|
|
607
|
-
different app versions side by side. Every op cero writes carries the app's
|
|
608
|
-
contract version (stamped into the generated spec by `build` — it advances
|
|
609
|
-
automatically whenever the schema changes). Peers handle version skew
|
|
610
|
-
deterministically:
|
|
611
|
-
|
|
612
|
-
- Ops from a **newer** version are skipped — they stay in the log, they are
|
|
613
|
-
not errors, and nothing diverges. The store reports it:
|
|
614
|
-
|
|
615
|
-
```js
|
|
616
|
-
me.store.behind // highest future version seen, or null
|
|
617
|
-
me.store.on('behind', (version) => showUpdatePrompt())
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
- After the app **upgrades** past everything it skipped, the next open
|
|
621
|
-
rebuilds the local view from the log through the new handlers — the
|
|
622
|
-
previously-skipped ops apply, nothing is lost, and the store emits
|
|
623
|
-
`'rebuild'` once.
|
|
624
|
-
- Apps built before versioned ops existed read as version 0 and interoperate
|
|
625
|
-
unchanged.
|
|
626
|
-
|
|
627
|
-
Ship gradual rollouts freely; old peers keep working on everything they
|
|
628
|
-
understand and know when to prompt for an update.
|
|
629
|
-
|
|
630
|
-
## Mirrors (offline sync)
|
|
631
|
-
|
|
632
|
-
Two peers can only sync while both are online. A **blind peer** is an always-on relay that holds your rooms' encrypted blocks and serves them to other members — so a peer can pick up messages that were sent while it was offline. It's _blind_: rooms are end-to-end encrypted, so the mirror stores ciphertext and never sees your data.
|
|
633
|
-
|
|
634
|
-
Pass mirror public keys and every room and file is mirrored automatically:
|
|
635
|
-
|
|
636
|
-
```js
|
|
637
|
-
const me = await cero(dir, spec, {
|
|
638
|
-
mirrors: ['<blind-peer-public-key>'] // hex or z32
|
|
639
|
-
})
|
|
640
|
-
```
|
|
641
|
-
|
|
642
|
-
Nothing else changes — the operators, invites, and roles work exactly as before; mirrors only add availability. Keys carry through account recovery (`cero.restore`).
|
|
643
|
-
|
|
644
|
-
**Running a mirror.** The `blind-peer` package is the server (a library — see `holepunchto/blind-peer-cli` for a turnkey host). A minimal self-hosted mirror:
|
|
645
|
-
|
|
646
|
-
```js
|
|
647
|
-
import Hyperswarm from 'hyperswarm'
|
|
648
|
-
import BlindPeer from 'blind-peer'
|
|
649
|
-
|
|
650
|
-
const swarm = new Hyperswarm()
|
|
651
|
-
const mirror = new BlindPeer('./mirror-store', { swarm })
|
|
652
|
-
await mirror.listen()
|
|
653
|
-
console.log('mirror key:', mirror.publicKey.toString('hex')) // give this to cero({ mirrors })
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
Point your app's `mirrors` at that key. Storage grows with the rooms it holds; `blind-peer` GCs least-recently-used data past its `maxBytes` limit.
|
|
57
|
+
- [Quickstart](https://github.com/lekinox/cero-base/blob/main/docs/quickstart.md)
|
|
58
|
+
- [Guides](https://github.com/lekinox/cero-base/blob/main/docs/README.md#guides): schema, data, rooms, identity, apps, extensions, files, network
|
|
59
|
+
- [API reference](https://github.com/lekinox/cero-base/blob/main/docs/api.md)
|
|
60
|
+
- [Examples](https://github.com/lekinox/cero-base/blob/main/docs/examples.md)
|
|
657
61
|
|
|
658
62
|
## Exports
|
|
659
63
|
|
|
660
|
-
|
|
|
661
|
-
| ---------------------------- |
|
|
662
|
-
| `@cero-base/cero` |
|
|
663
|
-
| `@cero-base/cero/
|
|
664
|
-
| `@cero-base/cero/server` | `serve(ipc,
|
|
665
|
-
| `@cero-base/cero/
|
|
666
|
-
| `@cero-base/cero/extensions` |
|
|
667
|
-
|
|
668
|
-
## Tests
|
|
669
|
-
|
|
670
|
-
```sh
|
|
671
|
-
npm test
|
|
672
|
-
```
|
|
673
|
-
|
|
674
|
-
Runs the test files in parallel via `xargs -P2 -n1 brittle-node` (brittle v4). `npm run build:test` regenerates the test fixture spec.
|
|
64
|
+
| Subpath | What it gives you |
|
|
65
|
+
| ---------------------------- | ---------------------------------------------------------------------------- |
|
|
66
|
+
| `@cero-base/cero` | `cero`, `t`, `schema`, `restore`, `peek`, `Handle`, `Ref`, `Local` |
|
|
67
|
+
| `@cero-base/cero/build` | `build(specDir, schema, opts)` |
|
|
68
|
+
| `@cero-base/cero/server` | `serve(ipc, spec, opts)` and `Server`, the process that owns the data |
|
|
69
|
+
| `@cero-base/cero/client` | `connect(ipc, spec)`, `Client`, `restore` and the operators for a UI process |
|
|
70
|
+
| `@cero-base/cero/extensions` | `profileSync`, `handleSync` |
|
|
675
71
|
|
|
676
|
-
|
|
72
|
+
Built on the [Pear](https://pears.com) stack by [Holepunch](https://holepunch.to): Hypercore, Autobee, HyperDB, Hyperblobs, Hyperswarm, blind-pairing, blind-peering and Bare. Runs on Node and Bare, with TypeScript declarations.
|
|
677
73
|
|
|
678
|
-
|
|
679
|
-
npm run build:types
|
|
680
|
-
```
|
|
74
|
+
## License
|
|
681
75
|
|
|
682
|
-
|
|
76
|
+
Apache-2.0
|