@cero-base/cero 1.1.1 → 1.3.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.
@@ -9,7 +9,7 @@ import z32 from 'z32'
9
9
  import { Identity } from '@cero-base/core/identity'
10
10
  import { Database } from '@cero-base/core/database'
11
11
  import { Pairing } from '@cero-base/core/pairing'
12
- import { toId, grants } from '@cero-base/core/utils'
12
+ import { toId, grants, addWriterPayload } from '@cero-base/core/utils'
13
13
  import { CeroError } from '@cero-base/core/errors'
14
14
  import { Blobs } from '@cero-base/core/blobs'
15
15
  import { FileServer } from '@cero-base/core/blobs/server'
@@ -42,6 +42,7 @@ export { Ref } from '../lib/utils.js'
42
42
  * @property {Uint8Array} [encryptionKey] Existing encryption key.
43
43
  * @property {string} [namespace] Corestore namespace.
44
44
  * @property {KeyPair} [keyPair] Writer keypair.
45
+ * @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
45
46
  * @property {boolean} [pair] When `false`, skips creating a `Pairing` session.
46
47
  *
47
48
  * @typedef {object} CreateChildOpts
@@ -132,6 +133,7 @@ export class Handle extends ReadyResource {
132
133
  encryptionKey: opts.encryptionKey,
133
134
  namespace: opts.namespace,
134
135
  keyPair: opts.keyPair,
136
+ passive: opts.passive,
135
137
  onerror: this._onerror
136
138
  })
137
139
  this.pair = null
@@ -156,6 +158,8 @@ export class Handle extends ReadyResource {
156
158
 
157
159
  async _close() {
158
160
  this.root._coreKeys.delete(b4a.toString(this.store.key, 'hex'))
161
+ if (this._blobs?.key) this.root._coreKeys.delete(b4a.toString(this._blobs.key, 'hex'))
162
+ for (const hex of this._blobKeys || []) this.root._coreKeys.delete(hex)
159
163
  for (const r of [...this._owned]) r.destroy?.()
160
164
  this._owned.clear()
161
165
  if (this._blobs) await this._blobs.close()
@@ -302,7 +306,7 @@ export class Handle extends ReadyResource {
302
306
  this.root._coreKeys.set(b4a.toString(this._blobs.key, 'hex'), this.store.encryptionKey)
303
307
  }
304
308
  })
305
- .catch(() => {})
309
+ .catch(this._onerror)
306
310
  }
307
311
  return this._blobs
308
312
  }
@@ -359,6 +363,18 @@ export class Handle extends ReadyResource {
359
363
  await this.store.bee.update()
360
364
  }
361
365
 
366
+ /**
367
+ * Flip this handle's swarm announce mode — `setActive(false)` demotes an
368
+ * idle/background room to server-only (still reachable, stops searching);
369
+ * `setActive(true)` promotes it back on focus. Cheap, safe to call often.
370
+ *
371
+ * @param {boolean} active
372
+ * @returns {Promise<void>}
373
+ */
374
+ setActive(active) {
375
+ return this.store.setActive(active)
376
+ }
377
+
362
378
  /**
363
379
  * Mint a pairing invite for this handle.
364
380
  *
@@ -405,6 +421,11 @@ export class Handle extends ReadyResource {
405
421
  throw CeroError.INVALID(`role '${role}' exceeds the invite role '${candidate.invite.role}'`)
406
422
  }
407
423
 
424
+ // confirm must answer within the incoming pairing request's lifetime —
425
+ // any await before it (even ~100ms) and the response is dropped, the
426
+ // joiner times out. So the key is revealed before the membership writes
427
+ // land; if they fail the joiner holds the key un-admitted, which is
428
+ // recoverable (re-pair) and surfaced via onerror in _wireAccept.
408
429
  await candidate.confirm({ key: this.store.key, encryptionKey: this.store.encryptionKey })
409
430
 
410
431
  const ts = Date.now()
@@ -423,7 +444,9 @@ export class Handle extends ReadyResource {
423
444
  return
424
445
  }
425
446
 
426
- const sig = this.identity.sign(b4a.concat([writerKey, this.store.writerKey]))
447
+ const sig = this.identity.sign(
448
+ addWriterPayload(this.store.key, writerKey, this.store.writerKey)
449
+ )
427
450
  await this.store.tx(async () => {
428
451
  await this.store.call('add-writer', {
429
452
  sig,
@@ -480,7 +503,7 @@ export class Handle extends ReadyResource {
480
503
  await child.store.call('add-writer', {
481
504
  master: this.identity.publicKey,
482
505
  writer: writerKey,
483
- sig: this.identity.sign(b4a.concat([writerKey, child.store.writerKey]))
506
+ sig: this.identity.sign(addWriterPayload(child.store.key, writerKey, child.store.writerKey))
484
507
  })
485
508
  await child.store.call('add-member', {
486
509
  id: this.identity.id,
@@ -535,6 +558,10 @@ export class Handle extends ReadyResource {
535
558
  if (existing) return this._load(type, existing.id)
536
559
  }
537
560
 
561
+ // offline join: with nearby sync on, also rendezvous on the invite-derived
562
+ // BLE UUID for the duration of the join — pairing rides the injected link
563
+ const stopNearby = this.root.bluetooth ? this.root.bluetooth.announce(invite) : null
564
+
538
565
  const child = /** @type {Child} */ (
539
566
  await Handle.join(invite, {
540
567
  parent: this,
@@ -542,7 +569,7 @@ export class Handle extends ReadyResource {
542
569
  namespace: `${NS}/handle/${type}/${randomNs()}`,
543
570
  routes,
544
571
  timeout
545
- })
572
+ }).finally(() => stopNearby?.())
546
573
  )
547
574
  // whenWritable timing out (host offline) is a normal failure — close the
548
575
  // fully-opened child rather than leak its Database/pairing/swarm session
@@ -607,7 +634,8 @@ export class Handle extends ReadyResource {
607
634
  async _reopen(type, id) {
608
635
  const { data } = await this.store.get('handles', id)
609
636
  if (!data) throw CeroError.UNKNOWN('handle', id)
610
- if (data.type !== type) throw new Error(`handle ${id} is type ${data.type}, not ${type}`)
637
+ if (data.type !== type)
638
+ throw CeroError.INVALID(`handle ${id} is type ${data.type}, not ${type}`)
611
639
  let writer = await this._loadKeyPair(id)
612
640
  const firstTime = !writer
613
641
  if (firstTime) {
@@ -643,7 +671,7 @@ export class Handle extends ReadyResource {
643
671
  async suspend() {
644
672
  if (this.parent || this.closing || this.closed || this._suspended) return
645
673
  this._suspended = true
646
- for (const c of this.children) if (c.pair) await c.pair.suspend()
674
+ await Promise.all([...this.children].map((c) => c.pair?.suspend()))
647
675
  await this.network.suspend()
648
676
  try {
649
677
  await this.store.store.suspend()
@@ -683,10 +711,10 @@ export class Handle extends ReadyResource {
683
711
  ) {
684
712
  const net = network || parent?.network
685
713
  const id = identity || parent?.identity
686
- if (!net) throw new TypeError('network is required')
687
- if (!id) throw new TypeError('identity is required')
688
- if (!store && !parent) throw new TypeError('store is required')
689
- if (!spec) throw new TypeError('spec is required')
714
+ if (!net) throw CeroError.REQUIRED('network')
715
+ if (!id) throw CeroError.REQUIRED('identity')
716
+ if (!store && !parent) throw CeroError.REQUIRED('store')
717
+ if (!spec) throw CeroError.REQUIRED('spec')
690
718
 
691
719
  const writer = Identity.randomKeyPair()
692
720
  const pair = new Pairing({ network: net, identity: id })
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { CeroError } from '@cero-base/core/errors'
9
9
 
10
10
  import { Handle, Ref } from './handle/index.js'
11
11
  import { Local } from './local/index.js'
12
+ import { Bluetooth } from './bluetooth.js'
12
13
  import {
13
14
  put,
14
15
  set,
@@ -66,6 +67,9 @@ export { t, schema } from './lib/spec.js'
66
67
  * @property {(err: any) => void} [onerror] Background-task error handler.
67
68
  * @property {boolean} [recovery] Recovery flow — wipe local state and re-claim a writer slot.
68
69
  * @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
70
+ * @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
+ * @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'`.
69
73
  */
70
74
 
71
75
  /**
@@ -93,24 +97,27 @@ export async function cero(dir, spec, opts = {}) {
93
97
  // lock leaks and a retry in the same process hits locked storage.
94
98
  try {
95
99
  await storage.ready()
100
+ await fs.promises.chmod(`${dir}/main`, 0o700)
96
101
  store = new Corestore(storage, { manifestVersion: 2 })
97
102
  await store.ready()
98
103
 
99
104
  if (spec.local && spec.meta?.local) {
100
- local = new Local(null, spec, { store })
105
+ local = new Local(null, spec, { store, storageKey: opts.storageKey })
101
106
  await local.ready()
102
107
  }
103
108
 
104
109
  const identity = await resolveIdentity(opts, local)
105
110
  const writer = local ? (await local.store.get('keypair')).data : null
106
111
 
107
- // Channel stamp: a storage remembers its channel; reopening it under a different channel is a
108
- // misconfiguration that could leak data across networks, so reject it. Only when a channel is
109
- // set (no channel = global, no stamp, unchanged).
110
- if (local && opts.channel) {
111
- const stored = (await local.store.get('environment')).data?.channel
112
- if (stored == null) await local.store.set('environment', { channel: opts.channel })
113
- else if (stored !== opts.channel) throw CeroError.CHANNEL_MISMATCH()
112
+ // Channel stamp: a storage remembers its channel; reopening it under a different channel
113
+ // including no channel at all, which would silently rejoin the global network is a
114
+ // misconfiguration that could leak data across networks, so reject it.
115
+ if (local) {
116
+ const stored = (await local.store.get('environment')).data?.channel ?? null
117
+ const wanted = opts.channel ?? null
118
+ if (stored == null && wanted != null)
119
+ await local.store.set('environment', { channel: wanted })
120
+ else if (stored != null && stored !== wanted) throw CeroError.CHANNEL_MISMATCH()
114
121
  }
115
122
 
116
123
  network = new Network({ bootstrap: opts.bootstrap, channel: opts.channel })
@@ -164,10 +171,28 @@ export async function cero(dir, spec, opts = {}) {
164
171
  if (opts.recovery) await me.recover({ timeout: opts.recoveryTimeout })
165
172
 
166
173
  for (const ext of internal.extensions) {
174
+ if (ext.bundled && opts.extensions === false) continue
167
175
  const off = await ext.setup?.(me)
168
176
  if (typeof off === 'function') me.once('close', off)
169
177
  }
170
178
 
179
+ if (opts.bluetooth) {
180
+ // `true` → defaults; `{ autoStart, backend }` → options. A bare backend
181
+ // object (pre-1.3 shape, has Central/Server) is still accepted.
182
+ const bt =
183
+ opts.bluetooth === true
184
+ ? {}
185
+ : opts.bluetooth.Central
186
+ ? { backend: opts.bluetooth }
187
+ : opts.bluetooth
188
+ me.bluetooth = new Bluetooth(me, {
189
+ backend: bt.backend || null,
190
+ autoStart: bt.autoStart !== false
191
+ })
192
+ me.once('close', () => me.bluetooth.close().catch(safetyCatch))
193
+ await me.bluetooth.ready()
194
+ }
195
+
171
196
  bind(me, null)
172
197
  return me
173
198
  } catch (err) {
@@ -205,7 +230,17 @@ export async function restore(me, phrase) {
205
230
  const { _dir: dir, spec, _opts: opts } = me
206
231
  // channel must carry over — without it the recovered instance rejoins the
207
232
  // global identity topic and never meets its channeled peers (recovery timeout).
208
- const { name, bootstrap, isMobile, onerror, routes, recoveryTimeout, channel } = opts
233
+ const {
234
+ name,
235
+ bootstrap,
236
+ isMobile,
237
+ onerror,
238
+ routes,
239
+ recoveryTimeout,
240
+ channel,
241
+ storageKey,
242
+ extensions
243
+ } = opts
209
244
 
210
245
  await me.close()
211
246
  await fs.promises.rm(`${dir}/main`, { recursive: true, force: true })
@@ -218,6 +253,8 @@ export async function restore(me, phrase) {
218
253
  routes,
219
254
  recoveryTimeout,
220
255
  channel,
256
+ storageKey,
257
+ extensions,
221
258
  phrase,
222
259
  recovery: true
223
260
  })
@@ -242,11 +279,20 @@ cero.restore = restore
242
279
  cero.schema = schema
243
280
  cero.bind = bind
244
281
  cero.define = define
282
+ // test-only escape hatch (cross-package tests reset/seed the registry)
245
283
  cero._internal = internal
246
284
  // A bare function is shorthand for a behavior-only extension: `{ setup: fn }`.
247
285
  // Accepts both `use(a, b)` and `use([a, b])` (and a mix) for easier composition.
248
- cero.use = (...exts) =>
249
- internal.extensions.push(...exts.flat().map((e) => (typeof e === 'function' ? { setup: e } : e)))
286
+ // A named extension replaces any registered one with the same name — so
287
+ // `use(profileSync({ fields }))` reconfigures the bundled default instead of
288
+ // doubling it.
289
+ cero.use = (...exts) => {
290
+ for (const e of exts.flat().map((e) => (typeof e === 'function' ? { setup: e } : e))) {
291
+ const i = e.name ? internal.extensions.findIndex((x) => x.name === e.name) : -1
292
+ if (i >= 0) internal.extensions[i] = e
293
+ else internal.extensions.push(e)
294
+ }
295
+ }
250
296
 
251
297
  async function resolveIdentity(opts, local) {
252
298
  if (opts.identity) return opts.identity
@@ -1,3 +1,9 @@
1
1
  // Process-wide registry for extensions registered via `cero.use()`.
2
2
  // `build()` folds in each extension's schema; `cero()` runs each setup.
3
- export const internal = { extensions: [] }
3
+ // The bundled extensions are on by default — `cero.use(profileSync({...}))`
4
+ // replaces a default by name, `{ extensions: false }` (on build and cero)
5
+ // leaves them out entirely.
6
+ import { profileSync } from '../extensions/profile-sync.js'
7
+ import { handleSync } from '../extensions/handle-sync.js'
8
+
9
+ export const internal = { extensions: [profileSync(), handleSync()] }
@@ -1,4 +1,5 @@
1
1
  import { Readable } from 'streamx'
2
+ import b4a from 'b4a'
2
3
 
3
4
  import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
4
5
  import { onAbort } from './utils.js'
@@ -30,11 +31,11 @@ export function resolveFile(handle, id, name) {
30
31
  /**
31
32
  * Insert (or overwrite by id) a row on `ref`. The `files` builtin is special:
32
33
  * `put(handle.files, { data, type, name? })` uploads the bytes to this handle's
33
- * blob store, records `{ id, name }`, and returns the resolved file.
34
+ * blob store, records `{ id, name }`, and resolves the file.
34
35
  *
35
36
  * @param {Ref} ref
36
37
  * @param {Record<string, any>} row
37
- * @returns {Promise<SingleResult | { id: string, name?: string, type: string, size: number, url: string }>}
38
+ * @returns {Promise<SingleResult>}
38
39
  */
39
40
  export const put = (ref, row) =>
40
41
  ref.name === 'files' ? putFile(ref, row) : ref.handle.store.put(ref.name, row)
@@ -47,7 +48,7 @@ async function putFile(ref, row) {
47
48
  const blobId = await handle.blobs.put(data)
48
49
  const id = encodeId(handle.blobs.key, blobId, type)
49
50
  await handle.store.call('add-file', { id, name })
50
- return resolveFile(handle, id, name)
51
+ return { data: resolveFile(handle, id, name) }
51
52
  }
52
53
 
53
54
  /**
@@ -186,10 +187,13 @@ function registerBlobCore(handle, id) {
186
187
  if (!id || !handle.root?._coreKeys) return
187
188
  try {
188
189
  const { coreKey } = decodeId(id)
189
- const hex = Buffer.from(coreKey).toString('hex')
190
+ const hex = b4a.toString(coreKey, 'hex')
190
191
  if (!handle.root._coreKeys.has(hex)) {
191
192
  handle.root._coreKeys.set(hex, handle.root.store.encryptionKey)
192
193
  }
194
+ // remember which handle read it, so close prunes the entry (re-registered
195
+ // on the next read if another handle still serves the same core)
196
+ if (handle !== handle.root) (handle._blobKeys ??= new Set()).add(hex)
193
197
  } catch {
194
198
  // ignore invalid ids
195
199
  }
@@ -239,28 +243,44 @@ export const watch = (ref, q, opts) => {
239
243
  const owner = ref.handle
240
244
  if (ref.kind !== 'handle') {
241
245
  const src = owner.store.watch(ref.name, q)
242
- const out = new Readable({
243
- destroy(cb) {
244
- src.destroy()
245
- cb(null)
246
- }
247
- })
248
- src.on('data', (res) => out.push(resolveResult(ref, res)))
249
- src.on('end', () => out.push(null))
250
- src.on('error', (err) => out.destroy(err))
246
+ const out = snapshotStream(src, (res) => resolveResult(ref, res))
251
247
  return bindStream(owner, out, opts)
252
248
  }
253
249
  const source = parentStore(ref).watch('handles', q)
250
+ const out = snapshotStream(source, (snap) => normalize(snap?.data, ref.name))
251
+ return bindStream(owner, out, opts)
252
+ }
253
+
254
+ // Snapshots are idempotent — under a slow consumer hold only the NEWEST one
255
+ // instead of queueing every intermediate (a busy room + un-drained reader
256
+ // used to buffer full result sets without bound).
257
+ function snapshotStream(src, map) {
258
+ let pending
259
+ let wanted = false
254
260
  const out = new Readable({
261
+ read(cb) {
262
+ wanted = true
263
+ flush()
264
+ cb(null)
265
+ },
255
266
  destroy(cb) {
256
- source.destroy()
267
+ src.destroy()
257
268
  cb(null)
258
269
  }
259
270
  })
260
- source.on('data', (snap) => out.push(normalize(snap?.data, ref.name)))
261
- source.on('end', () => out.push(null))
262
- source.on('error', (err) => out.destroy(err))
263
- return bindStream(owner, out, opts)
271
+ const flush = () => {
272
+ if (!wanted || pending === undefined || out.destroyed) return
273
+ const snap = pending
274
+ pending = undefined
275
+ wanted = out.push(snap) !== false
276
+ }
277
+ src.on('data', (res) => {
278
+ pending = map(res)
279
+ flush()
280
+ })
281
+ src.on('end', () => out.push(null))
282
+ src.on('error', (err) => out.destroy(err))
283
+ return out
264
284
  }
265
285
 
266
286
  // Universal signal instantiator. Dispatch on the second arg:
@@ -9,6 +9,7 @@ import { attachRefs } from '../lib/utils.js'
9
9
  * @typedef {object} LocalOpts
10
10
  * @property {any} [root] Pre-existing HypercoreStorage to reuse.
11
11
  * @property {any} [store] Pre-existing Corestore to reuse.
12
+ * @property {Uint8Array} [storageKey] 32-byte key encrypting the local store at rest.
12
13
  */
13
14
 
14
15
  /**
@@ -22,7 +23,7 @@ export class Local extends ReadyResource {
22
23
  * @param {any} spec Built cero spec — must include `spec.local.database` and `spec.meta.local`.
23
24
  * @param {LocalOpts} [opts]
24
25
  */
25
- constructor(dir, spec, { root, store } = {}) {
26
+ constructor(dir, spec, { root, store, storageKey } = {}) {
26
27
  super()
27
28
  if (!root && !store && (typeof dir !== 'string' || !dir))
28
29
  throw CeroError.REQUIRED('dir, root, or store')
@@ -34,7 +35,8 @@ export class Local extends ReadyResource {
34
35
  this.store = Storage.bee(dir, {
35
36
  spec: { database: spec.local.database, meta: spec.meta.local },
36
37
  root,
37
- store
38
+ store,
39
+ storageKey
38
40
  })
39
41
  }
40
42
 
package/src/rpc/client.js CHANGED
@@ -152,7 +152,7 @@ const operators = {
152
152
  async put(name, row) {
153
153
  const codec = this._codec()
154
154
  const schema = this.schemaOf(name)
155
- if (row && row.data != null) {
155
+ if (name === 'files') {
156
156
  const res = await this.rpc.addFile({
157
157
  handle: this.id,
158
158
  data: row.data,
@@ -225,7 +225,11 @@ const operators = {
225
225
  info?.kind === 'single'
226
226
  ? codec.decodeRow(schema, res.data)
227
227
  : codec.decodeRows(schema, res.data)
228
- return { data: this._resolveFiles(name, raw), total: res.total, size: res.size }
228
+ return {
229
+ data: this._resolveFiles(name, raw),
230
+ total: res.total === -1 ? null : res.total,
231
+ size: res.size
232
+ }
229
233
  },
230
234
 
231
235
  /**
@@ -285,7 +289,11 @@ const operators = {
285
289
  refInfo.kind === 'single'
286
290
  ? (codec.decodeRow(schema, snap.data) ?? null)
287
291
  : codec.decodeRows(schema, snap.data)
288
- out.push({ data: this._resolveFiles(name, raw), total: snap.total, size: snap.size })
292
+ out.push({
293
+ data: this._resolveFiles(name, raw),
294
+ total: snap.total === -1 ? null : snap.total,
295
+ size: snap.size
296
+ })
289
297
  })
290
298
  // end exactly once whether the wire ends or the server destroys it (handle close)
291
299
  let ended = false
@@ -320,8 +328,13 @@ const operators = {
320
328
  * @param {{ role?: string }} [opts]
321
329
  * @returns {Promise<string>}
322
330
  */
323
- async invite({ role } = {}) {
324
- const { invite } = await this.rpc.invite({ handle: this.id, role: role || '' })
331
+ async invite({ role, expiresIn, multiUse } = {}) {
332
+ const { invite } = await this.rpc.invite({
333
+ handle: this.id,
334
+ role: role || '',
335
+ expiresIn: expiresIn || 0,
336
+ multiUse: multiUse === true
337
+ })
325
338
  return invite
326
339
  },
327
340
 
package/src/rpc/server.js CHANGED
@@ -88,7 +88,7 @@ export class Server extends RPCServer {
88
88
  /** Wire the `init` handler that lazily constructs the root cero handle. */
89
89
  _wireInit() {
90
90
  this.rpc.onInit(async () => {
91
- if (this.me) throw new CeroError('CONFLICT', 'already initialized')
91
+ if (this.me) throw CeroError.CONFLICT('already initialized')
92
92
  this.me = await cero(this.storage, this.spec, this.opts)
93
93
  this.handles.set(this.me.id, this.me)
94
94
  await this.me.fileServer.listen()
@@ -104,7 +104,7 @@ export class Server extends RPCServer {
104
104
  /** Wire the `restore` handler that rebuilds the local store from a phrase. */
105
105
  _wireRestore() {
106
106
  this.rpc.onRestore(async ({ phrase }) => {
107
- if (!this.me) throw new CeroError('NOT_READY', 'init')
107
+ if (!this.me) throw CeroError.NOT_READY('Server', 'server')
108
108
  this.me = await restore(this.me, phrase)
109
109
  this.handles = new Map([[this.me.id, this.me]])
110
110
  return this._identity()
@@ -144,7 +144,8 @@ export class Server extends RPCServer {
144
144
  r.kind === 'single'
145
145
  ? codec.encodeRow(r.schema, result.data)
146
146
  : codec.encodeRows(r.schema, result.data)
147
- return { data, total: result.total ?? 0, size: result.size ?? 0 }
147
+ // total is int on the wire: -1 encodes "skipped" (null), see T2.1 lazy total
148
+ return { data, total: result.total ?? -1, size: result.size ?? 0 }
148
149
  })
149
150
 
150
151
  this.rpc.onGetOne(async ({ handle, ref, id, local }) => {
@@ -179,12 +180,30 @@ export class Server extends RPCServer {
179
180
  stream.destroy()
180
181
  return
181
182
  }
183
+ // keep-latest under IPC backpressure: snapshots are idempotent, so an
184
+ // un-drained wire holds one pending snapshot (newest wins) instead of
185
+ // queueing every intermediate result set — encode only what ships
186
+ let pending = null
187
+ let blocked = false
182
188
  const onData = (snap) => {
189
+ if (blocked) {
190
+ pending = snap
191
+ return
192
+ }
183
193
  const data =
184
194
  r.kind === 'single'
185
195
  ? codec.encodeRow(r.schema, snap.data)
186
196
  : codec.encodeRows(r.schema, snap.data)
187
- stream.write({ data, total: snap.total ?? 0, size: snap.size ?? 0 })
197
+ blocked = stream.write({ data, total: snap.total ?? -1, size: snap.size ?? 0 }) === false
198
+ if (blocked) {
199
+ stream.once('drain', () => {
200
+ blocked = false
201
+ if (pending === null) return
202
+ const next = pending
203
+ pending = null
204
+ onData(next)
205
+ })
206
+ }
188
207
  }
189
208
  let set = this._watchStreams.get(handle)
190
209
  if (!set) this._watchStreams.set(handle, (set = new Set()))
@@ -208,9 +227,13 @@ export class Server extends RPCServer {
208
227
 
209
228
  /** Register invite/revoke/join RPC handlers. */
210
229
  _wirePairing() {
211
- this.rpc.onInvite(async ({ handle, role }) => {
230
+ this.rpc.onInvite(async ({ handle, role, expiresIn, multiUse }) => {
212
231
  const h = this._resolve(handle)
213
- const invite = await h.invite({ role: role || undefined })
232
+ const invite = await h.invite({
233
+ role: role || undefined,
234
+ expiresIn: expiresIn || undefined,
235
+ multiUse: multiUse === true
236
+ })
214
237
  return { invite }
215
238
  })
216
239
 
@@ -328,7 +351,7 @@ export class Server extends RPCServer {
328
351
  /** Wire the on-demand `seed` handler — surfaces the recovery phrase only when asked. */
329
352
  _wireSeed() {
330
353
  this.rpc.onSeed(async () => {
331
- if (!this.me) throw new CeroError('NOT_READY', 'init')
354
+ if (!this.me) throw CeroError.NOT_READY('Server', 'server')
332
355
  return { phrase: this.me.identity.toPhrase() }
333
356
  })
334
357
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `me.bluetooth` — the whole app-facing surface for nearby (Bluetooth) sync.
3
+ * Bluetooth is a cero feature, not a parallel API: it only changes how peers
4
+ * meet and carry bytes; capability-gated replication still decides what syncs.
5
+ *
6
+ * ```js
7
+ * const me = await cero(dir, spec, { channel, bluetooth: true })
8
+ * me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
9
+ * await me.bluetooth.start()
10
+ * me.bluetooth.peers // Map of live BLE links
11
+ * me.bluetooth.on('update', () => {})
12
+ * ```
13
+ *
14
+ * @extends ReadyResource
15
+ */
16
+ export class Bluetooth extends ReadyResource {
17
+ /**
18
+ * @param {object} handle Root cero Handle (network + identity + channel).
19
+ * @param {object} [opts]
20
+ * @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
21
+ * @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
22
+ */
23
+ constructor(handle: object, { backend, autoStart }?: {
24
+ backend?: any;
25
+ autoStart?: boolean;
26
+ });
27
+ _handle: any;
28
+ _backend: any;
29
+ _autoStart: boolean;
30
+ _transport: BluetoothTransport;
31
+ _name: any;
32
+ _announces: Set<any>;
33
+ /** @type {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
34
+ state: "unsupported" | "unauthorized" | "off" | "waiting" | "starting" | "on";
35
+ /**
36
+ * Offline join rendezvous. Both sides derive the same BLE service UUID from
37
+ * the invite, so they find each other with zero DHT: the host calls this
38
+ * while the invite QR is on screen; the joiner's `open(me.room, invite)`
39
+ * calls it automatically for the duration of the join. Returns a stop
40
+ * function — closing the QR must stop the advertisement so a photographed
41
+ * invite doesn't stay an ambient admission ticket. Auto-stops at the
42
+ * invite's expiry, on `bluetooth.stop()`, and on close.
43
+ *
44
+ * @param {string} invite Z32 invite string.
45
+ * @returns {() => void}
46
+ */
47
+ announce(invite: string): () => void;
48
+ /** @returns {Map<string, any>} Live BLE links, keyed by peer node id. */
49
+ get peers(): Map<string, any>;
50
+ /**
51
+ * Begin advertising + scanning on the channel-derived UUID. Idempotent.
52
+ * No-op (stays `unsupported`) when no backend is present.
53
+ *
54
+ * @returns {Promise<void>}
55
+ */
56
+ start({ name }?: {}): Promise<void>;
57
+ /**
58
+ * Stop advertising/scanning and drop links. Idempotent. Sync stops; local
59
+ * data and the rest of the network (DHT) are untouched.
60
+ *
61
+ * @returns {Promise<void>}
62
+ */
63
+ stop(): Promise<void>;
64
+ }
65
+ import ReadyResource from 'ready-resource';
66
+ import { BluetoothTransport } from '@cero-base/core/network/bluetooth';
@@ -1,5 +1,6 @@
1
- export function build(specDir: any, schema: any, { ns }?: {
1
+ export function build(specDir: any, schema: any, { ns, extensions }?: {
2
2
  ns?: string;
3
+ extensions?: boolean;
3
4
  }): Promise<void>;
4
5
  export { getHyperdbType } from "./builtins.js";
5
6
  export type Schema = import("@cero-base/core/schema").Schema;
@@ -118,6 +118,8 @@ export const rpc: {
118
118
  'req-invite': {
119
119
  handle: import("@cero-base/core").Prim;
120
120
  role: import("@cero-base/core").Prim;
121
+ expiresIn: import("@cero-base/core").Prim;
122
+ multiUse: import("@cero-base/core").Prim;
121
123
  };
122
124
  'req-revoke': {
123
125
  handle: import("@cero-base/core").Prim;
@@ -12,6 +12,8 @@
12
12
  export function handleSync({ fields }?: {
13
13
  fields?: Record<string, any>;
14
14
  }): {
15
+ name: string;
16
+ bundled: boolean;
15
17
  schema: {
16
18
  handles: {
17
19
  kind: "extend";
@@ -10,6 +10,8 @@
10
10
  export function profileSync({ fields }?: {
11
11
  fields?: Record<string, any>;
12
12
  }): {
13
+ name: string;
14
+ bundled: boolean;
13
15
  schema: {
14
16
  profile: import("@cero-base/core").TypeDef;
15
17
  members: {