@cero-base/cero 2.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
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,
@@ -98,7 +98,7 @@
98
98
  "test:node": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-node"
99
99
  },
100
100
  "dependencies": {
101
- "@cero-base/core": "^2.0.0",
101
+ "@cero-base/core": "^2.1.0",
102
102
  "b4a": "^1.8.1",
103
103
  "bare-abort-controller": "^1.1.2",
104
104
  "bare-crypto": "^1.15.3",
@@ -1,5 +1,6 @@
1
- import { join } from 'path'
1
+ import { join, resolve } from 'path'
2
2
  import { promises as fs } from 'fs'
3
+ import { pathToFileURL } from 'url'
3
4
 
4
5
  import Hyperschema from 'hyperschema'
5
6
  import HyperdbBuilder from 'hyperdb/builder'
@@ -9,7 +10,7 @@ import HRPCBuilder from 'hrpc'
9
10
  import { CeroError } from '@cero-base/core/errors'
10
11
 
11
12
  import { NS } from '../lib/constants.js'
12
- import { registry } from '../extensions/index.js'
13
+ import { extensionsOf } from '../extensions/index.js'
13
14
  import * as internal from './internal.js'
14
15
 
15
16
  /**
@@ -19,6 +20,8 @@ import * as internal from './internal.js'
19
20
  *
20
21
  * @typedef {object} BuildOpts
21
22
  * @property {string} [ns] Namespace prefix for emitted schema ids. Defaults to `'cero'`.
23
+ * @property {string | import('../extensions/index.js').Extension[]} [extensions] The extensions to fold in. A module specifier, relative to `specDir`, is imported here for its `extensions` export and written into the spec, so every process runs the same list. A list is folded in only. The bundled two by default, `[]` for none.
24
+ * @property {string} [operators] A module specifier, relative to `specDir`, written into the spec for its `operators` export, so every process binds the same map.
22
25
  */
23
26
 
24
27
  /**
@@ -29,30 +32,34 @@ import * as internal from './internal.js'
29
32
  * @param {BuildOpts} [opts]
30
33
  * @returns {Promise<void>}
31
34
  */
32
- export async function build(specDir, schema, { ns = NS, extensions = true } = {}) {
35
+ export async function build(specDir, schema, { ns = NS, extensions, operators } = {}) {
33
36
  const raw = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
34
37
  if (!raw || typeof raw !== 'object') throw CeroError.REQUIRED('schema')
38
+ const from = { extensions: str(extensions), operators: str(operators) }
39
+ const exts = extensionsOf(
40
+ null,
41
+ from.extensions ? await load(from.extensions, specDir) : extensions
42
+ )
35
43
 
36
- // t.extend entries by internal type: app schema first, then each extension
44
+ // t.extend entries by internal type: the app schema first, then each extension at its scope
37
45
  const extend = {}
38
46
  const defs = {}
39
- const collect = (entries, fromExt = false) => {
47
+ const fold = (entries, target, fromExt) => {
40
48
  for (const [k, v] of Object.entries(entries)) {
41
49
  if (v && v.kind === 'extend') {
42
50
  const type = internal.defs.main[k]?.type
43
51
  if (!type) throw CeroError.INVALID(`'${k}' is not an extendable builtin`)
44
52
  // app schema wins on field conflicts, extensions only add new fields
45
53
  extend[type] = fromExt ? { ...v.fields, ...extend[type] } : { ...extend[type], ...v.fields }
46
- } else if (!fromExt || !(k in defs)) {
47
- defs[k] = v // app schema wins over an extension's default
54
+ } else if (fromExt && isPlainHandle(v)) {
55
+ fold(v, (target[k] = { ...target[k] }), true) // into the handle type, the app's refs kept
56
+ } else if (!fromExt || !(k in target)) {
57
+ target[k] = v // app schema wins over an extension's default
48
58
  }
49
59
  }
50
60
  }
51
- collect(raw)
52
- for (const ext of registry) {
53
- if (ext.bundled && extensions === false) continue
54
- if (ext.schema) collect(ext.schema, true)
55
- }
61
+ fold(raw, defs, false)
62
+ for (const ext of exts) if (ext.schema) fold(ext.schema, defs, true)
56
63
 
57
64
  const main = compile(splitMain(defs), ns, 'main')
58
65
  const local = compile(defs.local || {}, ns, 'local')
@@ -81,7 +88,24 @@ export async function build(specDir, schema, { ns = NS, extensions = true } = {}
81
88
  if (meta.refs[name]) meta.refs[name] = { kind: 'handle', type: name, schema: `@${ns}/handle` }
82
89
  }
83
90
 
84
- await fs.writeFile(join(specDir, 'index.js'), wireModule(meta, Object.keys(handles)), 'utf-8')
91
+ await fs.writeFile(
92
+ join(specDir, 'index.js'),
93
+ wireModule(meta, Object.keys(handles), from),
94
+ 'utf-8'
95
+ )
96
+ }
97
+
98
+ const str = (v) => (typeof v === 'string' ? v : null)
99
+
100
+ async function load(specifier, specDir) {
101
+ const url = specifier.startsWith('.')
102
+ ? pathToFileURL(resolve(specDir, specifier)).href
103
+ : specifier
104
+ const { extensions } = await import(url)
105
+ if (!Array.isArray(extensions)) {
106
+ throw CeroError.INVALID(`'${specifier}' must export an extensions array`)
107
+ }
108
+ return extensions
85
109
  }
86
110
 
87
111
  function splitMain(defs) {
@@ -295,7 +319,10 @@ function handleEntries(names, kinds) {
295
319
  .join(',\n')
296
320
  }
297
321
 
298
- function wireModule(meta, names) {
322
+ // the spec imports what the build named, so every process finds the same lists
323
+ const named = (what, from) => (from ? `import { ${what} } from '${from}'` : `const ${what} = null`)
324
+
325
+ function wireModule(meta, names, from) {
299
326
  const kinds = ['database', 'dispatch', 'schema']
300
327
  return `// autogenerated by cero/build
301
328
  import database from './main/db/index.js'
@@ -305,6 +332,8 @@ import rpc from './main/rpc/index.js'
305
332
  import localDatabase from './local/db/index.js'
306
333
  import * as localSchema from './local/schema/index.js'
307
334
  ${handleImports(names, kinds)}
335
+ ${named('extensions', from.extensions)}
336
+ ${named('operators', from.operators)}
308
337
 
309
338
  export const meta = ${JSON.stringify(meta, null, 2)}
310
339
 
@@ -315,6 +344,8 @@ export const spec = {
315
344
  rpc,
316
345
  local: { database: localDatabase, schema: localSchema, meta: meta.local },
317
346
  meta,
347
+ extensions,
348
+ operators,
318
349
  handles: {
319
350
  ${handleEntries(names, kinds)}
320
351
  }
@@ -99,7 +99,6 @@ const COMMANDS = [
99
99
  ['get', 'req-query', 'res-rows'],
100
100
  ['get-one', 'req-id', 'res-data'],
101
101
  ['del', 'req-id', 'res-ok'],
102
- ['count', 'req-query', 'res-count'],
103
102
  ['watch', 'req-query', 'res-rows', true],
104
103
  ['call', 'req-call', 'res-data'],
105
104
  ['invite', 'req-invite', 'res-invite'],
@@ -109,7 +108,10 @@ const COMMANDS = [
109
108
  ['close-handle', 'req-handle', 'res-ok'],
110
109
  ['leave', 'req-handle', 'res-ok'],
111
110
  ['changes', 'req-query', 'res-changes', true],
112
- ['rotate', 'req-handle', 'res-epoch']
111
+ ['rotate', 'req-handle', 'res-epoch'],
112
+ ['set-active', 'req-set-active', 'res-ok'],
113
+ ['suspend', 'req-empty', 'res-ok'],
114
+ ['resume', 'req-empty', 'res-ok']
113
115
  ]
114
116
 
115
117
  export function commands(ns) {
@@ -159,6 +159,10 @@ export const rpc = {
159
159
  'req-handle': {
160
160
  handle: required(string)
161
161
  },
162
+ 'req-set-active': {
163
+ handle: required(string),
164
+ active: bool
165
+ },
162
166
  'res-data': {
163
167
  data: bytes
164
168
  },
@@ -171,9 +175,6 @@ export const rpc = {
171
175
  changes: required(bytes),
172
176
  reset: bool
173
177
  },
174
- 'res-count': {
175
- count: required(int)
176
- },
177
178
  'res-invite': {
178
179
  invite: required(string)
179
180
  },
@@ -10,7 +10,6 @@ import { get, set, watch } from '../lib/operators.js'
10
10
  export function handleSync({ fields = { avatar: t.string } } = {}) {
11
11
  return {
12
12
  name: 'handle-sync',
13
- bundled: true,
14
13
  schema: { handles: t.extend(fields) },
15
14
  setup(me) {
16
15
  const keys = ['name', ...Object.keys(fields)]
@@ -1,8 +1,88 @@
1
1
  import { profileSync } from './profile-sync.js'
2
2
  import { handleSync } from './handle-sync.js'
3
+ import { t, schema } from '../lib/spec.js'
4
+ import * as operators from '../lib/operators.js'
3
5
 
4
6
  export * from './profile-sync.js'
5
7
  export * from './handle-sync.js'
6
8
 
7
- // process-wide: build() folds in each schema, cero() runs each setup
8
- export const registry = [profileSync(), handleSync()]
9
+ // everything an extension module needs, light enough for the UI bundle the spec pulls it into
10
+ export { t, schema }
11
+ export const { put, set, get, del, watch, changes, call, open, rotate, before, after } = operators
12
+ export const cero = {
13
+ t,
14
+ schema,
15
+ put,
16
+ set,
17
+ get,
18
+ del,
19
+ watch,
20
+ changes,
21
+ call,
22
+ open,
23
+ rotate,
24
+ before,
25
+ after
26
+ }
27
+
28
+ /**
29
+ * @typedef {object} Extension
30
+ * @property {Record<string, any>} [schema] Refs to add, or `t.extend` on a builtin, nested by handle type like the app schema.
31
+ * @property {(me: any) => any} [setup] Runs once the root is ready; a returned function runs on close.
32
+ */
33
+
34
+ /** The two every app gets unless its build names a list. */
35
+ export const bundled = [profileSync(), handleSync()]
36
+
37
+ /**
38
+ * The extensions a spec carries, else the bundled two. A bare function is `{ setup }`.
39
+ *
40
+ * @param {any} spec
41
+ * @param {Array<any>} [override]
42
+ * @returns {Extension[]}
43
+ */
44
+ export function extensionsOf(spec, override) {
45
+ const list = override || spec?.extensions || bundled
46
+ return list.map((e) => (typeof e === 'function' ? { setup: e } : e))
47
+ }
48
+
49
+ /**
50
+ * The operators a spec carries: functions taking the handle first, keyed by namespace, a
51
+ * key naming a handle type holding that type's namespaces.
52
+ *
53
+ * @param {any} spec
54
+ * @param {Record<string, any>} [override]
55
+ * @returns {Record<string, any>}
56
+ */
57
+ export function operatorsOf(spec, override) {
58
+ return override || spec?.operators || {}
59
+ }
60
+
61
+ // handle.ns.fn(args) calls fn(handle, args)
62
+ function attach(handle, ns, fns) {
63
+ const bound = {}
64
+ for (const key of Object.keys(fns)) {
65
+ if (typeof fns[key] === 'function') bound[key] = (...args) => fns[key](handle, ...args)
66
+ }
67
+ handle[ns] = bound
68
+ }
69
+
70
+ /**
71
+ * Put the operators for `handle` on it: the root when `type` is null, else a child of `type`.
72
+ *
73
+ * @param {any} handle
74
+ * @param {string | null} type
75
+ * @param {Record<string, any>} operators
76
+ * @returns {any} handle
77
+ */
78
+ export function bind(handle, type, operators) {
79
+ const handles = handle.spec?.meta?.handles || {}
80
+ for (const [ns, fns] of Object.entries(operators)) {
81
+ if (type === null) {
82
+ if (!(ns in handles)) attach(handle, ns, fns)
83
+ } else if (ns === type) {
84
+ for (const [k, group] of Object.entries(fns)) attach(handle, k, group)
85
+ }
86
+ }
87
+ return handle
88
+ }
@@ -1,5 +1,5 @@
1
1
  import { t } from '../lib/spec.js'
2
- import { get, set, after } from '../lib/operators.js'
2
+ import { get, set, changes } from '../lib/operators.js'
3
3
 
4
4
  /**
5
5
  * Mirror your `profile` onto your `member` row in every handle you're in.
@@ -9,31 +9,35 @@ import { get, set, after } from '../lib/operators.js'
9
9
  export function profileSync({ fields = { avatar: t.string } } = {}) {
10
10
  return {
11
11
  name: 'profile-sync',
12
- bundled: true,
13
12
  schema: {
14
13
  profile: t.single({ name: t.string, ...fields }),
15
14
  members: t.extend(fields)
16
15
  },
17
16
  setup(me) {
18
17
  const keys = ['name', ...Object.keys(fields)]
19
- const publish = (child, profile) =>
20
- set(child.members, { id: me.identity.id, ...profile }, { upsert: false }).catch(me._onerror)
21
18
 
22
19
  // an unconditional set on every open is a room-wide op forever
23
- const onHandle = async (child) => {
24
- const { data: profile } = await get(me.profile)
25
- if (!profile) return
20
+ const publish = async (child, profile) => {
26
21
  const { data: member } = await get(child.members, me.identity.id)
27
22
  if (member && keys.every((k) => member[k] === profile[k])) return
28
- publish(child, profile)
23
+ await set(child.members, { id: me.identity.id, ...profile }, { upsert: false })
29
24
  }
30
25
 
31
- const onProfile = (ctx) => {
32
- if (ctx.row) me.children.forEach((child) => publish(child, ctx.row))
26
+ const onHandle = async (child) => {
27
+ const { data: profile } = await get(me.profile)
28
+ if (profile) await publish(child, profile)
33
29
  }
34
30
 
35
31
  me.on('handle', (child) => onHandle(child).catch(me._onerror), { signal: me.signal })
36
- after(me.profile, onProfile, { signal: me.signal })
32
+
33
+ // a local edit and one replicated from another device both land here
34
+ const stream = changes(me.profile, {}, { signal: me.signal })
35
+ stream.on('error', me._onerror)
36
+ stream.on('data', ({ changes: batch }) => {
37
+ for (const { next } of batch) {
38
+ if (next) me.children.forEach((child) => publish(child, next).catch(me._onerror))
39
+ }
40
+ })
37
41
  }
38
42
  }
39
43
  }
@@ -22,7 +22,8 @@ import { FileServer } from '@cero-base/core/blobs/server'
22
22
  import { NS, TIMEOUT } from '../lib/constants.js'
23
23
 
24
24
  import { Ref } from '../lib/refs.js'
25
- import { bind } from '../lib/operators.js'
25
+ import { extensionsOf, operatorsOf, bind } from '../extensions/index.js'
26
+ import { before, after } from '../lib/operators.js'
26
27
 
27
28
  export { Ref } from '../lib/refs.js'
28
29
 
@@ -48,7 +49,6 @@ export { Ref } from '../lib/refs.js'
48
49
  * @property {Array<{ epoch: number, entropy: Uint8Array }>} [epochs] Rotation epochs delivered at join.
49
50
  * @property {string} [namespace] Corestore namespace.
50
51
  * @property {KeyPair} [keyPair] Writer keypair.
51
- * @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
52
52
  * @property {boolean} [pair] When `false`, skips creating a `Pairing` session.
53
53
  *
54
54
  * @typedef {object} CreateChildOpts
@@ -114,8 +114,11 @@ export class Handle extends ReadyResource {
114
114
  this._discovery = opts.discovery || null
115
115
  this._dir = opts.dir || null
116
116
  this._opts = opts.opts || {}
117
+ this.extensions = parent?.extensions || extensionsOf(spec, this._opts.extensions)
118
+ this.operators = parent?.operators || operatorsOf(spec, this._opts.operators)
117
119
  this._onerror = this._opts.onerror || safetyCatch
118
120
  this.children = parent ? null : new Set()
121
+ this._typeHooks = parent ? null : new Set()
119
122
  this._loading = parent ? null : new Map()
120
123
  this._joining = parent ? null : new Map()
121
124
  this._coreKeys = parent ? null : new Map()
@@ -139,7 +142,7 @@ export class Handle extends ReadyResource {
139
142
  epochs: opts.epochs,
140
143
  namespace: opts.namespace,
141
144
  keyPair: opts.keyPair,
142
- passive: opts.passive,
145
+ pinned: !parent,
143
146
  onerror: this._onerror
144
147
  })
145
148
  this.pair = null
@@ -201,6 +204,11 @@ export class Handle extends ReadyResource {
201
204
  return blobs
202
205
  }
203
206
 
207
+ /** The handle type of a child, null on the root. */
208
+ get type() {
209
+ return this.spec.meta.type || null
210
+ }
211
+
204
212
  /** Canonical id — identity id for the root handle, store key for children. */
205
213
  get id() {
206
214
  if (!this.parent) return this.identity.id
@@ -233,10 +241,12 @@ export class Handle extends ReadyResource {
233
241
  await this.pair.ready()
234
242
  // serve invites persisted by any member, in step with the rows
235
243
  await this._syncInvites().catch(safetyCatch)
236
- this._invitesSync = () => this._syncInvites().catch(safetyCatch)
244
+ this._invitesSync = (touched) => {
245
+ if (touched.has('*') || touched.has('invites')) this._syncInvites().catch(safetyCatch)
246
+ }
237
247
  this.store.on('update', this._invitesSync)
238
248
  }
239
- Ref.attach(this, this.store.refs)
249
+ Ref.attach(this, this.store.refs, this.spec.handles)
240
250
  this.root._coreKeys.set(b4a.toString(this.store.key, 'hex'), this.store.encryptionKey)
241
251
  if (!this.parent) await this.fileServer.listen()
242
252
  }
@@ -349,15 +359,13 @@ export class Handle extends ReadyResource {
349
359
  }
350
360
 
351
361
  /**
352
- * Flip this handle's swarm announce mode `setActive(false)` demotes an idle/background
353
- * room to server-only (still reachable, stops searching); `setActive(true)` promotes it
354
- * back on focus.
362
+ * `true` ranks this handle as just touched, `false` takes it out of the swarm until the
363
+ * next update lands in it.
355
364
  *
356
365
  * @param {boolean} active
357
- * @returns {Promise<void>}
358
366
  */
359
367
  setActive(active) {
360
- return this.store.setActive(active)
368
+ this.store.setActive(active)
361
369
  }
362
370
 
363
371
  /**
@@ -467,16 +475,16 @@ export class Handle extends ReadyResource {
467
475
  }
468
476
 
469
477
  const sig = this.identity.sign(admission(this.store.key, writerKey, this.store.writerKey))
478
+ // one batch: the member row, then the writer that belongs to it; a refusal discards both
470
479
  await this.store.tx(async (tx) => {
480
+ await tx.call('add-member', member)
471
481
  await tx.call('add-writer', {
472
482
  sig,
473
483
  master: this.identity.publicKey,
474
484
  writer: writerKey,
475
- // add-member in the same transaction decides the rank, a refusal discards both
476
485
  memberId: member.id,
477
486
  ts: member.updatedAt || Date.now()
478
487
  })
479
- await tx.call('add-member', member)
480
488
  })
481
489
  }
482
490
 
@@ -675,9 +683,7 @@ export class Handle extends ReadyResource {
675
683
  })
676
684
 
677
685
  if (accept !== false) this._wireAccept(child, { role })
678
- bind(child, type)
679
- this.children.add(child)
680
- this.emit('handle', child, { name, role })
686
+ this._adopt(child, { name, role })
681
687
  publish(child)
682
688
  this._loading?.delete(id)
683
689
  return child
@@ -781,9 +787,7 @@ export class Handle extends ReadyResource {
781
787
  updatedAt: ts
782
788
  })
783
789
  this._wireAccept(child)
784
- bind(child, type)
785
- this.children.add(child)
786
- this.emit('handle', child, {})
790
+ this._adopt(child, {})
787
791
  publish(child)
788
792
  this._loading?.delete(id)
789
793
  return child
@@ -852,9 +856,7 @@ export class Handle extends ReadyResource {
852
856
  }
853
857
  // `accept: false` is a host-approval gate, re-arming it silently is worse
854
858
  if (opts?.accept !== false) this._wireAccept(child, { role: opts?.role })
855
- bind(child, type)
856
- this.children.add(child)
857
- this.emit('handle', child, {})
859
+ this._adopt(child, {})
858
860
  return child
859
861
  }
860
862
 
@@ -882,6 +884,35 @@ export class Handle extends ReadyResource {
882
884
  await Promise.all([...this.children].map((child) => child.pair?.resume()))
883
885
  }
884
886
 
887
+ // a child is a child once it has its operators, the hooks declared for its type, and a slot
888
+ _adopt(child, info) {
889
+ bind(child, child.type, this.operators)
890
+ for (const hook of this._typeHooks) hook.apply(child)
891
+ this.children.add(child)
892
+ this.emit('handle', child, info)
893
+ }
894
+
895
+ // before(me.room.notes, fn): on every room open now and every one opened later
896
+ _hookType(op, ref, fn, opts) {
897
+ const offs = new Map()
898
+ const hook = {
899
+ apply: (child) => {
900
+ if (child.type !== ref.type) return
901
+ offs.set(child, op(child[ref.name], fn))
902
+ child.once('close', () => offs.delete(child))
903
+ }
904
+ }
905
+ this._typeHooks.add(hook)
906
+ for (const child of this.children) hook.apply(child)
907
+ const off = () => {
908
+ this._typeHooks.delete(hook)
909
+ for (const o of offs.values()) o()
910
+ offs.clear()
911
+ }
912
+ onAbort(opts?.signal, off)
913
+ return off
914
+ }
915
+
885
916
  /**
886
917
  * @param {Handle} child
887
918
  * @param {{ role?: string }} [opts]
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 { registry } from './extensions/index.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,13 +63,15 @@ 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
 
@@ -129,7 +125,8 @@ export async function cero(dir, spec, opts = {}) {
129
125
  backoffs: opts.backoffs,
130
126
  channel: opts.channel,
131
127
  store,
132
- mirrors: opts.mirrors
128
+ mirrors: opts.mirrors,
129
+ presence: opts.presence
133
130
  })
134
131
  await network.ready()
135
132
  discovery = network.join(identity.topic)
@@ -183,22 +180,12 @@ export async function cero(dir, spec, opts = {}) {
183
180
  secretKey: result.writer.secretKey
184
181
  })
185
182
  }
186
- if (!recovering) {
187
- const ts = Date.now()
188
- await me.store.call('add-member', {
189
- id: identity.id,
190
- key: me.store.writerKey,
191
- role: 'owner',
192
- name: opts.name || null,
193
- createdAt: ts,
194
- updatedAt: ts
195
- })
196
- 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))
197
185
  }
198
186
  }
199
187
 
200
- for (const ext of registry) {
201
- if (ext.bundled && opts.extensions === false) continue
188
+ for (const ext of me.extensions) {
202
189
  const off = await ext.setup?.(me)
203
190
  if (typeof off === 'function') me.once('close', off)
204
191
  }
@@ -217,7 +204,7 @@ export async function cero(dir, spec, opts = {}) {
217
204
  await me.bluetooth.ready()
218
205
  }
219
206
 
220
- bind(me, null)
207
+ bind(me, null, me.operators)
221
208
  return me
222
209
  } catch (err) {
223
210
  // before the root Handle exists, tear the raw resources down in reverse order
@@ -266,7 +253,6 @@ cero.put = put
266
253
  cero.set = set
267
254
  cero.get = get
268
255
  cero.del = del
269
- cero.count = count
270
256
  cero.watch = watch
271
257
  cero.changes = changes
272
258
  cero.call = call
@@ -277,18 +263,6 @@ cero.after = after
277
263
  cero.peek = peek
278
264
  cero.restore = restore
279
265
  cero.schema = schema
280
- cero.bind = bind
281
- cero.define = define
282
- // test-only
283
- cero._registry = registry
284
- // a bare function is shorthand for { setup }; a named extension replaces one of the same name
285
- cero.use = (...exts) => {
286
- for (const e of exts.flat().map((e) => (typeof e === 'function' ? { setup: e } : e))) {
287
- const i = e.name ? registry.findIndex((x) => x.name === e.name) : -1
288
- if (i >= 0) registry[i] = e
289
- else registry.push(e)
290
- }
291
- }
292
266
 
293
267
  function pointerManifest(store, identity) {
294
268
  return { version: store.manifestVersion, signers: [{ publicKey: identity.publicKey }] }