@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
@@ -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
@@ -87,10 +87,6 @@ export { Ref } from '../lib/refs.js'
87
87
 
88
88
  /**
89
89
  * A cero handle — a single writable database session attached to a swarm.
90
- * The "root" handle is the user's facade; child handles (created via
91
- * `_create`/`_join`/`_load`) live under it and share the same identity,
92
- * network and corestore. Ref properties (`profile`, `members`, ...) are
93
- * attached dynamically per the schema; child handles also carry a `name`.
94
90
  */
95
91
  export class Handle extends ReadyResource {
96
92
  /** @param {HandleOpts} [opts] */
@@ -118,8 +114,11 @@ export class Handle extends ReadyResource {
118
114
  this._discovery = opts.discovery || null
119
115
  this._dir = opts.dir || null
120
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)
121
119
  this._onerror = this._opts.onerror || safetyCatch
122
120
  this.children = parent ? null : new Set()
121
+ this._typeHooks = parent ? null : new Set()
123
122
  this._loading = parent ? null : new Map()
124
123
  this._joining = parent ? null : new Map()
125
124
  this._coreKeys = parent ? null : new Map()
@@ -143,13 +142,90 @@ export class Handle extends ReadyResource {
143
142
  epochs: opts.epochs,
144
143
  namespace: opts.namespace,
145
144
  keyPair: opts.keyPair,
146
- passive: opts.passive,
145
+ pinned: !parent,
147
146
  onerror: this._onerror
148
147
  })
149
148
  this.pair = null
150
149
  this._wantsPair = opts.pair !== false
151
150
  }
152
151
 
152
+ /**
153
+ * An `AbortSignal` that fires when this handle closes.
154
+ *
155
+ * @returns {AbortSignal}
156
+ */
157
+ get signal() {
158
+ if (!this._ac) {
159
+ this._ac = new AbortController()
160
+ if (this.closed) this._ac.abort()
161
+ else this.once('close', () => this._ac.abort())
162
+ }
163
+ return this._ac.signal
164
+ }
165
+
166
+ /** The top-most handle in the parent chain — itself for a root handle. */
167
+ get root() {
168
+ let h = this
169
+ while (h.parent) h = h.parent
170
+ return h
171
+ }
172
+
173
+ /**
174
+ * Lazily-built file server for this identity. Root-only — child handles
175
+ * reach it through `this.root.fileServer`.
176
+ *
177
+ * @returns {FileServer}
178
+ */
179
+ get fileServer() {
180
+ if (this.parent) return this.root.fileServer
181
+ if (!this._fileServer) {
182
+ this._fileServer = new FileServer({
183
+ store: this.store.store,
184
+ resolve: (coreKey, info) => this._resolveCore(coreKey, info)
185
+ })
186
+ }
187
+ return this._fileServer
188
+ }
189
+
190
+ /**
191
+ * Lazily-built blob store for THIS handle's writing device, at the current rotation epoch.
192
+ *
193
+ * @returns {Blobs}
194
+ */
195
+ get blobs() {
196
+ const stamp = this.store.keyring.current
197
+ if (!stamp) return this._baseBlobs()
198
+ let blobs = this._epochBlobs?.get(stamp)
199
+ if (!blobs) {
200
+ const entropy = this.store.keyring.entropy(stamp)
201
+ blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
202
+ ;(this._epochBlobs ??= new Map()).set(stamp, blobs)
203
+ }
204
+ return blobs
205
+ }
206
+
207
+ /** The handle type of a child, null on the root. */
208
+ get type() {
209
+ return this.spec.meta.type || null
210
+ }
211
+
212
+ /** Canonical id — identity id for the root handle, store key for children. */
213
+ get id() {
214
+ if (!this.parent) return this.identity.id
215
+ return this.store?.key ? hid.encode(this.store.key) : null
216
+ }
217
+
218
+ /** This device's id + name. `null` on child handles. */
219
+ get device() {
220
+ if (this.parent) return null
221
+ const k = this.store?.writerKey
222
+ return k ? { id: hid.encode(k), name: this._opts.name || null } : null
223
+ }
224
+
225
+ get suspended() {
226
+ return this._sus?.suspended === true
227
+ }
228
+
153
229
  async _open() {
154
230
  await this.store.ready()
155
231
  if (this._wantsPair) {
@@ -163,13 +239,14 @@ export class Handle extends ReadyResource {
163
239
  }
164
240
  })
165
241
  await this.pair.ready()
166
- // serve invites persisted by any member, and keep the set in step as rows
167
- // replicate in or disappear (minted, revoked or consumed elsewhere)
242
+ // serve invites persisted by any member, in step with the rows
168
243
  await this._syncInvites().catch(safetyCatch)
169
- this._invitesSync = () => this._syncInvites().catch(safetyCatch)
244
+ this._invitesSync = (touched) => {
245
+ if (touched.has('*') || touched.has('invites')) this._syncInvites().catch(safetyCatch)
246
+ }
170
247
  this.store.on('update', this._invitesSync)
171
248
  }
172
- Ref.attach(this, this.store.refs)
249
+ Ref.attach(this, this.store.refs, this.spec.handles)
173
250
  this.root._coreKeys.set(b4a.toString(this.store.key, 'hex'), this.store.encryptionKey)
174
251
  if (!this.parent) await this.fileServer.listen()
175
252
  }
@@ -216,9 +293,9 @@ export class Handle extends ReadyResource {
216
293
  }
217
294
 
218
295
  /**
219
- * Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`)
220
- * to this handle's lifecycle — it's destroyed automatically on close, so
221
- * callers don't track cleanup. De-registers itself if destroyed earlier.
296
+ * Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`) to this
297
+ * handle's lifecycle — it's destroyed automatically on close, so callers don't track
298
+ * cleanup.
222
299
  *
223
300
  * @template {{ destroy?: Function, once?: Function }} T
224
301
  * @param {T} resource
@@ -234,22 +311,6 @@ export class Handle extends ReadyResource {
234
311
  return resource
235
312
  }
236
313
 
237
- /**
238
- * An `AbortSignal` that fires when this handle closes. Pass it as
239
- * `{ signal }` to `on`/`after`/`before`/`watch` to drop a subscription on
240
- * close — or use your own `AbortController` for a finer scope.
241
- *
242
- * @returns {AbortSignal}
243
- */
244
- get signal() {
245
- if (!this._ac) {
246
- this._ac = new AbortController()
247
- if (this.closed) this._ac.abort()
248
- else this.once('close', () => this._ac.abort())
249
- }
250
- return this._ac.signal
251
- }
252
-
253
314
  /**
254
315
  * `EventEmitter.on` plus an optional `{ signal }` that removes the listener
255
316
  * when the signal aborts — e.g. `me.on('handle', fn, { signal: me.signal })`.
@@ -265,42 +326,6 @@ export class Handle extends ReadyResource {
265
326
  return this
266
327
  }
267
328
 
268
- /** The top-most handle in the parent chain — itself for a root handle. */
269
- get root() {
270
- let h = this
271
- while (h.parent) h = h.parent
272
- return h
273
- }
274
-
275
- /**
276
- * Lazily-built file server for this identity. Root-only — child handles
277
- * reach it through `this.root.fileServer`.
278
- *
279
- * @returns {FileServer}
280
- */
281
- get fileServer() {
282
- if (this.parent) return this.root.fileServer
283
- if (!this._fileServer) {
284
- this._fileServer = new FileServer({
285
- store: this.store.store,
286
- resolve: (coreKey, info) => this._resolveCore(coreKey, info)
287
- })
288
- }
289
- return this._fileServer
290
- }
291
-
292
- /**
293
- * @param {Uint8Array} coreKey
294
- * @param {object} info
295
- * @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
296
- */
297
- _resolveCore(coreKey, info) {
298
- const hex = b4a.toString(coreKey, 'hex')
299
- const encryptionKey = this.root._coreKeys.get(hex)
300
- if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
301
- return null
302
- }
303
-
304
329
  /**
305
330
  * Resolve a durable file id to an ephemeral download url via this identity's
306
331
  * file server.
@@ -312,112 +337,6 @@ export class Handle extends ReadyResource {
312
337
  return this.root.fileServer.getLink(id)
313
338
  }
314
339
 
315
- /**
316
- * Lazily-built blob store for THIS handle's writing device, at the current
317
- * rotation epoch. One core per writer per epoch that writes files: the base
318
- * era uses the handle's encryptionKey, rotated epochs use a key derived
319
- * from the epoch entropy — so a removed member cannot decrypt files added
320
- * after the rotation. Instances carry `.stamp` so the file row records
321
- * which era its core belongs to.
322
- *
323
- * @returns {Blobs}
324
- */
325
- get blobs() {
326
- const stamp = this.store.keyring.current
327
- if (!stamp) return this._baseBlobs()
328
- let blobs = this._epochBlobs?.get(stamp)
329
- if (!blobs) {
330
- const entropy = this.store.keyring.entropy(stamp)
331
- blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
332
- ;(this._epochBlobs ??= new Map()).set(stamp, blobs)
333
- }
334
- return blobs
335
- }
336
-
337
- _baseBlobs() {
338
- if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
339
- return this._blobs
340
- }
341
-
342
- _makeBlobs(name, encryptionKey, stamp) {
343
- const blobs = new Blobs({
344
- store: this.store.store,
345
- network: this.network,
346
- encryptionKey,
347
- name
348
- })
349
- blobs.stamp = stamp
350
- blobs
351
- .ready()
352
- .then(() => {
353
- // close prunes _coreKeys first, a late ready() must not re-insert the entry
354
- if (this.closing || this.closed || !blobs.key) return
355
- this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
356
- })
357
- .catch(this._onerror)
358
- return blobs
359
- }
360
-
361
- /**
362
- * Remember the blob-core key a file id points at so the file server can
363
- * open the core. Lives on the Handle (not the shared operators) because the
364
- * epoch-key derivation pulls native crypto — the RPC client must stay
365
- * bundleable without it.
366
- *
367
- * @param {string} id
368
- * @param {number} [stamp]
369
- */
370
- _registerBlobCore(id, stamp) {
371
- if (!id || !this.root?._coreKeys) return
372
- try {
373
- const { coreKey } = decodeId(id)
374
- const hex = b4a.toString(coreKey, 'hex')
375
- if (!this.root._coreKeys.has(hex)) {
376
- // no stamp on a file-field value, look it up (fire-and-forget, idempotent)
377
- if (stamp === undefined) {
378
- this.store
379
- .get('files', id)
380
- .then(({ data }) => {
381
- if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
382
- })
383
- .catch(safetyCatch)
384
- return
385
- }
386
- const key = this._blobCoreKey(stamp)
387
- if (!key) return // unknown epoch — this device is not entitled to the core
388
- this.root._coreKeys.set(hex, key)
389
- }
390
- // remember which handle read it, so close prunes the entry
391
- if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
392
- } catch {
393
- // ignore invalid ids
394
- }
395
- }
396
-
397
- // base-era cores use the OWNING handle's key, rooms have their own
398
- _blobCoreKey(stamp) {
399
- if (!stamp) return this.store.encryptionKey
400
- const entropy = this.store.keyring.entropy(stamp)
401
- return entropy ? blobEpochKey(entropy) : null
402
- }
403
-
404
- /** Canonical id — identity id for the root handle, store key for children. */
405
- get id() {
406
- if (!this.parent) return this.identity.id
407
- return this.store?.key ? hid.encode(this.store.key) : null
408
- }
409
-
410
- /** This device's id + name. `null` on child handles. */
411
- get device() {
412
- if (this.parent) return null
413
- const k = this.store?.writerKey
414
- return k ? { id: hid.encode(k), name: this._opts.name || null } : null
415
- }
416
-
417
- get suspended() {
418
- return this._sus?.suspended === true
419
- }
420
-
421
340
  /**
422
341
  * Initialise a fresh database: write the genesis claim, derive the writer.
423
342
  * Forwards to `Database.bootstrap`.
@@ -440,15 +359,13 @@ export class Handle extends ReadyResource {
440
359
  }
441
360
 
442
361
  /**
443
- * Flip this handle's swarm announce mode `setActive(false)` demotes an
444
- * idle/background room to server-only (still reachable, stops searching);
445
- * `setActive(true)` promotes it back on focus. Cheap, safe to call often.
362
+ * `true` ranks this handle as just touched, `false` takes it out of the swarm until the
363
+ * next update lands in it.
446
364
  *
447
365
  * @param {boolean} active
448
- * @returns {Promise<void>}
449
366
  */
450
367
  setActive(active) {
451
- return this.store.setActive(active)
368
+ this.store.setActive(active)
452
369
  }
453
370
 
454
371
  /**
@@ -485,12 +402,6 @@ export class Handle extends ReadyResource {
485
402
  return str
486
403
  }
487
404
 
488
- async _syncInvites() {
489
- if (!this.pair) return
490
- const { data } = await this.store.get('invites')
491
- this.pair.syncRows(data || [])
492
- }
493
-
494
405
  /**
495
406
  * Revoke a previously-minted invite by its string form.
496
407
  *
@@ -538,10 +449,8 @@ export class Handle extends ReadyResource {
538
449
  throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
539
450
  }
540
451
 
541
- // confirm must answer within the pairing request's lifetime, any await before
542
- // it (even ~100ms) drops the response. So the key is revealed before the
543
- // membership writes land, and a failure there is recoverable by re-pairing.
544
- // Epoch secrets ride along so a post-rotation joiner reads full history.
452
+ // confirm must answer within the request's lifetime, so the key goes out before the membership
453
+ // writes land; epoch secrets ride along so a post-rotation joiner reads full history
545
454
  const epochs = this.store.keyring.all()
546
455
  await candidate.confirm({
547
456
  key: this.store.key,
@@ -566,19 +475,131 @@ export class Handle extends ReadyResource {
566
475
  }
567
476
 
568
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
569
479
  await this.store.tx(async (tx) => {
480
+ await tx.call('add-member', member)
570
481
  await tx.call('add-writer', {
571
482
  sig,
572
483
  master: this.identity.publicKey,
573
484
  writer: writerKey,
574
- // add-member in the same transaction decides the rank, a refusal discards both
575
485
  memberId: member.id,
576
486
  ts: member.updatedAt || Date.now()
577
487
  })
578
- await tx.call('add-member', member)
579
488
  })
580
489
  }
581
490
 
491
+ /**
492
+ * Leave a child handle — removes it from the parent's `handles` collection
493
+ * and closes the session. No-op on root handles.
494
+ *
495
+ * @returns {Promise<void>}
496
+ */
497
+ async leave() {
498
+ if (!this.parent) return
499
+ await this.parent.store.call('del-handle', { id: hid.encode(this.store.key) })
500
+ await this.close()
501
+ }
502
+
503
+ /**
504
+ * Pause networking + storage. Idempotent; no-op on child handles.
505
+ *
506
+ * @returns {Promise<void>}
507
+ */
508
+ async suspend() {
509
+ if (this._sus) await this._sus.suspend()
510
+ }
511
+
512
+ /**
513
+ * Resume a suspended root handle. Idempotent; no-op on child handles.
514
+ *
515
+ * @returns {Promise<void>}
516
+ */
517
+ async resume() {
518
+ if (this._sus) await this._sus.resume()
519
+ }
520
+
521
+ /**
522
+ * @param {Uint8Array} coreKey
523
+ * @param {object} info
524
+ * @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
525
+ */
526
+ _resolveCore(coreKey, info) {
527
+ const hex = b4a.toString(coreKey, 'hex')
528
+ const encryptionKey = this.root._coreKeys.get(hex)
529
+ if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
530
+ return null
531
+ }
532
+
533
+ _baseBlobs() {
534
+ if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
535
+ return this._blobs
536
+ }
537
+
538
+ _makeBlobs(name, encryptionKey, stamp) {
539
+ const blobs = new Blobs({
540
+ store: this.store.store,
541
+ network: this.network,
542
+ encryptionKey,
543
+ name
544
+ })
545
+ blobs.stamp = stamp
546
+ blobs
547
+ .ready()
548
+ .then(() => {
549
+ // close prunes _coreKeys first, a late ready() must not re-insert the entry
550
+ if (this.closing || this.closed || !blobs.key) return
551
+ this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
552
+ })
553
+ .catch(this._onerror)
554
+ return blobs
555
+ }
556
+
557
+ /**
558
+ * Remember the blob-core key a file id points at so the file server can open the core.
559
+ *
560
+ * @param {string} id
561
+ * @param {number} [stamp]
562
+ */
563
+ _registerBlobCore(id, stamp) {
564
+ if (!id || !this.root?._coreKeys) return
565
+ try {
566
+ const { coreKey } = decodeId(id)
567
+ const hex = b4a.toString(coreKey, 'hex')
568
+ if (!this.root._coreKeys.has(hex)) {
569
+ // no stamp on a file-field value, look it up (fire-and-forget, idempotent)
570
+ if (stamp === undefined) {
571
+ this.store
572
+ .get('files', id)
573
+ .then(({ data }) => {
574
+ if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
575
+ })
576
+ .catch(safetyCatch)
577
+ return
578
+ }
579
+ const key = this._blobCoreKey(stamp)
580
+ if (!key) return // unknown epoch — this device is not entitled to the core
581
+ this.root._coreKeys.set(hex, key)
582
+ }
583
+ // remember which handle read it, so close prunes the entry
584
+ if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
585
+ } catch {
586
+ // ignore invalid ids
587
+ }
588
+ }
589
+
590
+ // base-era cores use the OWNING handle's key, rooms have their own
591
+ _blobCoreKey(stamp) {
592
+ if (!stamp) return this.store.encryptionKey
593
+ const entropy = this.store.keyring.entropy(stamp)
594
+ return entropy ? blobEpochKey(entropy) : null
595
+ }
596
+
597
+ async _syncInvites() {
598
+ if (!this.pair) return
599
+ const { data } = await this.store.get('invites')
600
+ this.pair.syncRows(data || [])
601
+ }
602
+
582
603
  // `grants` treats an unknown role as "no", so an app role name must fail loudly
583
604
  async _checkGrant(role) {
584
605
  if (!isRank(role)) {
@@ -593,21 +614,8 @@ export class Handle extends ReadyResource {
593
614
  }
594
615
 
595
616
  /**
596
- * Leave a child handle — removes it from the parent's `handles` collection
597
- * and closes the session. No-op on root handles.
598
- *
599
- * @returns {Promise<void>}
600
- */
601
- async leave() {
602
- if (!this.parent) return
603
- await this.parent.store.call('del-handle', { id: hid.encode(this.store.key) })
604
- await this.close()
605
- }
606
-
607
- /**
608
- * Create a new child handle of `type`. Owner-flow — generates a fresh
609
- * writer, adds it as a writer + member, and registers the child on the
610
- * parent's `handles` collection.
617
+ * Create a new child handle of `type`. Owner-flow generates a fresh writer, adds it as a
618
+ * writer + member, and registers the child on the parent's `handles` collection.
611
619
  *
612
620
  * @param {string} type
613
621
  * @param {CreateChildOpts} [opts]
@@ -635,8 +643,7 @@ export class Handle extends ReadyResource {
635
643
  child.name = name
636
644
 
637
645
  const id = hid.encode(child.store.key)
638
- // add-handle makes the row visible before this create finishes, and a
639
- // duplicate Handle over the same core deadlocks in ready()
646
+ // add-handle makes the row visible before create finishes; a second Handle on the same core deadlocks
640
647
  const inflight = new Promise((resolve, reject) => {
641
648
  publish = resolve
642
649
  abort = reject
@@ -676,9 +683,7 @@ export class Handle extends ReadyResource {
676
683
  })
677
684
 
678
685
  if (accept !== false) this._wireAccept(child, { role })
679
- bind(child, type)
680
- this.children.add(child)
681
- this.emit('handle', child, { name, role })
686
+ this._adopt(child, { name, role })
682
687
  publish(child)
683
688
  this._loading?.delete(id)
684
689
  return child
@@ -691,9 +696,7 @@ export class Handle extends ReadyResource {
691
696
  }
692
697
 
693
698
  /**
694
- * Join a child handle by invite (joiner-flow). Waits for writer
695
- * capability and registers the child on the parent's `handles`
696
- * collection.
699
+ * Join a child handle by invite (joiner-flow).
697
700
  *
698
701
  * @param {string} invite
699
702
  * @param {string} type
@@ -726,10 +729,8 @@ export class Handle extends ReadyResource {
726
729
  async _pair(invite, type, { routes, timeout } = {}, target = null) {
727
730
  const deadline = timeout || TIMEOUT
728
731
 
729
- // idempotent while our stored writer is still admitted, so no waiting on a
730
- // peer to confirm. A removed writer's core is frozen and reusing its keypair
731
- // only gets it re-removed, so fall through to a real pairing, which mints a
732
- // fresh keypair. A member row outlives a revoked device, so test the device.
732
+ // a stored writer still admitted reopens without waiting; a removed writer's core is
733
+ // frozen, so fall through to a real pairing and a fresh keypair
733
734
  if (target) {
734
735
  const { data: joined } = await this.store.get('handles')
735
736
  const existing = joined.find(
@@ -786,9 +787,7 @@ export class Handle extends ReadyResource {
786
787
  updatedAt: ts
787
788
  })
788
789
  this._wireAccept(child)
789
- bind(child, type)
790
- this.children.add(child)
791
- this.emit('handle', child, {})
790
+ this._adopt(child, {})
792
791
  publish(child)
793
792
  this._loading?.delete(id)
794
793
  return child
@@ -801,9 +800,8 @@ export class Handle extends ReadyResource {
801
800
  }
802
801
 
803
802
  /**
804
- * Get an open child by id, or re-open it. Concurrent calls for the same id
805
- * share one in-flight load, so the child is built — and `handle` emitted —
806
- * exactly once.
803
+ * Get an open child by id, or re-open it. Concurrent calls for the same id share one
804
+ * in-flight load, so the child is built — and `handle` emitted — exactly once.
807
805
  *
808
806
  * @param {string} type
809
807
  * @param {string} id
@@ -858,21 +856,10 @@ export class Handle extends ReadyResource {
858
856
  }
859
857
  // `accept: false` is a host-approval gate, re-arming it silently is worse
860
858
  if (opts?.accept !== false) this._wireAccept(child, { role: opts?.role })
861
- bind(child, type)
862
- this.children.add(child)
863
- this.emit('handle', child, {})
859
+ this._adopt(child, {})
864
860
  return child
865
861
  }
866
862
 
867
- /**
868
- * Pause networking + storage. Idempotent; no-op on child handles.
869
- *
870
- * @returns {Promise<void>}
871
- */
872
- async suspend() {
873
- if (this._sus) await this._sus.suspend()
874
- }
875
-
876
863
  async _suspend() {
877
864
  if (this.closing || this.closed) return
878
865
  await Promise.all([...this.children].map((c) => c.pair?.suspend()))
@@ -885,15 +872,6 @@ export class Handle extends ReadyResource {
885
872
  }
886
873
  }
887
874
 
888
- /**
889
- * Resume a suspended root handle. Idempotent; no-op on child handles.
890
- *
891
- * @returns {Promise<void>}
892
- */
893
- async resume() {
894
- if (this._sus) await this._sus.resume()
895
- }
896
-
897
875
  async _resume() {
898
876
  if (this.closing || this.closed) return
899
877
  try {
@@ -906,6 +884,74 @@ export class Handle extends ReadyResource {
906
884
  await Promise.all([...this.children].map((child) => child.pair?.resume()))
907
885
  }
908
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
+
916
+ /**
917
+ * @param {Handle} child
918
+ * @param {{ role?: string }} [opts]
919
+ */
920
+ _wireAccept(child, { role } = {}) {
921
+ child.pair.on('candidate', (cand) => {
922
+ if (this.closing || this.closed || child.closing || child.closed) return
923
+ child.accept(cand, { role }).catch(this._onerror)
924
+ })
925
+ }
926
+
927
+ /**
928
+ * @param {string} id
929
+ * @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
930
+ * @returns {Promise<void>}
931
+ */
932
+ async _saveKeyPair(id, keyPair) {
933
+ if (!this.local || !keyPair) return
934
+ await this.local.store.put('handle-keypairs', {
935
+ id,
936
+ publicKey: keyPair.publicKey,
937
+ secretKey: keyPair.secretKey
938
+ })
939
+ }
940
+
941
+ /**
942
+ * @param {string} id
943
+ * @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
944
+ */
945
+ async _loadKeyPair(id) {
946
+ if (!this.local) return null
947
+ const { data } = await this.local.store.get('handle-keypairs', id)
948
+ if (!data) return null
949
+ return {
950
+ publicKey: data.publicKey,
951
+ secretKey: data.secretKey
952
+ }
953
+ }
954
+
909
955
  /**
910
956
  * Pair into an existing handle via an invite, returning a brand-new
911
957
  * `Handle` already configured with the resolved key + encryption key.
@@ -926,8 +972,7 @@ export class Handle extends ReadyResource {
926
972
  if (!spec) throw CeroError.REQUIRED('spec')
927
973
 
928
974
  const writer = Identity.randomKeyPair()
929
- // join-only: no member listener, so concurrent joins never collide on the
930
- // identity-derived default topic ('Active member already exist')
975
+ // join-only: a member listener here would collide with concurrent joins on the identity topic
931
976
  const pair = new Pairing({ network: net, identity: id, host: false })
932
977
  await pair.ready()
933
978
 
@@ -963,45 +1008,6 @@ export class Handle extends ReadyResource {
963
1008
  keyPair: writer
964
1009
  })
965
1010
  }
966
-
967
- /**
968
- * @param {Handle} child
969
- * @param {{ role?: string }} [opts]
970
- */
971
- _wireAccept(child, { role } = {}) {
972
- child.pair.on('candidate', (cand) => {
973
- if (this.closing || this.closed || child.closing || child.closed) return
974
- child.accept(cand, { role }).catch(this._onerror)
975
- })
976
- }
977
-
978
- /**
979
- * @param {string} id
980
- * @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
981
- * @returns {Promise<void>}
982
- */
983
- async _saveKeyPair(id, keyPair) {
984
- if (!this.local || !keyPair) return
985
- await this.local.store.put('handle-keypairs', {
986
- id,
987
- publicKey: keyPair.publicKey,
988
- secretKey: keyPair.secretKey
989
- })
990
- }
991
-
992
- /**
993
- * @param {string} id
994
- * @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
995
- */
996
- async _loadKeyPair(id) {
997
- if (!this.local) return null
998
- const { data } = await this.local.store.get('handle-keypairs', id)
999
- if (!data) return null
1000
- return {
1001
- publicKey: data.publicKey,
1002
- secretKey: data.secretKey
1003
- }
1004
- }
1005
1011
  }
1006
1012
 
1007
1013
  function pickHandle(spec, type) {