@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/src/index.js CHANGED
@@ -17,21 +17,18 @@ import {
17
17
  set,
18
18
  get,
19
19
  del,
20
- count,
21
20
  watch,
22
21
  changes,
23
22
  call,
24
23
  open,
25
24
  rotate,
26
25
  before,
27
- after,
28
- bind,
29
- define
26
+ after
30
27
  } from './lib/operators.js'
31
28
  import { peek } from './lib/peek.js'
32
29
  import { t, schema } from './lib/spec.js'
33
30
  import { FLUSH, TIMEOUT } from './lib/constants.js'
34
- import { internal } from './lib/internal.js'
31
+ import { bind } from './extensions/index.js'
35
32
 
36
33
  export { Handle, Ref, Local }
37
34
  export {
@@ -39,16 +36,13 @@ export {
39
36
  set,
40
37
  get,
41
38
  del,
42
- count,
43
39
  watch,
44
40
  changes,
45
41
  call,
46
42
  open,
47
43
  rotate,
48
44
  before,
49
- after,
50
- bind,
51
- define
45
+ after
52
46
  } from './lib/operators.js'
53
47
  export { peek } from './lib/peek.js'
54
48
  export { t, schema } from './lib/spec.js'
@@ -69,20 +63,20 @@ export { t, schema } from './lib/spec.js'
69
63
  * @property {number[]} [backoffs] Swarm reconnect backoff tiers in ms (testing/tuning).
70
64
  * @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
71
65
  * @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.
66
+ * @property {{ active?: number, announced?: number, idle?: number }} [presence] How many rooms search, how many only announce, and the idle ms before the rest leave the swarm.
72
67
  * @property {Uint8Array} [key] Existing database key to recover into, skipping the pointer lookup.
73
68
  * @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
74
69
  * @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
75
70
  * @property {(err: any) => void} [onerror] Background-task error handler.
76
71
  * @property {number} [recoveryTimeout] Max wait to find another device and be admitted, in ms. Defaults to 30000.
77
72
  * @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.
78
- * @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
73
+ * @property {import('./extensions/index.js').Extension[]} [extensions] The extensions this instance runs, instead of the ones the spec carries. Build with the same list.
74
+ * @property {Record<string, any>} [operators] The operators to bind, instead of the ones the spec carries.
79
75
  * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number, pipe?: 'l2cap' | 'gatt' }} [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. `pipe` picks the data pipe — `'l2cap'` (default, faster) or `'gatt'`; both peers must match. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
80
76
  */
81
77
 
82
78
  /**
83
- * Open (or create) a cero handle at `dir`. Sets up storage, network and
84
- * identity, then returns a ready root `Handle` with all schema refs
85
- * attached as properties.
79
+ * Open (or create) a cero handle at `dir`.
86
80
  *
87
81
  * @param {string} dir Data directory.
88
82
  * @param {any} spec Built spec — output of `cero/build`.
@@ -99,9 +93,7 @@ export async function cero(dir, spec, opts = {}) {
99
93
  let network = null
100
94
  let discovery = null
101
95
  let me = null
102
- // Any failure during open (bad phrase, network/bootstrap error, recovery
103
- // timeout, extension setup) must close whatever opened — else the corestore
104
- // lock leaks and a retry in the same process hits locked storage.
96
+ // any failure during open must close what opened, or the storage lock leaks
105
97
  try {
106
98
  await storage.ready()
107
99
  await fs.promises.chmod(`${dir}/main`, 0o700)
@@ -115,15 +107,11 @@ export async function cero(dir, spec, opts = {}) {
115
107
 
116
108
  const { identity, fresh } = await resolveIdentity(opts, local)
117
109
  const writer = local ? (await local.store.get('keypair')).data : null
118
- // a supplied identity on a device with no writer is recovering: it never
119
- // authors the identity's pointer core or any core that exists elsewhere.
120
- // Only an identity cero generated here creates a database
110
+ // a supplied identity on a device with no writer recovers; only a cero-minted identity creates
121
111
  const recovering = !writer && (!!opts.key || !fresh)
122
112
  const timeout = opts.recoveryTimeout || TIMEOUT
123
113
 
124
- // Channel stamp: a storage remembers its channel; reopening it under a different channel
125
- // including no channel at all, which would silently rejoin the global network — is a
126
- // misconfiguration that could leak data across networks, so reject it.
114
+ // a storage remembers its channel; reopening under another would silently rejoin the global network
127
115
  if (local) {
128
116
  const stored = (await local.store.get('environment')).data?.channel ?? null
129
117
  const wanted = opts.channel ?? null
@@ -137,17 +125,15 @@ export async function cero(dir, spec, opts = {}) {
137
125
  backoffs: opts.backoffs,
138
126
  channel: opts.channel,
139
127
  store,
140
- mirrors: opts.mirrors
128
+ mirrors: opts.mirrors,
129
+ presence: opts.presence
141
130
  })
142
131
  await network.ready()
143
132
  discovery = network.join(identity.topic)
144
- // a freshly minted identity has no peers yet flushing the announce
145
- // before proceeding only delays first onboarding
133
+ // a fresh identity has no peers yet, flushing the announce would only delay onboarding
146
134
  if (!fresh) await Promise.race([discovery.flush(), new Promise((r) => setTimeout(r, FLUSH))])
147
135
 
148
- // the pointer core: signed by the identity, written once by the device that
149
- // created the identity, read by every device that recovers it. It holds the
150
- // root database key, so a phrase alone finds the data
136
+ // the pointer core: identity-signed, written once by the creating device, holds the root key
151
137
  const manifest = pointerManifest(store, identity)
152
138
  const pointer = store.get(
153
139
  !writer && !recovering
@@ -194,39 +180,20 @@ export async function cero(dir, spec, opts = {}) {
194
180
  secretKey: result.writer.secretKey
195
181
  })
196
182
  }
197
- if (!recovering) {
198
- const ts = Date.now()
199
- await me.store.call('add-member', {
200
- id: identity.id,
201
- key: me.store.writerKey,
202
- role: 'owner',
203
- name: opts.name || null,
204
- createdAt: ts,
205
- updatedAt: ts
206
- })
207
- if (pointer.length === 0) await pointer.append(c.encode(c.fixed32, me.store.key))
183
+ if (!recovering && pointer.length === 0) {
184
+ await pointer.append(c.encode(c.fixed32, me.store.key))
208
185
  }
209
186
  }
210
187
 
211
- for (const ext of internal.extensions) {
212
- if (ext.bundled && opts.extensions === false) continue
188
+ for (const ext of me.extensions) {
213
189
  const off = await ext.setup?.(me)
214
190
  if (typeof off === 'function') me.once('close', off)
215
191
  }
216
192
 
217
193
  if (opts.bluetooth) {
218
- // `true` defaults; `{ autoStart, backend }` options. A bare backend
219
- // object (pre-1.3 shape, has Central/Server) is still accepted.
220
- const bt =
221
- opts.bluetooth === true
222
- ? {}
223
- : opts.bluetooth.Central
224
- ? { backend: opts.bluetooth }
225
- : opts.bluetooth
194
+ const bt = opts.bluetooth === true ? {} : opts.bluetooth
226
195
  me.bluetooth = new Bluetooth(me, {
227
- // pass the backend through untouched: `|| null` turned an omitted
228
- // backend (= lazy-load bare-bluetooth) into an explicit null
229
- // (= disabled), leaving BLE 'unsupported' on every platform
196
+ // an omitted backend lazy-loads bare-bluetooth, null disables it
230
197
  backend: bt.backend,
231
198
  autoStart: bt.autoStart !== false,
232
199
  maxOutbound: bt.maxOutbound,
@@ -237,11 +204,10 @@ export async function cero(dir, spec, opts = {}) {
237
204
  await me.bluetooth.ready()
238
205
  }
239
206
 
240
- bind(me, null)
207
+ bind(me, null, me.operators)
241
208
  return me
242
209
  } catch (err) {
243
- // once the root Handle exists it owns (and closes) everything; before that,
244
- // tear the raw resources down in reverse order.
210
+ // before the root Handle exists, tear the raw resources down in reverse order
245
211
  if (me) await me.close().catch(safetyCatch)
246
212
  else {
247
213
  await discovery?.destroy().catch(safetyCatch)
@@ -255,9 +221,7 @@ export async function cero(dir, spec, opts = {}) {
255
221
  }
256
222
 
257
223
  /**
258
- * Restore a cero instance from a mnemonic phrase. Closes the running
259
- * instance, wipes the on-disk `main/` tree and re-opens with the phrase, which
260
- * recovers so the writer slot is re-claimed.
224
+ * Restore a cero instance from a mnemonic phrase.
261
225
  *
262
226
  * @param {Handle} me Existing root handle to restore.
263
227
  * @param {string} phrase BIP-39 mnemonic phrase.
@@ -267,12 +231,10 @@ export async function restore(me, phrase) {
267
231
  if (!me?._dir) throw CeroError.INVALID('me must be a cero instance')
268
232
  if (!phrase || typeof phrase !== 'string') throw CeroError.INVALID('phrase must be a string')
269
233
 
270
- // Already this identity? Nothing to restore — return the running instance.
271
234
  const current = await Identity.fromSeed(Identity.toSeed(phrase))
272
235
  if (current.id === me.identity.id) return me
273
236
 
274
- // everything carries over except the old identity the channel in
275
- // particular, or the recovered instance never meets its peers
237
+ // everything carries over except the identity, the channel above all
276
238
  const {
277
239
  _dir: dir,
278
240
  spec,
@@ -285,15 +247,12 @@ export async function restore(me, phrase) {
285
247
  return cero(dir, spec, { ...opts, phrase })
286
248
  }
287
249
 
288
- // Facade: expose the operators + helpers as properties on `cero` too, so both
289
- // `import { define }` and `cero.define(...)` work. Explicit assignments (not
290
- // `Object.assign`) so tsc reflects them onto the `cero` namespace in the .d.ts.
250
+ // the facade: cero.put and import { put } are the same function
291
251
  cero.t = t
292
252
  cero.put = put
293
253
  cero.set = set
294
254
  cero.get = get
295
255
  cero.del = del
296
- cero.count = count
297
256
  cero.watch = watch
298
257
  cero.changes = changes
299
258
  cero.call = call
@@ -304,22 +263,6 @@ cero.after = after
304
263
  cero.peek = peek
305
264
  cero.restore = restore
306
265
  cero.schema = schema
307
- cero.bind = bind
308
- cero.define = define
309
- // test-only escape hatch (cross-package tests reset/seed the registry)
310
- cero._internal = internal
311
- // A bare function is shorthand for a behavior-only extension: `{ setup: fn }`.
312
- // Accepts both `use(a, b)` and `use([a, b])` (and a mix) for easier composition.
313
- // A named extension replaces any registered one with the same name — so
314
- // `use(profileSync({ fields }))` reconfigures the bundled default instead of
315
- // doubling it.
316
- cero.use = (...exts) => {
317
- for (const e of exts.flat().map((e) => (typeof e === 'function' ? { setup: e } : e))) {
318
- const i = e.name ? internal.extensions.findIndex((x) => x.name === e.name) : -1
319
- if (i >= 0) internal.extensions[i] = e
320
- else internal.extensions.push(e)
321
- }
322
- }
323
266
 
324
267
  function pointerManifest(store, identity) {
325
268
  return { version: store.manifestVersion, signers: [{ publicKey: identity.publicKey }] }
@@ -8,19 +8,8 @@ import { Pairing } from '@cero-base/core/pairing'
8
8
  import { Invite } from '@cero-base/core/invite'
9
9
 
10
10
  /**
11
- * `me.bluetooth` — the app-facing surface for nearby (Bluetooth) sync, a thin
12
- * facade over ble-swarm. Bluetooth only changes how peers meet and carry
13
- * bytes; capability-gated replication still decides what syncs. Discovery is
14
- * one topic-derived service UUID at a time (tag `cero-ble`) — the data service
15
- * sits on a fixed per-tag UUID, so switching topics only retunes the radio.
16
- *
17
- * ```js
18
- * const me = await cero(dir, spec, { channel, bluetooth: true })
19
- * me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'starting' | 'on'
20
- * await me.bluetooth.start()
21
- * me.bluetooth.peers // Map of live BLE links
22
- * me.bluetooth.on('update', () => {})
23
- * ```
11
+ * `me.bluetooth` — the app-facing surface for nearby (Bluetooth) sync, a thin facade over
12
+ * ble-swarm.
24
13
  *
25
14
  * @extends ReadyResource
26
15
  */
@@ -43,14 +32,11 @@ export class Bluetooth extends ReadyResource {
43
32
  this._restorePending = false
44
33
 
45
34
  const identity = handle.identity
46
- // the mesh topic: one per channel, or a fixed global topic when
47
- // channelless — any nearby cero device links (global nearby; strangers
48
- // still sync zero bytes, replication is capability-gated)
35
+ // one mesh topic per channel, or a global one when channelless; strangers still sync nothing
49
36
  this._topic = crypto.hash(b4a.from(handle.network.channel || 'cero-ble'))
50
37
  this.swarm = new BluetoothSwarm({
51
38
  backend,
52
- // injected links authenticate with the same long-lived identity as the
53
- // swarm, so one person reached over Wi-Fi and BLE dedupes to one peer
39
+ // the swarm identity, so one person over Wi-Fi and BLE dedupes to one peer
54
40
  keyPair: { publicKey: identity.publicKey, secretKey: identity.secretKey },
55
41
  topic: this._topic,
56
42
  tag: 'cero-ble',
@@ -59,9 +45,7 @@ export class Bluetooth extends ReadyResource {
59
45
  maxInbound
60
46
  })
61
47
  this.swarm.on('update', () => {
62
- // an ended rendezvous retunes back to the mesh only once its links
63
- // drain — retuning drops links, and the pairing link still carries
64
- // the joiner's initial replication
48
+ // retune to the mesh only once the pairing link's initial replication drained
65
49
  if (this._restorePending && this.swarm.peers.size === 0) {
66
50
  this._restorePending = false
67
51
  this.swarm.setTopic(this._topic).catch(safetyCatch)
@@ -69,7 +53,6 @@ export class Bluetooth extends ReadyResource {
69
53
  this.emit('update')
70
54
  })
71
55
  this.swarm.on('connection', (conn) => {
72
- // the same treatment as any transport: wakeup, replication, pairing
73
56
  this._handle.network.inject(conn)
74
57
  })
75
58
  }
@@ -88,6 +71,11 @@ export class Bluetooth extends ReadyResource {
88
71
  if (this._autoStart) await this.start()
89
72
  }
90
73
 
74
+ async _close() {
75
+ this._clearAnnounce()
76
+ await this.swarm.destroy()
77
+ }
78
+
91
79
  /**
92
80
  * Begin advertising + scanning. Idempotent; no-op when unsupported.
93
81
  *
@@ -98,34 +86,20 @@ export class Bluetooth extends ReadyResource {
98
86
  }
99
87
 
100
88
  /**
101
- * Stop advertising/scanning and drop links; open invite rendezvous end with
102
- * the radio. Idempotent. Local data and the rest of the network (DHT) are
103
- * untouched.
89
+ * Stop advertising/scanning and drop links; open invite rendezvous end with the radio.
104
90
  *
105
91
  * @returns {Promise<void>}
106
92
  */
107
93
  async stop() {
108
94
  this._clearAnnounce()
109
95
  await this.swarm.stop()
110
- // while stopped setTopic just sticks the next start() is back on the
111
- // mesh. A start() + announce() that landed during the await wins.
96
+ // while stopped setTopic sticks; a start() + announce() during the await wins
112
97
  if (!this._announce) await this.swarm.setTopic(this._topic)
113
98
  }
114
99
 
115
100
  /**
116
- * Offline join rendezvous: retune the radio to the invite-derived topic so
117
- * holder and joiner find each other with zero DHT. One topic at a time —
118
- * announcing a new invite replaces the previous rendezvous. Returns a stop
119
- * function — closing the QR must stop the rendezvous so a photographed
120
- * invite doesn't stay an ambient discovery beacon (admission itself is
121
- * always gated by blind-pairing verifying the invite). The retune back to
122
- * the mesh topic waits for live links to drain: the link a join just
123
- * established survives and carries the joiner's initial replication.
124
- * Auto-stops at the invite's expiry, on `stop()`, and on close.
125
- *
126
- * Only active while nearby sync is on: before `start()` (and after `stop()`)
127
- * this is a no-op — the user controls the radio, and a join must not touch
128
- * Bluetooth (OS permissions, GATT server) they never enabled.
101
+ * Offline join rendezvous: retune the radio to the invite-derived topic so holder and
102
+ * joiner find each other with zero DHT.
129
103
  *
130
104
  * @param {string} invite Z32 invite string.
131
105
  * @returns {() => void}
@@ -161,20 +135,6 @@ export class Bluetooth extends ReadyResource {
161
135
  }
162
136
  }
163
137
 
164
- _stopAnnounce() {
165
- this._clearAnnounce()
166
- if (this.swarm.peers.size > 0) this._restorePending = true
167
- else this.swarm.setTopic(this._topic).catch(safetyCatch)
168
- }
169
-
170
- _clearAnnounce() {
171
- const e = this._announce
172
- if (!e) return
173
- if (e.timer) clearTimeout(e.timer)
174
- this._announce = null
175
- this._restorePending = false
176
- }
177
-
178
138
  /**
179
139
  * Host-lifecycle pause (app backgrounded): radio down, user intent kept.
180
140
  *
@@ -193,8 +153,17 @@ export class Bluetooth extends ReadyResource {
193
153
  await this.swarm.resume()
194
154
  }
195
155
 
196
- async _close() {
156
+ _stopAnnounce() {
197
157
  this._clearAnnounce()
198
- await this.swarm.destroy()
158
+ if (this.swarm.peers.size > 0) this._restorePending = true
159
+ else this.swarm.setTopic(this._topic).catch(safetyCatch)
160
+ }
161
+
162
+ _clearAnnounce() {
163
+ const e = this._announce
164
+ if (!e) return
165
+ if (e.timer) clearTimeout(e.timer)
166
+ this._announce = null
167
+ this._restorePending = false
199
168
  }
200
169
  }
@@ -1,5 +1,3 @@
1
- // Shared cero constants.
2
-
3
1
  export const NS = 'cero'
4
2
  export const COUNTERS = 'counters'
5
3
  export const EPOCHS = 'epochs'
@@ -7,16 +5,3 @@ export const EPOCHS = 'epochs'
7
5
  // Writer-admission / pairing timeout (ms), and the initial discovery-flush wait (ms).
8
6
  export const TIMEOUT = 30000
9
7
  export const FLUSH = 500
10
-
11
- // schema-DSL primitive → HyperDB type (used by the builder).
12
- export const DB_TYPE = {
13
- string: 'string',
14
- uint: 'uint',
15
- int: 'int',
16
- bool: 'bool',
17
- bytes: 'buffer',
18
- json: 'json',
19
- fixed32: 'fixed32',
20
- fixed64: 'fixed64',
21
- file: 'string'
22
- }