@cero-base/cero 1.18.2 → 2.0.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 (44) hide show
  1. package/README.md +38 -644
  2. package/package.json +16 -12
  3. package/src/build/index.js +21 -50
  4. package/src/build/internal.js +121 -0
  5. package/src/build/schemas.js +2 -8
  6. package/src/extensions/handle-sync.js +3 -10
  7. package/src/extensions/index.js +6 -0
  8. package/src/extensions/profile-sync.js +0 -5
  9. package/src/handle/index.js +255 -300
  10. package/src/index.js +65 -112
  11. package/src/lib/bluetooth.js +25 -56
  12. package/src/lib/constants.js +0 -15
  13. package/src/lib/operators.js +24 -74
  14. package/src/lib/peek.js +4 -8
  15. package/src/lib/refs.js +46 -0
  16. package/src/lib/spec.js +2 -3
  17. package/src/local/index.js +4 -5
  18. package/src/rpc/client.js +27 -39
  19. package/src/rpc/index.js +3 -3
  20. package/src/rpc/server.js +29 -40
  21. package/types/build/index.d.ts +19 -7
  22. package/types/build/internal.d.ts +78 -0
  23. package/types/build/schemas.d.ts +3 -3
  24. package/types/extensions/handle-sync.d.ts +4 -9
  25. package/types/extensions/index.d.ts +24 -2
  26. package/types/extensions/profile-sync.d.ts +2 -6
  27. package/types/handle/index.d.ts +218 -254
  28. package/types/index.d.ts +78 -102
  29. package/types/lib/bluetooth.d.ts +24 -46
  30. package/types/lib/constants.d.ts +5 -16
  31. package/types/lib/operators.d.ts +49 -77
  32. package/types/lib/peek.d.ts +3 -4
  33. package/types/lib/refs.d.ts +36 -0
  34. package/types/lib/spec.d.ts +5 -1
  35. package/types/local/index.d.ts +24 -23
  36. package/types/rpc/client.d.ts +107 -127
  37. package/types/rpc/index.d.ts +7 -2
  38. package/types/rpc/server.d.ts +62 -76
  39. package/src/build/builtins.js +0 -174
  40. package/src/lib/internal.js +0 -9
  41. package/src/lib/utils.js +0 -67
  42. package/types/build/builtins.d.ts +0 -100
  43. package/types/lib/internal.d.ts +0 -24
  44. package/types/lib/utils.d.ts +0 -55
@@ -12,7 +12,8 @@ import { Identity } from '@cero-base/core/identity'
12
12
  import { Database } from '@cero-base/core/database'
13
13
  import { epochEntries, blobEpochKey } from '@cero-base/core/database/encryption'
14
14
  import { Pairing } from '@cero-base/core/pairing'
15
- import { toId, grants, can, isRank, addWriterPayload, REMOVE } from '@cero-base/core/utils'
15
+ import hid from 'hypercore-id-encoding'
16
+ import { grants, can, isRank, admission, REMOVE, onAbort } from '@cero-base/core/utils'
16
17
  import { CeroError } from '@cero-base/core/errors'
17
18
  import { Blobs } from '@cero-base/core/blobs'
18
19
  import { decodeId } from '@cero-base/core/blobs/codec'
@@ -20,10 +21,10 @@ import { FileServer } from '@cero-base/core/blobs/server'
20
21
 
21
22
  import { NS, TIMEOUT } from '../lib/constants.js'
22
23
 
23
- import { attachRefs, onAbort } from '../lib/utils.js'
24
+ import { Ref } from '../lib/refs.js'
24
25
  import { bind } from '../lib/operators.js'
25
26
 
26
- export { Ref } from '../lib/utils.js'
27
+ export { Ref } from '../lib/refs.js'
27
28
 
28
29
  /**
29
30
  * @typedef {import('@cero-base/core/network').Network} Network
@@ -74,25 +75,18 @@ export { Ref } from '../lib/utils.js'
74
75
  * @property {string} [role] Role to grant the joining peer. Falls back to the invite's role, then `'member'`.
75
76
  * @property {string | null} [name]
76
77
  *
77
- * @typedef {object} RecoverOpts
78
- * @property {number} [timeout]
79
- *
80
78
  * @typedef {object} HandleExtra
81
79
  * @property {string | null} [name] Display name; set on child handles by the owner flow.
82
- * @property {import('../lib/utils.js').Ref} [profile] `profile` ref, attached dynamically when the schema declares one.
83
- * @property {import('../lib/utils.js').Ref} [members] `members` ref, attached dynamically when the schema declares one.
80
+ * @property {import('../lib/refs.js').Ref} [profile] `profile` ref, attached dynamically when the schema declares one.
81
+ * @property {import('../lib/refs.js').Ref} [members] `members` ref, attached dynamically when the schema declares one.
84
82
  *
85
83
  * @typedef {Handle & HandleExtra} Child A child handle plus its dynamically-attached refs.
86
84
  *
87
- * @typedef {Handle & Record<string, import('../lib/utils.js').Ref>} CeroHandle A handle with every schema ref reachable as a `Ref` property (e.g. `me.profile`, `room.messages`).
85
+ * @typedef {Handle & Record<string, import('../lib/refs.js').Ref>} CeroHandle A handle with every schema ref reachable as a `Ref` property (e.g. `me.profile`, `room.messages`).
88
86
  */
89
87
 
90
88
  /**
91
89
  * A cero handle — a single writable database session attached to a swarm.
92
- * The "root" handle is the user's facade; child handles (created via
93
- * `_create`/`_join`/`_load`) live under it and share the same identity,
94
- * network and corestore. Ref properties (`profile`, `members`, ...) are
95
- * attached dynamically per the schema; child handles also carry a `name`.
96
90
  */
97
91
  export class Handle extends ReadyResource {
98
92
  /** @param {HandleOpts} [opts] */
@@ -152,6 +146,78 @@ export class Handle extends ReadyResource {
152
146
  this._wantsPair = opts.pair !== false
153
147
  }
154
148
 
149
+ /**
150
+ * An `AbortSignal` that fires when this handle closes.
151
+ *
152
+ * @returns {AbortSignal}
153
+ */
154
+ get signal() {
155
+ if (!this._ac) {
156
+ this._ac = new AbortController()
157
+ if (this.closed) this._ac.abort()
158
+ else this.once('close', () => this._ac.abort())
159
+ }
160
+ return this._ac.signal
161
+ }
162
+
163
+ /** The top-most handle in the parent chain — itself for a root handle. */
164
+ get root() {
165
+ let h = this
166
+ while (h.parent) h = h.parent
167
+ return h
168
+ }
169
+
170
+ /**
171
+ * Lazily-built file server for this identity. Root-only — child handles
172
+ * reach it through `this.root.fileServer`.
173
+ *
174
+ * @returns {FileServer}
175
+ */
176
+ get fileServer() {
177
+ if (this.parent) return this.root.fileServer
178
+ if (!this._fileServer) {
179
+ this._fileServer = new FileServer({
180
+ store: this.store.store,
181
+ resolve: (coreKey, info) => this._resolveCore(coreKey, info)
182
+ })
183
+ }
184
+ return this._fileServer
185
+ }
186
+
187
+ /**
188
+ * Lazily-built blob store for THIS handle's writing device, at the current rotation epoch.
189
+ *
190
+ * @returns {Blobs}
191
+ */
192
+ get blobs() {
193
+ const stamp = this.store.keyring.current
194
+ if (!stamp) return this._baseBlobs()
195
+ let blobs = this._epochBlobs?.get(stamp)
196
+ if (!blobs) {
197
+ const entropy = this.store.keyring.entropy(stamp)
198
+ blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
199
+ ;(this._epochBlobs ??= new Map()).set(stamp, blobs)
200
+ }
201
+ return blobs
202
+ }
203
+
204
+ /** Canonical id — identity id for the root handle, store key for children. */
205
+ get id() {
206
+ if (!this.parent) return this.identity.id
207
+ return this.store?.key ? hid.encode(this.store.key) : null
208
+ }
209
+
210
+ /** This device's id + name. `null` on child handles. */
211
+ get device() {
212
+ if (this.parent) return null
213
+ const k = this.store?.writerKey
214
+ return k ? { id: hid.encode(k), name: this._opts.name || null } : null
215
+ }
216
+
217
+ get suspended() {
218
+ return this._sus?.suspended === true
219
+ }
220
+
155
221
  async _open() {
156
222
  await this.store.ready()
157
223
  if (this._wantsPair) {
@@ -165,13 +231,12 @@ export class Handle extends ReadyResource {
165
231
  }
166
232
  })
167
233
  await this.pair.ready()
168
- // serve invites persisted by any member, and keep the set in step as rows
169
- // replicate in or disappear (minted, revoked or consumed elsewhere)
234
+ // serve invites persisted by any member, in step with the rows
170
235
  await this._syncInvites().catch(safetyCatch)
171
236
  this._invitesSync = () => this._syncInvites().catch(safetyCatch)
172
237
  this.store.on('update', this._invitesSync)
173
238
  }
174
- attachRefs(this, this.store.refs)
239
+ Ref.attach(this, this.store.refs)
175
240
  this.root._coreKeys.set(b4a.toString(this.store.key, 'hex'), this.store.encryptionKey)
176
241
  if (!this.parent) await this.fileServer.listen()
177
242
  }
@@ -218,9 +283,9 @@ export class Handle extends ReadyResource {
218
283
  }
219
284
 
220
285
  /**
221
- * Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`)
222
- * to this handle's lifecycle — it's destroyed automatically on close, so
223
- * callers don't track cleanup. De-registers itself if destroyed earlier.
286
+ * Tie a destroyable resource (a `watch` stream, a timer, any `{ destroy }`) to this
287
+ * handle's lifecycle — it's destroyed automatically on close, so callers don't track
288
+ * cleanup.
224
289
  *
225
290
  * @template {{ destroy?: Function, once?: Function }} T
226
291
  * @param {T} resource
@@ -236,22 +301,6 @@ export class Handle extends ReadyResource {
236
301
  return resource
237
302
  }
238
303
 
239
- /**
240
- * An `AbortSignal` that fires when this handle closes. Pass it as
241
- * `{ signal }` to `on`/`after`/`before`/`watch` to drop a subscription on
242
- * close — or use your own `AbortController` for a finer scope.
243
- *
244
- * @returns {AbortSignal}
245
- */
246
- get signal() {
247
- if (!this._ac) {
248
- this._ac = new AbortController()
249
- if (this.closed) this._ac.abort()
250
- else this.once('close', () => this._ac.abort())
251
- }
252
- return this._ac.signal
253
- }
254
-
255
304
  /**
256
305
  * `EventEmitter.on` plus an optional `{ signal }` that removes the listener
257
306
  * when the signal aborts — e.g. `me.on('handle', fn, { signal: me.signal })`.
@@ -267,42 +316,6 @@ export class Handle extends ReadyResource {
267
316
  return this
268
317
  }
269
318
 
270
- /** The top-most handle in the parent chain — itself for a root handle. */
271
- get root() {
272
- let h = this
273
- while (h.parent) h = h.parent
274
- return h
275
- }
276
-
277
- /**
278
- * Lazily-built file server for this identity. Root-only — child handles
279
- * reach it through `this.root.fileServer`.
280
- *
281
- * @returns {FileServer}
282
- */
283
- get fileServer() {
284
- if (this.parent) return this.root.fileServer
285
- if (!this._fileServer) {
286
- this._fileServer = new FileServer({
287
- store: this.store.store,
288
- resolve: (coreKey, info) => this._resolveCore(coreKey, info)
289
- })
290
- }
291
- return this._fileServer
292
- }
293
-
294
- /**
295
- * @param {Uint8Array} coreKey
296
- * @param {object} info
297
- * @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
298
- */
299
- _resolveCore(coreKey, info) {
300
- const hex = b4a.toString(coreKey, 'hex')
301
- const encryptionKey = this.root._coreKeys.get(hex)
302
- if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
303
- return null
304
- }
305
-
306
319
  /**
307
320
  * Resolve a durable file id to an ephemeral download url via this identity's
308
321
  * file server.
@@ -314,112 +327,6 @@ export class Handle extends ReadyResource {
314
327
  return this.root.fileServer.getLink(id)
315
328
  }
316
329
 
317
- /**
318
- * Lazily-built blob store for THIS handle's writing device, at the current
319
- * rotation epoch. One core per writer per epoch that writes files: the base
320
- * era uses the handle's encryptionKey, rotated epochs use a key derived
321
- * from the epoch entropy — so a removed member cannot decrypt files added
322
- * after the rotation. Instances carry `.stamp` so the file row records
323
- * which era its core belongs to.
324
- *
325
- * @returns {Blobs}
326
- */
327
- get blobs() {
328
- const stamp = this.store.keyring.current
329
- if (!stamp) return this._baseBlobs()
330
- let blobs = this._epochBlobs?.get(stamp)
331
- if (!blobs) {
332
- const entropy = this.store.keyring.entropy(stamp)
333
- blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
334
- ;(this._epochBlobs ??= new Map()).set(stamp, blobs)
335
- }
336
- return blobs
337
- }
338
-
339
- _baseBlobs() {
340
- if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
341
- return this._blobs
342
- }
343
-
344
- _makeBlobs(name, encryptionKey, stamp) {
345
- const blobs = new Blobs({
346
- store: this.store.store,
347
- network: this.network,
348
- encryptionKey,
349
- name
350
- })
351
- blobs.stamp = stamp
352
- blobs
353
- .ready()
354
- .then(() => {
355
- // close prunes _coreKeys first, a late ready() must not re-insert the entry
356
- if (this.closing || this.closed || !blobs.key) return
357
- this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
358
- })
359
- .catch(this._onerror)
360
- return blobs
361
- }
362
-
363
- /**
364
- * Remember the blob-core key a file id points at so the file server can
365
- * open the core. Lives on the Handle (not the shared operators) because the
366
- * epoch-key derivation pulls native crypto — the RPC client must stay
367
- * bundleable without it.
368
- *
369
- * @param {string} id
370
- * @param {number} [stamp]
371
- */
372
- _registerBlobCore(id, stamp) {
373
- if (!id || !this.root?._coreKeys) return
374
- try {
375
- const { coreKey } = decodeId(id)
376
- const hex = b4a.toString(coreKey, 'hex')
377
- if (!this.root._coreKeys.has(hex)) {
378
- // no stamp on a file-field value, look it up (fire-and-forget, idempotent)
379
- if (stamp === undefined) {
380
- this.store
381
- .get('files', id)
382
- .then(({ data }) => {
383
- if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
384
- })
385
- .catch(safetyCatch)
386
- return
387
- }
388
- const key = this._blobCoreKey(stamp)
389
- if (!key) return // unknown epoch — this device is not entitled to the core
390
- this.root._coreKeys.set(hex, key)
391
- }
392
- // remember which handle read it, so close prunes the entry
393
- if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
394
- } catch {
395
- // ignore invalid ids
396
- }
397
- }
398
-
399
- // base-era cores use the OWNING handle's key, rooms have their own
400
- _blobCoreKey(stamp) {
401
- if (!stamp) return this.store.encryptionKey
402
- const entropy = this.store.keyring.entropy(stamp)
403
- return entropy ? blobEpochKey(entropy) : null
404
- }
405
-
406
- /** Canonical id — identity id for the root handle, store key for children. */
407
- get id() {
408
- if (!this.parent) return this.identity.id
409
- return this.store?.key ? toId(this.store.key) : null
410
- }
411
-
412
- /** This device's id + name. `null` on child handles. */
413
- get device() {
414
- if (this.parent) return null
415
- const k = this.store?.writerKey
416
- return k ? { id: toId(k), name: this._opts.name || null } : null
417
- }
418
-
419
- get suspended() {
420
- return this._sus?.suspended === true
421
- }
422
-
423
330
  /**
424
331
  * Initialise a fresh database: write the genesis claim, derive the writer.
425
332
  * Forwards to `Database.bootstrap`.
@@ -442,23 +349,9 @@ export class Handle extends ReadyResource {
442
349
  }
443
350
 
444
351
  /**
445
- * Claim + wait until this peer becomes a writer + bring the bee up to date.
446
- * Used by `restore()` after wiping local state.
447
- *
448
- * @param {RecoverOpts} [opts]
449
- * @returns {Promise<void>}
450
- */
451
- async recover({ timeout = TIMEOUT } = {}) {
452
- if (this.store.writable) return
453
- await this.store.claim()
454
- await this.store.whenWritable({ timeout })
455
- await this.store.bee.update()
456
- }
457
-
458
- /**
459
- * Flip this handle's swarm announce mode — `setActive(false)` demotes an
460
- * idle/background room to server-only (still reachable, stops searching);
461
- * `setActive(true)` promotes it back on focus. Cheap, safe to call often.
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.
462
355
  *
463
356
  * @param {boolean} active
464
357
  * @returns {Promise<void>}
@@ -501,12 +394,6 @@ export class Handle extends ReadyResource {
501
394
  return str
502
395
  }
503
396
 
504
- async _syncInvites() {
505
- if (!this.pair) return
506
- const { data } = await this.store.get('invites')
507
- this.pair.syncRows(data || [])
508
- }
509
-
510
397
  /**
511
398
  * Revoke a previously-minted invite by its string form.
512
399
  *
@@ -554,10 +441,8 @@ export class Handle extends ReadyResource {
554
441
  throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
555
442
  }
556
443
 
557
- // confirm must answer within the pairing request's lifetime, any await before
558
- // it (even ~100ms) drops the response. So the key is revealed before the
559
- // membership writes land, and a failure there is recoverable by re-pairing.
560
- // Epoch secrets ride along so a post-rotation joiner reads full history.
444
+ // confirm must answer within the request's lifetime, so the key goes out before the membership
445
+ // writes land; epoch secrets ride along so a post-rotation joiner reads full history
561
446
  const epochs = this.store.keyring.all()
562
447
  await candidate.confirm({
563
448
  key: this.store.key,
@@ -568,7 +453,7 @@ export class Handle extends ReadyResource {
568
453
  const ts = Date.now()
569
454
  const writerKey = Hypercore.key({ version: 2, signers: [{ publicKey: data.subarray(32, 64) }] })
570
455
  const member = {
571
- id: toId(data.subarray(0, 32)),
456
+ id: hid.encode(data.subarray(0, 32)),
572
457
  key: writerKey,
573
458
  role,
574
459
  name: name || null,
@@ -581,9 +466,7 @@ export class Handle extends ReadyResource {
581
466
  return
582
467
  }
583
468
 
584
- const sig = this.identity.sign(
585
- addWriterPayload(this.store.key, writerKey, this.store.writerKey)
586
- )
469
+ const sig = this.identity.sign(admission(this.store.key, writerKey, this.store.writerKey))
587
470
  await this.store.tx(async (tx) => {
588
471
  await tx.call('add-writer', {
589
472
  sig,
@@ -597,6 +480,118 @@ export class Handle extends ReadyResource {
597
480
  })
598
481
  }
599
482
 
483
+ /**
484
+ * Leave a child handle — removes it from the parent's `handles` collection
485
+ * and closes the session. No-op on root handles.
486
+ *
487
+ * @returns {Promise<void>}
488
+ */
489
+ async leave() {
490
+ if (!this.parent) return
491
+ await this.parent.store.call('del-handle', { id: hid.encode(this.store.key) })
492
+ await this.close()
493
+ }
494
+
495
+ /**
496
+ * Pause networking + storage. Idempotent; no-op on child handles.
497
+ *
498
+ * @returns {Promise<void>}
499
+ */
500
+ async suspend() {
501
+ if (this._sus) await this._sus.suspend()
502
+ }
503
+
504
+ /**
505
+ * Resume a suspended root handle. Idempotent; no-op on child handles.
506
+ *
507
+ * @returns {Promise<void>}
508
+ */
509
+ async resume() {
510
+ if (this._sus) await this._sus.resume()
511
+ }
512
+
513
+ /**
514
+ * @param {Uint8Array} coreKey
515
+ * @param {object} info
516
+ * @returns {{ key: Uint8Array, encryptionKey: Uint8Array } | null}
517
+ */
518
+ _resolveCore(coreKey, info) {
519
+ const hex = b4a.toString(coreKey, 'hex')
520
+ const encryptionKey = this.root._coreKeys.get(hex)
521
+ if (encryptionKey !== undefined) return { key: coreKey, encryptionKey }
522
+ return null
523
+ }
524
+
525
+ _baseBlobs() {
526
+ if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
527
+ return this._blobs
528
+ }
529
+
530
+ _makeBlobs(name, encryptionKey, stamp) {
531
+ const blobs = new Blobs({
532
+ store: this.store.store,
533
+ network: this.network,
534
+ encryptionKey,
535
+ name
536
+ })
537
+ blobs.stamp = stamp
538
+ blobs
539
+ .ready()
540
+ .then(() => {
541
+ // close prunes _coreKeys first, a late ready() must not re-insert the entry
542
+ if (this.closing || this.closed || !blobs.key) return
543
+ this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
544
+ })
545
+ .catch(this._onerror)
546
+ return blobs
547
+ }
548
+
549
+ /**
550
+ * Remember the blob-core key a file id points at so the file server can open the core.
551
+ *
552
+ * @param {string} id
553
+ * @param {number} [stamp]
554
+ */
555
+ _registerBlobCore(id, stamp) {
556
+ if (!id || !this.root?._coreKeys) return
557
+ try {
558
+ const { coreKey } = decodeId(id)
559
+ const hex = b4a.toString(coreKey, 'hex')
560
+ if (!this.root._coreKeys.has(hex)) {
561
+ // no stamp on a file-field value, look it up (fire-and-forget, idempotent)
562
+ if (stamp === undefined) {
563
+ this.store
564
+ .get('files', id)
565
+ .then(({ data }) => {
566
+ if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
567
+ })
568
+ .catch(safetyCatch)
569
+ return
570
+ }
571
+ const key = this._blobCoreKey(stamp)
572
+ if (!key) return // unknown epoch — this device is not entitled to the core
573
+ this.root._coreKeys.set(hex, key)
574
+ }
575
+ // remember which handle read it, so close prunes the entry
576
+ if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
577
+ } catch {
578
+ // ignore invalid ids
579
+ }
580
+ }
581
+
582
+ // base-era cores use the OWNING handle's key, rooms have their own
583
+ _blobCoreKey(stamp) {
584
+ if (!stamp) return this.store.encryptionKey
585
+ const entropy = this.store.keyring.entropy(stamp)
586
+ return entropy ? blobEpochKey(entropy) : null
587
+ }
588
+
589
+ async _syncInvites() {
590
+ if (!this.pair) return
591
+ const { data } = await this.store.get('invites')
592
+ this.pair.syncRows(data || [])
593
+ }
594
+
600
595
  // `grants` treats an unknown role as "no", so an app role name must fail loudly
601
596
  async _checkGrant(role) {
602
597
  if (!isRank(role)) {
@@ -611,21 +606,8 @@ export class Handle extends ReadyResource {
611
606
  }
612
607
 
613
608
  /**
614
- * Leave a child handle — removes it from the parent's `handles` collection
615
- * and closes the session. No-op on root handles.
616
- *
617
- * @returns {Promise<void>}
618
- */
619
- async leave() {
620
- if (!this.parent) return
621
- await this.parent.store.call('del-handle', { id: toId(this.store.key) })
622
- await this.close()
623
- }
624
-
625
- /**
626
- * Create a new child handle of `type`. Owner-flow — generates a fresh
627
- * writer, adds it as a writer + member, and registers the child on the
628
- * parent's `handles` collection.
609
+ * Create a new child handle of `type`. Owner-flow generates a fresh writer, adds it as a
610
+ * writer + member, and registers the child on the parent's `handles` collection.
629
611
  *
630
612
  * @param {string} type
631
613
  * @param {CreateChildOpts} [opts]
@@ -652,9 +634,8 @@ export class Handle extends ReadyResource {
652
634
  await child.ready()
653
635
  child.name = name
654
636
 
655
- const id = toId(child.store.key)
656
- // add-handle makes the row visible before this create finishes, and a
657
- // duplicate Handle over the same core deadlocks in ready()
637
+ const id = hid.encode(child.store.key)
638
+ // add-handle makes the row visible before create finishes; a second Handle on the same core deadlocks
658
639
  const inflight = new Promise((resolve, reject) => {
659
640
  publish = resolve
660
641
  abort = reject
@@ -671,9 +652,7 @@ export class Handle extends ReadyResource {
671
652
  await tx.call('add-writer', {
672
653
  master: this.identity.publicKey,
673
654
  writer: writerKey,
674
- sig: this.identity.sign(
675
- addWriterPayload(child.store.key, writerKey, child.store.writerKey)
676
- ),
655
+ sig: this.identity.sign(admission(child.store.key, writerKey, child.store.writerKey)),
677
656
  ts
678
657
  })
679
658
  await tx.call('add-member', {
@@ -711,9 +690,7 @@ export class Handle extends ReadyResource {
711
690
  }
712
691
 
713
692
  /**
714
- * Join a child handle by invite (joiner-flow). Waits for writer
715
- * capability and registers the child on the parent's `handles`
716
- * collection.
693
+ * Join a child handle by invite (joiner-flow).
717
694
  *
718
695
  * @param {string} invite
719
696
  * @param {string} type
@@ -746,10 +723,8 @@ export class Handle extends ReadyResource {
746
723
  async _pair(invite, type, { routes, timeout } = {}, target = null) {
747
724
  const deadline = timeout || TIMEOUT
748
725
 
749
- // idempotent while our stored writer is still admitted, so no waiting on a
750
- // peer to confirm. A removed writer's core is frozen and reusing its keypair
751
- // only gets it re-removed, so fall through to a real pairing, which mints a
752
- // fresh keypair. A member row outlives a revoked device, so test the device.
726
+ // a stored writer still admitted reopens without waiting; a removed writer's core is
727
+ // frozen, so fall through to a real pairing and a fresh keypair
753
728
  if (target) {
754
729
  const { data: joined } = await this.store.get('handles')
755
730
  const existing = joined.find(
@@ -758,7 +733,7 @@ export class Handle extends ReadyResource {
758
733
  if (existing) {
759
734
  const known = await this._load(type, existing.id)
760
735
  const { data: me } = await known.store.get('members', this.identity.id)
761
- const { data: device } = await known.store.get('devices', toId(known.store.writerKey))
736
+ const { data: device } = await known.store.get('devices', hid.encode(known.store.writerKey))
762
737
  if (me && device) return known
763
738
  await known.close().catch(safetyCatch)
764
739
  }
@@ -784,7 +759,7 @@ export class Handle extends ReadyResource {
784
759
  await child.ready()
785
760
  if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
786
761
 
787
- const id = toId(child.store.key)
762
+ const id = hid.encode(child.store.key)
788
763
  // same create/open race as _create, a concurrent _load must share this child
789
764
  const inflight = new Promise((resolve, reject) => {
790
765
  publish = resolve
@@ -821,9 +796,8 @@ export class Handle extends ReadyResource {
821
796
  }
822
797
 
823
798
  /**
824
- * Get an open child by id, or re-open it. Concurrent calls for the same id
825
- * share one in-flight load, so the child is built — and `handle` emitted —
826
- * exactly once.
799
+ * Get an open child by id, or re-open it. Concurrent calls for the same id share one
800
+ * in-flight load, so the child is built — and `handle` emitted — exactly once.
827
801
  *
828
802
  * @param {string} type
829
803
  * @param {string} id
@@ -884,15 +858,6 @@ export class Handle extends ReadyResource {
884
858
  return child
885
859
  }
886
860
 
887
- /**
888
- * Pause networking + storage. Idempotent; no-op on child handles.
889
- *
890
- * @returns {Promise<void>}
891
- */
892
- async suspend() {
893
- if (this._sus) await this._sus.suspend()
894
- }
895
-
896
861
  async _suspend() {
897
862
  if (this.closing || this.closed) return
898
863
  await Promise.all([...this.children].map((c) => c.pair?.suspend()))
@@ -905,15 +870,6 @@ export class Handle extends ReadyResource {
905
870
  }
906
871
  }
907
872
 
908
- /**
909
- * Resume a suspended root handle. Idempotent; no-op on child handles.
910
- *
911
- * @returns {Promise<void>}
912
- */
913
- async resume() {
914
- if (this._sus) await this._sus.resume()
915
- }
916
-
917
873
  async _resume() {
918
874
  if (this.closing || this.closed) return
919
875
  try {
@@ -926,6 +882,45 @@ export class Handle extends ReadyResource {
926
882
  await Promise.all([...this.children].map((child) => child.pair?.resume()))
927
883
  }
928
884
 
885
+ /**
886
+ * @param {Handle} child
887
+ * @param {{ role?: string }} [opts]
888
+ */
889
+ _wireAccept(child, { role } = {}) {
890
+ child.pair.on('candidate', (cand) => {
891
+ if (this.closing || this.closed || child.closing || child.closed) return
892
+ child.accept(cand, { role }).catch(this._onerror)
893
+ })
894
+ }
895
+
896
+ /**
897
+ * @param {string} id
898
+ * @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
899
+ * @returns {Promise<void>}
900
+ */
901
+ async _saveKeyPair(id, keyPair) {
902
+ if (!this.local || !keyPair) return
903
+ await this.local.store.put('handle-keypairs', {
904
+ id,
905
+ publicKey: keyPair.publicKey,
906
+ secretKey: keyPair.secretKey
907
+ })
908
+ }
909
+
910
+ /**
911
+ * @param {string} id
912
+ * @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
913
+ */
914
+ async _loadKeyPair(id) {
915
+ if (!this.local) return null
916
+ const { data } = await this.local.store.get('handle-keypairs', id)
917
+ if (!data) return null
918
+ return {
919
+ publicKey: data.publicKey,
920
+ secretKey: data.secretKey
921
+ }
922
+ }
923
+
929
924
  /**
930
925
  * Pair into an existing handle via an invite, returning a brand-new
931
926
  * `Handle` already configured with the resolved key + encryption key.
@@ -946,8 +941,7 @@ export class Handle extends ReadyResource {
946
941
  if (!spec) throw CeroError.REQUIRED('spec')
947
942
 
948
943
  const writer = Identity.randomKeyPair()
949
- // join-only: no member listener, so concurrent joins never collide on the
950
- // identity-derived default topic ('Active member already exist')
944
+ // join-only: a member listener here would collide with concurrent joins on the identity topic
951
945
  const pair = new Pairing({ network: net, identity: id, host: false })
952
946
  await pair.ready()
953
947
 
@@ -983,45 +977,6 @@ export class Handle extends ReadyResource {
983
977
  keyPair: writer
984
978
  })
985
979
  }
986
-
987
- /**
988
- * @param {Handle} child
989
- * @param {{ role?: string }} [opts]
990
- */
991
- _wireAccept(child, { role } = {}) {
992
- child.pair.on('candidate', (cand) => {
993
- if (this.closing || this.closed || child.closing || child.closed) return
994
- child.accept(cand, { role }).catch(this._onerror)
995
- })
996
- }
997
-
998
- /**
999
- * @param {string} id
1000
- * @param {KeyPair | { publicKey: Uint8Array, secretKey: Uint8Array } | null} keyPair
1001
- * @returns {Promise<void>}
1002
- */
1003
- async _saveKeyPair(id, keyPair) {
1004
- if (!this.local || !keyPair) return
1005
- await this.local.store.put('handle-keypairs', {
1006
- id,
1007
- publicKey: keyPair.publicKey,
1008
- secretKey: keyPair.secretKey
1009
- })
1010
- }
1011
-
1012
- /**
1013
- * @param {string} id
1014
- * @returns {Promise<{ publicKey: Uint8Array, secretKey: Uint8Array } | null>}
1015
- */
1016
- async _loadKeyPair(id) {
1017
- if (!this.local) return null
1018
- const { data } = await this.local.store.get('handle-keypairs', id)
1019
- if (!data) return null
1020
- return {
1021
- publicKey: data.publicKey,
1022
- secretKey: data.secretKey
1023
- }
1024
- }
1025
980
  }
1026
981
 
1027
982
  function pickHandle(spec, type) {