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