@cero-base/cero 1.4.0 → 1.5.1

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 CHANGED
@@ -178,6 +178,23 @@ const ac = new AbortController()
178
178
  cero.watch(room.messages, null, { signal: ac.signal }) // ac.abort() ⇒ destroyed
179
179
  ```
180
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
+
181
198
  ### `cero.call(actionRef, data)`
182
199
 
183
200
  Invoke an action defined in your schema with `t.action({ … })`. Custom write paths; the dispatcher handles encoding/decoding.
@@ -515,6 +532,60 @@ const room = await cero.open(me.room, invite) // rendezvouses over BLE automatic
515
532
 
516
533
  The advertisement auto-stops at the invite's `expiresIn` — a photographed QR must not stay an ambient admission ticket.
517
534
 
535
+ ## App versions & rollouts
536
+
537
+ Rooms are permanent replicated logs, and after release your users will run
538
+ different app versions side by side. Every op cero writes carries the app's
539
+ contract version (stamped into the generated spec by `build` — it advances
540
+ automatically whenever the schema changes). Peers handle version skew
541
+ deterministically:
542
+
543
+ - Ops from a **newer** version are skipped — they stay in the log, they are
544
+ not errors, and nothing diverges. The store reports it:
545
+
546
+ ```js
547
+ me.store.behind // highest future version seen, or null
548
+ me.store.on('behind', (version) => showUpdatePrompt())
549
+ ```
550
+
551
+ - After the app **upgrades** past everything it skipped, the next open
552
+ rebuilds the local view from the log through the new handlers — the
553
+ previously-skipped ops apply, nothing is lost, and the store emits
554
+ `'rebuild'` once.
555
+ - Apps built before versioned ops existed read as version 0 and interoperate
556
+ unchanged.
557
+
558
+ Ship gradual rollouts freely; old peers keep working on everything they
559
+ understand and know when to prompt for an update.
560
+
561
+ ## Mirrors (offline sync)
562
+
563
+ 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.
564
+
565
+ Pass mirror public keys and every room and file is mirrored automatically:
566
+
567
+ ```js
568
+ const me = await cero(dir, spec, {
569
+ mirrors: ['<blind-peer-public-key>'] // hex or z32
570
+ })
571
+ ```
572
+
573
+ Nothing else changes — the operators, invites, and roles work exactly as before; mirrors only add availability. Keys carry through account recovery (`cero.restore`).
574
+
575
+ **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:
576
+
577
+ ```js
578
+ import Hyperswarm from 'hyperswarm'
579
+ import BlindPeer from 'blind-peer'
580
+
581
+ const swarm = new Hyperswarm()
582
+ const mirror = new BlindPeer('./mirror-store', { swarm })
583
+ await mirror.listen()
584
+ console.log('mirror key:', mirror.publicKey.toString('hex')) // give this to cero({ mirrors })
585
+ ```
586
+
587
+ 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.
588
+
518
589
  ## Exports
519
590
 
520
591
  | Path | What you get |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -74,6 +74,14 @@
74
74
  "crypto": {
75
75
  "bare": "bare-crypto",
76
76
  "default": "crypto"
77
+ },
78
+ "url": {
79
+ "bare": "bare-url",
80
+ "default": "url"
81
+ },
82
+ "events": {
83
+ "bare": "bare-events",
84
+ "default": "events"
77
85
  }
78
86
  },
79
87
  "scripts": {
@@ -81,23 +89,24 @@
81
89
  "build:types": "rm -rf types && tsc -p .",
82
90
  "pretest": "npm run build:test",
83
91
  "prepublishOnly": "npm run build:types",
84
- "test": "ls test/*.test.js | xargs -P1 -n1 brittle-node",
92
+ "test": "npm run test:node",
85
93
  "pretest:bare": "npm run build:test",
86
- "test:bare": "npx bare test/bare-smoke.js"
94
+ "test:bare": "ls test/*.test.js | xargs -P1 -n1 brittle-bare test/bare.js",
95
+ "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
87
96
  },
88
97
  "dependencies": {
89
- "@cero-base/core": "^1.4.0",
98
+ "@cero-base/core": "^1.5.1",
90
99
  "b4a": "^1.8.1",
91
100
  "bare-abort-controller": "^1.1.2",
92
101
  "bare-crypto": "^1.15.3",
93
- "bare-fs": "^4.7.2",
94
- "bare-path": "^3.0.1",
95
- "compact-encoding": "^3.2.0",
96
- "corestore": "^7.10.1",
102
+ "bare-fs": "^4.7.4",
103
+ "bare-path": "^3.1.1",
104
+ "compact-encoding": "^3.3.0",
105
+ "corestore": "^7.11.1",
97
106
  "hrpc": "^4.3.0",
98
- "hypercore": "^11.33.1",
107
+ "hypercore": "^11.34.1",
99
108
  "hypercore-crypto": "^3.7.0",
100
- "hypercore-storage": "^3.1.1",
109
+ "hypercore-storage": "^3.2.0",
101
110
  "hyperdb": "^6.7.0",
102
111
  "hyperdispatch": "^1.6.0",
103
112
  "hyperschema": "^1.21.0",
@@ -108,7 +117,11 @@
108
117
  },
109
118
  "devDependencies": {
110
119
  "@hyperswarm/testnet": "^3.1.4",
111
- "brittle": "^4.0.2",
120
+ "bare-events": "^2.9.1",
121
+ "bare-fetch": "^3.2.0",
122
+ "bare-process": "^4.5.1",
123
+ "bare-url": "^2.4.6",
124
+ "brittle": "^4.1.0",
112
125
  "typescript": "^5.9.3"
113
126
  },
114
127
  "license": "Apache-2.0",
package/src/bluetooth.js CHANGED
@@ -41,12 +41,18 @@ export class Bluetooth extends ReadyResource {
41
41
  * @param {object} [opts]
42
42
  * @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
43
43
  * @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
44
+ * @param {number} [opts.maxOutbound] Max concurrent outbound links; gossip covers the rest.
45
+ * @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
44
46
  */
45
- constructor(handle, { backend, autoStart } = {}) {
47
+ constructor(handle, { backend, autoStart, maxOutbound, maxInbound } = {}) {
46
48
  super()
47
49
  this._handle = handle
50
+ // explicit null/false disables bluetooth; only an omitted backend lazy-loads
48
51
  this._backend = backend || null
52
+ this._lazy = backend === undefined
49
53
  this._autoStart = autoStart === true
54
+ this._maxOutbound = maxOutbound
55
+ this._maxInbound = maxInbound
50
56
  this._transport = null
51
57
  this._name = null
52
58
  this._announces = new Set()
@@ -101,7 +107,7 @@ export class Bluetooth extends ReadyResource {
101
107
  }
102
108
 
103
109
  async _open() {
104
- if (this._backend === null) this._backend = await loadBackend()
110
+ if (this._backend === null && this._lazy) this._backend = await loadBackend()
105
111
  if (!this._backend) {
106
112
  this.state = 'unsupported'
107
113
  return
@@ -139,14 +145,19 @@ export class Bluetooth extends ReadyResource {
139
145
  : handle.identity.publicKey,
140
146
  nodeId: handle.identity.publicKey,
141
147
  name: this._name || '',
148
+ maxOutbound: this._maxOutbound,
149
+ maxInbound: this._maxInbound,
142
150
  // Android scans in a battery-saver mode by default — too slow for a mesh
143
151
  scanOptions:
144
152
  typeof Bare !== 'undefined' && Bare.platform === 'android'
145
153
  ? { scanMode: this._backend.Central.SCAN_MODE_LOW_LATENCY }
146
154
  : undefined
147
155
  })
148
- this._transport.on('update', () => {
149
- this.state = this._transport.state
156
+ const transport = this._transport
157
+ transport.on('update', () => {
158
+ // a torn-down or replaced transport can still emit late adapter events
159
+ if (this._transport !== transport) return
160
+ this.state = transport.state
150
161
  this.emit('update')
151
162
  })
152
163
  try {
@@ -140,6 +140,11 @@ export const rpcCommands = (ns) => {
140
140
  request: { name: ref('req-handle') },
141
141
  response: { name: ref('res-ok') }
142
142
  },
143
- { name: 'leave', request: { name: ref('req-handle') }, response: { name: ref('res-ok') } }
143
+ { name: 'leave', request: { name: ref('req-handle') }, response: { name: ref('res-ok') } },
144
+ {
145
+ name: 'changes',
146
+ request: { name: ref('req-query') },
147
+ response: { name: ref('res-changes'), stream: true }
148
+ }
144
149
  ]
145
150
  }
@@ -85,6 +85,7 @@ export async function build(specDir, schema, { ns = NS, extensions = true } = {}
85
85
 
86
86
  const meta = {
87
87
  ...main.meta,
88
+ version: await contractVersion(join(specDir, 'main')),
88
89
  local: local.meta,
89
90
  handles: Object.fromEntries(
90
91
  Object.entries(handles).map(([n, h]) => [n, { ...h.meta, type: n }])
@@ -231,6 +232,22 @@ function fieldsFor(fields) {
231
232
  }))
232
233
  }
233
234
 
235
+ // The contract version is the highest of the three auto-bumped artifact
236
+ // versions — any schema/db/dispatch change advances it, so a peer can tell
237
+ // whether an op predates or postdates its own build.
238
+ async function contractVersion(mainDir) {
239
+ const read = async (rel) => {
240
+ const j = JSON.parse(await fs.readFile(join(mainDir, rel), 'utf-8'))
241
+ return j.version || 1
242
+ }
243
+ const versions = await Promise.all([
244
+ read('schema/schema.json'),
245
+ read('db/db.json'),
246
+ read('dispatch/dispatch.json')
247
+ ])
248
+ return Math.max(...versions)
249
+ }
250
+
234
251
  function emitMain(dir, ns, { types, collections, dispatches, indexes = [] }, { rpc, extend = {} }) {
235
252
  const schemaDir = join(dir, 'schema')
236
253
  const dbDir = join(dir, 'db')
@@ -13,7 +13,8 @@ export const main = {
13
13
  master: required(bytes),
14
14
  writer: required(bytes),
15
15
  sig: required(bytes),
16
- isIndexer: bool
16
+ isIndexer: bool,
17
+ ts: int
17
18
  },
18
19
  counter: {
19
20
  name: required(string),
@@ -70,7 +71,8 @@ export const main = {
70
71
  claim: {
71
72
  identity: required(bytes),
72
73
  writer: required(bytes),
73
- sig: required(bytes)
74
+ sig: required(bytes),
75
+ ts: int
74
76
  }
75
77
  }
76
78
 
@@ -154,6 +156,10 @@ export const rpc = {
154
156
  total: required(int),
155
157
  size: required(int)
156
158
  },
159
+ 'res-changes': {
160
+ changes: required(bytes),
161
+ reset: bool
162
+ },
157
163
  'res-count': {
158
164
  count: required(int)
159
165
  },
@@ -451,7 +451,8 @@ export class Handle extends ReadyResource {
451
451
  await this.store.call('add-writer', {
452
452
  sig,
453
453
  master: this.identity.publicKey,
454
- writer: writerKey
454
+ writer: writerKey,
455
+ ts: member.updatedAt || Date.now()
455
456
  })
456
457
  await this.store.call('add-member', member)
457
458
  })
@@ -503,7 +504,10 @@ export class Handle extends ReadyResource {
503
504
  await child.store.call('add-writer', {
504
505
  master: this.identity.publicKey,
505
506
  writer: writerKey,
506
- sig: this.identity.sign(addWriterPayload(child.store.key, writerKey, child.store.writerKey))
507
+ sig: this.identity.sign(
508
+ addWriterPayload(child.store.key, writerKey, child.store.writerKey)
509
+ ),
510
+ ts
507
511
  })
508
512
  await child.store.call('add-member', {
509
513
  id: this.identity.id,
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  del,
18
18
  count,
19
19
  watch,
20
+ changes,
20
21
  call,
21
22
  open,
22
23
  before,
@@ -37,6 +38,7 @@ export {
37
38
  del,
38
39
  count,
39
40
  watch,
41
+ changes,
40
42
  call,
41
43
  open,
42
44
  before,
@@ -61,6 +63,7 @@ export { t, schema } from './lib/spec.js'
61
63
  * @property {boolean} [isMobile] Marks this device as mobile.
62
64
  * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
63
65
  * @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
66
+ * @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
64
67
  * @property {Uint8Array} [key] Pre-existing database key (skip bootstrap).
65
68
  * @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
66
69
  * @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
@@ -69,7 +72,7 @@ export { t, schema } from './lib/spec.js'
69
72
  * @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
70
73
  * @property {Uint8Array} [storageKey] 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it.
71
74
  * @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
72
- * @property {boolean | { autoStart?: boolean, backend?: any }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests); absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
75
+ * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
73
76
  */
74
77
 
75
78
  /**
@@ -120,7 +123,12 @@ export async function cero(dir, spec, opts = {}) {
120
123
  else if (stored != null && stored !== wanted) throw CeroError.CHANNEL_MISMATCH()
121
124
  }
122
125
 
123
- network = new Network({ bootstrap: opts.bootstrap, channel: opts.channel })
126
+ network = new Network({
127
+ bootstrap: opts.bootstrap,
128
+ channel: opts.channel,
129
+ store,
130
+ mirrors: opts.mirrors
131
+ })
124
132
  await network.ready()
125
133
  discovery = network.join(identity.topic)
126
134
  await Promise.race([discovery.flush(), new Promise((r) => setTimeout(r, FLUSH))])
@@ -187,7 +195,9 @@ export async function cero(dir, spec, opts = {}) {
187
195
  : opts.bluetooth
188
196
  me.bluetooth = new Bluetooth(me, {
189
197
  backend: bt.backend || null,
190
- autoStart: bt.autoStart !== false
198
+ autoStart: bt.autoStart !== false,
199
+ maxOutbound: bt.maxOutbound,
200
+ maxInbound: bt.maxInbound
191
201
  })
192
202
  me.once('close', () => me.bluetooth.close().catch(safetyCatch))
193
203
  await me.bluetooth.ready()
@@ -238,6 +248,7 @@ export async function restore(me, phrase) {
238
248
  routes,
239
249
  recoveryTimeout,
240
250
  channel,
251
+ mirrors,
241
252
  storageKey,
242
253
  extensions
243
254
  } = opts
@@ -253,6 +264,7 @@ export async function restore(me, phrase) {
253
264
  routes,
254
265
  recoveryTimeout,
255
266
  channel,
267
+ mirrors,
256
268
  storageKey,
257
269
  extensions,
258
270
  phrase,
@@ -270,6 +282,7 @@ cero.get = get
270
282
  cero.del = del
271
283
  cero.count = count
272
284
  cero.watch = watch
285
+ cero.changes = changes
273
286
  cero.call = call
274
287
  cero.open = open
275
288
  cero.before = before
@@ -2,6 +2,7 @@ import { Readable } from 'streamx'
2
2
  import b4a from 'b4a'
3
3
 
4
4
  import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
5
+ import { CeroError } from '@cero-base/core/errors'
5
6
  import { onAbort } from './utils.js'
6
7
 
7
8
  /**
@@ -251,6 +252,57 @@ export const watch = (ref, q, opts) => {
251
252
  return bindStream(owner, out, opts)
252
253
  }
253
254
 
255
+ /**
256
+ * Delta subscription: batches of `{ prev, next }` row pairs instead of
257
+ * full snapshots — lossless under backpressure, self-contained (the first
258
+ * batch, and any batch after a view swap, replays current state as inserts
259
+ * with `reset: true`). File-typed fields resolve on both sides.
260
+ */
261
+ export const changes = (ref, q, opts) => {
262
+ const owner = ref.handle
263
+ if (ref.kind === 'handle') throw CeroError.INVALID('changes does not support handle refs')
264
+ const src = owner.store.changes(ref.name, q)
265
+ let resume = null
266
+ const wake = () => {
267
+ const r = resume
268
+ resume = null
269
+ if (r) r()
270
+ }
271
+ const out = new Readable({
272
+ read(cb) {
273
+ wake()
274
+ cb(null)
275
+ },
276
+ destroy(cb) {
277
+ wake()
278
+ src.destroy()
279
+ cb(null)
280
+ }
281
+ })
282
+ const pump = async () => {
283
+ for await (const batch of src) {
284
+ const changes = []
285
+ for (const { prev, next } of batch.changes) {
286
+ changes.push({
287
+ prev: prev && resolveRow(ref, prev),
288
+ next: next && resolveRow(ref, next)
289
+ })
290
+ }
291
+ if (out.push({ ...batch, changes }) === false) {
292
+ await new Promise((r) => {
293
+ resume = r
294
+ })
295
+ if (out.destroyed) return
296
+ }
297
+ }
298
+ out.push(null)
299
+ }
300
+ pump().catch((err) => {
301
+ if (!out.destroyed) out.destroy(err)
302
+ })
303
+ return bindStream(owner, out, opts)
304
+ }
305
+
254
306
  // Snapshots are idempotent — under a slow consumer hold only the NEWEST one
255
307
  // instead of queueing every intermediate (a busy room + un-drained reader
256
308
  // used to buffer full result sets without bound).
package/src/rpc/client.js CHANGED
@@ -304,7 +304,52 @@ const operators = {
304
304
  }
305
305
  wire.on('end', end)
306
306
  wire.on('close', end)
307
- wire.on('error', (err) => out.destroy(err))
307
+ // the channel tears the stream down on client close — that is an end,
308
+ // not a failure (bare-rpc ≥1.3.2 errors every in-flight op on teardown)
309
+ wire.on('error', (err) => (err.code === 'CHANNEL_CLOSED' ? end() : out.destroy(err)))
310
+ return out
311
+ },
312
+
313
+ /**
314
+ * Delta subscription over the wire — same contract as the local operator:
315
+ * batches of `{ prev, next }` with file fields resolved, `reset` marks
316
+ * a full replay. Lossless: server-side the cursor folds under backpressure.
317
+ */
318
+ changes(name, query) {
319
+ const refInfo = this._refInfo(name)
320
+ const codec = this._codec()
321
+ const schema = refInfo?.schema
322
+ const wire = this.rpc.changes({
323
+ handle: this.id,
324
+ ref: name,
325
+ query: codec.encodeQuery(query),
326
+ local: this._local
327
+ })
328
+ const out = new Readable({
329
+ predestroy() {
330
+ wire.destroy()
331
+ }
332
+ })
333
+ const pump = async () => {
334
+ for await (const frame of wire) {
335
+ const changes = []
336
+ for (const { prev, next } of codec.decodeChanges(schema, frame.changes)) {
337
+ changes.push({
338
+ prev: prev && this._resolveFiles(name, prev),
339
+ next: next && this._resolveFiles(name, next)
340
+ })
341
+ }
342
+ out.push({ changes, reset: frame.reset === true })
343
+ }
344
+ if (!out.destroyed) out.push(null)
345
+ }
346
+ pump().catch((err) => {
347
+ if (out.destroyed) return
348
+ // the server tears the stream down on handle close, and the channel on
349
+ // client close — both are ends, not failures (same contract as watch)
350
+ if (err.code === 'PREMATURE_CLOSE' || err.code === 'CHANNEL_CLOSED') out.push(null)
351
+ else out.destroy(err)
352
+ })
308
353
  return out
309
354
  },
310
355
 
package/src/rpc/server.js CHANGED
@@ -1,9 +1,11 @@
1
+ import safetyCatch from 'safety-catch'
2
+
1
3
  import { RPCServer, bindCodec } from '@cero-base/core/rpc'
2
4
  import { CeroError } from '@cero-base/core/errors'
3
5
  import { encodeId } from '@cero-base/core/blobs/codec'
4
6
 
5
7
  import { cero, restore } from '../index.js'
6
- import { put, set, get, del, count, watch, call } from '../lib/operators.js'
8
+ import { put, set, get, del, count, watch, changes, call } from '../lib/operators.js'
7
9
 
8
10
  /**
9
11
  * @typedef {import('@cero-base/core/rpc').RPCServer} BaseRPCServer
@@ -209,6 +211,9 @@ export class Server extends RPCServer {
209
211
  if (!set) this._watchStreams.set(handle, (set = new Set()))
210
212
  set.add(stream)
211
213
  live.on('data', onData)
214
+ // channel teardown destroys the stream with CHANNEL_CLOSED — 'close'
215
+ // below does the cleanup; the error itself must not crash the server
216
+ stream.on('error', safetyCatch)
212
217
  stream.on('close', () => {
213
218
  live.off('data', onData)
214
219
  live.destroy()
@@ -216,6 +221,40 @@ export class Server extends RPCServer {
216
221
  })
217
222
  })
218
223
 
224
+ this.rpc.onChanges((stream) => {
225
+ const { handle, ref, query, local } = stream.data
226
+ let r, codec, live
227
+ try {
228
+ ;({ ref: r, codec } = this._refOf(handle, ref, local))
229
+ live = changes(r, codec.decodeQuery(query))
230
+ } catch (err) {
231
+ stream.once('error', () => {})
232
+ stream.writeStream.destroy(err)
233
+ stream.destroy()
234
+ return
235
+ }
236
+ let set = this._watchStreams.get(handle)
237
+ if (!set) this._watchStreams.set(handle, (set = new Set()))
238
+ set.add(stream)
239
+ // no keep-latest: delta batches are not idempotent — hold the iteration
240
+ // on wire backpressure instead; the cursor folds everything missed
241
+ const pump = async () => {
242
+ for await (const batch of live) {
243
+ const ok = stream.write({
244
+ changes: codec.encodeChanges(r.schema, batch.changes),
245
+ reset: batch.reset === true
246
+ })
247
+ if (ok === false) await new Promise((resolve) => stream.once('drain', resolve))
248
+ }
249
+ }
250
+ pump().catch(safetyCatch)
251
+ stream.on('error', safetyCatch)
252
+ stream.on('close', () => {
253
+ live.destroy()
254
+ this._watchStreams.get(handle)?.delete(stream)
255
+ })
256
+ })
257
+
219
258
  this.rpc.onCall(async ({ handle, op, data }) => {
220
259
  const h = this._resolve(handle)
221
260
  const r = h[op]
@@ -19,14 +19,21 @@ export class Bluetooth extends ReadyResource {
19
19
  * @param {object} [opts]
20
20
  * @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
21
21
  * @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
22
+ * @param {number} [opts.maxOutbound] Max concurrent outbound links; gossip covers the rest.
23
+ * @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
22
24
  */
23
- constructor(handle: object, { backend, autoStart }?: {
25
+ constructor(handle: object, { backend, autoStart, maxOutbound, maxInbound }?: {
24
26
  backend?: any;
25
27
  autoStart?: boolean;
28
+ maxOutbound?: number;
29
+ maxInbound?: number;
26
30
  });
27
31
  _handle: any;
28
32
  _backend: any;
33
+ _lazy: boolean;
29
34
  _autoStart: boolean;
35
+ _maxOutbound: number;
36
+ _maxInbound: number;
30
37
  _transport: BLETransport;
31
38
  _name: any;
32
39
  _announces: Set<any>;
@@ -7,6 +7,7 @@ export const main: {
7
7
  writer: import("@cero-base/core").Prim;
8
8
  sig: import("@cero-base/core").Prim;
9
9
  isIndexer: import("@cero-base/core").Prim;
10
+ ts: import("@cero-base/core").Prim;
10
11
  };
11
12
  counter: {
12
13
  name: import("@cero-base/core").Prim;
@@ -64,6 +65,7 @@ export const main: {
64
65
  identity: import("@cero-base/core").Prim;
65
66
  writer: import("@cero-base/core").Prim;
66
67
  sig: import("@cero-base/core").Prim;
68
+ ts: import("@cero-base/core").Prim;
67
69
  };
68
70
  };
69
71
  export const local: {
@@ -145,6 +147,10 @@ export const rpc: {
145
147
  total: import("@cero-base/core").Prim;
146
148
  size: import("@cero-base/core").Prim;
147
149
  };
150
+ 'res-changes': {
151
+ changes: import("@cero-base/core").Prim;
152
+ reset: import("@cero-base/core").Prim;
153
+ };
148
154
  'res-count': {
149
155
  count: import("@cero-base/core").Prim;
150
156
  };
package/types/index.d.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * @property {boolean} [isMobile] Marks this device as mobile.
12
12
  * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
13
13
  * @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
14
+ * @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
14
15
  * @property {Uint8Array} [key] Pre-existing database key (skip bootstrap).
15
16
  * @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
16
17
  * @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
@@ -19,7 +20,7 @@
19
20
  * @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
20
21
  * @property {Uint8Array} [storageKey] 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it.
21
22
  * @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
22
- * @property {boolean | { autoStart?: boolean, backend?: any }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests); absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
23
+ * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
23
24
  */
24
25
  /**
25
26
  * Open (or create) a cero handle at `dir`. Sets up storage, network and
@@ -40,6 +41,7 @@ export namespace cero {
40
41
  export { del };
41
42
  export { count };
42
43
  export { watch };
44
+ export { changes };
43
45
  export { call };
44
46
  export { open };
45
47
  export { before };
@@ -100,6 +102,10 @@ export type CeroOpts = {
100
102
  * Optional network-isolation label; only same-channel peers connect.
101
103
  */
102
104
  channel?: string;
105
+ /**
106
+ * Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
107
+ */
108
+ mirrors?: Array<string | Uint8Array>;
103
109
  /**
104
110
  * Pre-existing database key (skip bootstrap).
105
111
  */
@@ -133,11 +139,13 @@ export type CeroOpts = {
133
139
  */
134
140
  extensions?: boolean;
135
141
  /**
136
- * `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests); absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
142
+ * `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
137
143
  */
138
144
  bluetooth?: boolean | {
139
145
  autoStart?: boolean;
140
146
  backend?: any;
147
+ maxOutbound?: number;
148
+ maxInbound?: number;
141
149
  };
142
150
  };
143
151
  import { t } from './lib/spec.js';
@@ -147,6 +155,7 @@ import { get } from './lib/operators.js';
147
155
  import { del } from './lib/operators.js';
148
156
  import { count } from './lib/operators.js';
149
157
  import { watch } from './lib/operators.js';
158
+ import { changes } from './lib/operators.js';
150
159
  import { call } from './lib/operators.js';
151
160
  import { open } from './lib/operators.js';
152
161
  import { before } from './lib/operators.js';
@@ -161,5 +170,5 @@ import { Ref } from './handle/index.js';
161
170
  import { Local } from './local/index.js';
162
171
  import { Identity } from '@cero-base/core/identity';
163
172
  export { Handle, Ref, Local };
164
- export { put, set, get, del, count, watch, call, open, before, after, bind, define } from "./lib/operators.js";
173
+ export { put, set, get, del, count, watch, changes, call, open, before, after, bind, define } from "./lib/operators.js";
165
174
  export { t, schema } from "./lib/spec.js";
@@ -72,6 +72,7 @@ export function get(ref: Ref, q?: string | Record<string, any>): Promise<SingleR
72
72
  export function watch(ref: Ref, q?: Record<string, any>, opts?: {
73
73
  signal?: AbortSignal;
74
74
  }): import("streamx").Readable;
75
+ export function changes(ref: any, q: any, opts: any): any;
75
76
  export function open(ref: Ref, arg?: string | {
76
77
  invite?: string;
77
78
  id?: string;