@cero-base/cero 1.17.0 → 1.18.1

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": "1.17.0",
3
+ "version": "1.18.1",
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,
@@ -95,12 +95,12 @@
95
95
  "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
96
96
  },
97
97
  "dependencies": {
98
- "@cero-base/core": "^1.17.0",
98
+ "@cero-base/core": "^1.18.1",
99
99
  "b4a": "^1.8.1",
100
100
  "bare-abort-controller": "^1.1.2",
101
101
  "bare-crypto": "^1.15.3",
102
102
  "bare-fs": "^4.8.1",
103
- "bare-path": "^3.1.1",
103
+ "bare-path": "^3.1.2",
104
104
  "ble-swarm": "^2.3.0",
105
105
  "compact-encoding": "^3.3.2",
106
106
  "corestore": "^7.12.2",
@@ -110,7 +110,7 @@
110
110
  "hypercore-storage": "^3.2.1",
111
111
  "hyperdb": "^6.9.0",
112
112
  "hyperdispatch": "^1.6.0",
113
- "hyperschema": "^1.22.0",
113
+ "hyperschema": "^1.23.0",
114
114
  "ready-resource": "^1.2.0",
115
115
  "safety-catch": "^1.0.3",
116
116
  "streamx": "^2.28.1",
@@ -120,10 +120,10 @@
120
120
  "devDependencies": {
121
121
  "@hyperswarm/testnet": "^3.1.4",
122
122
  "bare-events": "^2.9.2",
123
- "bare-fetch": "^3.2.0",
123
+ "bare-fetch": "^3.2.1",
124
124
  "bare-process": "^4.5.1",
125
- "bare-url": "^2.5.2",
126
- "brittle": "^4.1.0",
125
+ "bare-url": "^2.5.4",
126
+ "brittle": "^4.1.1",
127
127
  "typescript": "^5.9.3"
128
128
  },
129
129
  "license": "Apache-2.0"
@@ -24,7 +24,9 @@ export const refs = {
24
24
  }
25
25
  }
26
26
 
27
- export const getHyperdbType = (prim) => DB_TYPE[prim] || 'string'
27
+ export function getHyperdbType(prim) {
28
+ return DB_TYPE[prim] || 'string'
29
+ }
28
30
 
29
31
  const at = (ns, n) => `@${ns}/${n}`
30
32
  const keyOf = (def) => (def.kind === 'single' ? [] : ['id'])
@@ -39,9 +41,11 @@ const fields = (map) =>
39
41
  // Merge app `t.extend` fields into a builtin's base fields — base can't be redeclared.
40
42
  const merge = (type, base, extra) => {
41
43
  if (!extra) return base
42
- for (const k in extra)
43
- if (k in base)
44
+ for (const k in extra) {
45
+ if (k in base) {
44
46
  throw CeroError.INVALID(`'${k}' is a base field of '${type}' and cannot be redeclared`)
47
+ }
48
+ }
45
49
  return { ...base, ...extra }
46
50
  }
47
51
 
@@ -53,8 +57,8 @@ const descriptors = (group, extend = {}) =>
53
57
  }))
54
58
 
55
59
  // meta.refs entries for a scope's builtins.
56
- export const builtinRefs = (ns, scope) =>
57
- Object.fromEntries(
60
+ export function builtinRefs(ns, scope) {
61
+ return Object.fromEntries(
58
62
  Object.entries(refs[scope]).map(([name, def]) => [
59
63
  name,
60
64
  {
@@ -66,14 +70,19 @@ export const builtinRefs = (ns, scope) =>
66
70
  }
67
71
  ])
68
72
  )
73
+ }
69
74
 
70
75
  // hyperschema type descriptors. scope is 'main' | 'local' | 'rpc'; `extend`
71
76
  // merges app `t.extend` fields into the matching type.
72
- export const builtinTypes = (scope, extend) => descriptors(schemas[scope], extend)
73
- export const rpcTypes = () => descriptors(schemas.rpc)
77
+ export function builtinTypes(scope, extend) {
78
+ return descriptors(schemas[scope], extend)
79
+ }
80
+ export function rpcTypes() {
81
+ return descriptors(schemas.rpc)
82
+ }
74
83
 
75
84
  // hyperdb collection descriptors for a scope.
76
- export const builtinCollections = (ns, scope) => {
85
+ export function builtinCollections(ns, scope) {
77
86
  const out = Object.entries(refs[scope]).map(([name, def]) => ({
78
87
  name,
79
88
  schema: at(ns, def.type),
@@ -87,25 +96,29 @@ export const builtinCollections = (ns, scope) => {
87
96
  }
88
97
 
89
98
  // hyperdispatch descriptors (main scope only).
90
- export const builtinDispatches = (ns) => [
91
- { name: 'add-writer', requestType: at(ns, 'writer') },
92
- { name: 'del-writer', requestType: at(ns, 'writer') },
93
- { name: 'claim-writer', requestType: at(ns, 'claim') },
94
- ...Object.values(refs.main).flatMap(({ type }) => [
95
- { name: `add-${type}`, requestType: at(ns, type) },
96
- { name: `set-${type}`, requestType: at(ns, type) },
97
- { name: `del-${type}`, requestType: at(ns, 'del-by-id') }
98
- ])
99
- ]
99
+ export function builtinDispatches(ns) {
100
+ return [
101
+ { name: 'add-writer', requestType: at(ns, 'writer') },
102
+ { name: 'del-writer', requestType: at(ns, 'writer') },
103
+ { name: 'claim-writer', requestType: at(ns, 'claim') },
104
+ ...Object.values(refs.main).flatMap(({ type }) => [
105
+ { name: `add-${type}`, requestType: at(ns, type) },
106
+ { name: `set-${type}`, requestType: at(ns, type) },
107
+ { name: `del-${type}`, requestType: at(ns, 'del-by-id') }
108
+ ])
109
+ ]
110
+ }
100
111
 
101
112
  // Registered AFTER the app's own dispatches (see build/index.js): hyperdispatch
102
113
  // numbers routes positionally and persists them, so a spec built before
103
114
  // rotation must see rotate-key appended at the end — inserting it into the
104
115
  // builtin group would collide with the app's persisted route ids on an
105
116
  // incremental rebuild.
106
- export const rotateDispatch = (ns) => ({ name: 'rotate-key', requestType: at(ns, 'epoch') })
117
+ export function rotateDispatch(ns) {
118
+ return { name: 'rotate-key', requestType: at(ns, 'epoch') }
119
+ }
107
120
 
108
- export const rpcCommands = (ns) => {
121
+ export function rpcCommands(ns) {
109
122
  const ref = (n) => at(ns, n)
110
123
  return [
111
124
  { name: 'init', request: { name: ref('req-empty') }, response: { name: ref('res-identity') } },
@@ -12,7 +12,7 @@ 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, isRank, addWriterPayload } from '@cero-base/core/utils'
15
+ import { toId, grants, can, isRank, addWriterPayload, REMOVE } from '@cero-base/core/utils'
16
16
  import { CeroError } from '@cero-base/core/errors'
17
17
  import { Blobs } from '@cero-base/core/blobs'
18
18
  import { decodeId } from '@cero-base/core/blobs/codec'
@@ -128,7 +128,7 @@ export class Handle extends ReadyResource {
128
128
  this._fileServer = null
129
129
  this._blobs = null
130
130
  this._epochBlobs = null
131
- // root only serializes suspend/resume, converges rapid bounces
131
+ // root only, serializes suspend/resume and converges rapid bounces
132
132
  this._sus = parent
133
133
  ? null
134
134
  : new Suspendify({ suspend: () => this._suspend(), resume: () => this._resume() })
@@ -165,9 +165,8 @@ export class Handle extends ReadyResource {
165
165
  }
166
166
  })
167
167
  await this.pair.ready()
168
- // Serve invites persisted by any member and keep the served set in
169
- // step as rows replicate in (minted elsewhere) or disappear (revoked
170
- // or consumed elsewhere).
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)
171
170
  await this._syncInvites().catch(safetyCatch)
172
171
  this._invitesSync = () => this._syncInvites().catch(safetyCatch)
173
172
  this.store.on('update', this._invitesSync)
@@ -353,8 +352,7 @@ export class Handle extends ReadyResource {
353
352
  blobs
354
353
  .ready()
355
354
  .then(() => {
356
- // close prunes _coreKeys first a ready() resolving after that must
357
- // not re-insert the entry (it would outlive the handle on the root)
355
+ // close prunes _coreKeys first, a late ready() must not re-insert the entry
358
356
  if (this.closing || this.closed || !blobs.key) return
359
357
  this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
360
358
  })
@@ -377,8 +375,7 @@ export class Handle extends ReadyResource {
377
375
  const { coreKey } = decodeId(id)
378
376
  const hex = b4a.toString(coreKey, 'hex')
379
377
  if (!this.root._coreKeys.has(hex)) {
380
- // a file-field value carries no stamp — look it up from the files row
381
- // (fire-and-forget: idempotent, resolution happens again per request)
378
+ // no stamp on a file-field value, look it up (fire-and-forget, idempotent)
382
379
  if (stamp === undefined) {
383
380
  this.store
384
381
  .get('files', id)
@@ -392,16 +389,14 @@ export class Handle extends ReadyResource {
392
389
  if (!key) return // unknown epoch — this device is not entitled to the core
393
390
  this.root._coreKeys.set(hex, key)
394
391
  }
395
- // remember which handle read it, so close prunes the entry (re-registered
396
- // on the next read if another handle still serves the same core)
392
+ // remember which handle read it, so close prunes the entry
397
393
  if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
398
394
  } catch {
399
395
  // ignore invalid ids
400
396
  }
401
397
  }
402
398
 
403
- // Base-era blob cores use the OWNING handle's key (not the root's — rooms
404
- // have their own keys); rotated-era cores derive from the epoch entropy.
399
+ // base-era cores use the OWNING handle's key, rooms have their own
405
400
  _blobCoreKey(stamp) {
406
401
  if (!stamp) return this.store.encryptionKey
407
402
  const entropy = this.store.keyring.entropy(stamp)
@@ -479,20 +474,17 @@ export class Handle extends ReadyResource {
479
474
  * @returns {Promise<string>} Z32-encoded invite string.
480
475
  */
481
476
  async invite(opts) {
482
- if (!this.pair)
477
+ if (!this.pair) {
483
478
  throw CeroError.INVALID(
484
479
  'invite() is not available on the root handle — open a child handle first'
485
480
  )
486
- // An invite is a capability: whoever holds it is admitted. Cap it here or
487
- // a member mints one above their own rank, and the mismatch only surfaces
488
- // at apply — as a silent drop that still leaves the joiner admitted.
481
+ }
482
+ // an invite is a capability, cap the rank or apply drops the mismatch silently
489
483
  if (opts?.role !== undefined && opts.role !== '') await this._checkGrant(opts.role)
490
484
  const str = await this.pair.createInvite(opts)
491
485
  const record = this.pair.recordOf(str)
492
486
  if (record) {
493
- // Persist so every member replica serves this invite across restarts
494
- // the in-memory record keeps working this session even if the write
495
- // fails.
487
+ // persist so every member replica serves it across restarts
496
488
  await this.store
497
489
  .call('add-invite', {
498
490
  id: b4a.toString(record.id, 'hex'),
@@ -519,15 +511,20 @@ export class Handle extends ReadyResource {
519
511
  * Revoke a previously-minted invite by its string form.
520
512
  *
521
513
  * @param {string} invite
522
- * @returns {boolean} `true` if the invite was found and removed.
514
+ * @returns {Promise<boolean>} `true` if the invite was found and removed.
523
515
  */
524
- revoke(invite) {
516
+ async revoke(invite) {
525
517
  if (!this.pair) throw CeroError.INVALID('revoke() is not available on the root handle')
518
+ // apply refuses revoke below REMOVE, and other members would keep serving it
519
+ const { data: me } = await this.store.get('members', this.identity.id)
520
+ if (me && !can(me.role, REMOVE)) {
521
+ throw CeroError.DENIED(null, 'revoking an invite needs the remove permission')
522
+ }
526
523
  const record = this.pair.recordOf(invite)
527
524
  const revoked = this.pair.revoke(invite)
528
525
  if (revoked && record) {
529
526
  // drop the persisted row so every other member stops serving it too
530
- this.store.call('del-invite', { id: b4a.toString(record.id, 'hex') }).catch(safetyCatch)
527
+ await this.store.call('del-invite', { id: b4a.toString(record.id, 'hex') })
531
528
  }
532
529
  return revoked
533
530
  }
@@ -552,20 +549,14 @@ export class Handle extends ReadyResource {
552
549
  if (candidate.invite.role && !grants(candidate.invite.role, role)) {
553
550
  throw CeroError.INVALID(`role '${role}' exceeds the invite role '${candidate.invite.role}'`)
554
551
  }
555
- // Refuse BEFORE confirm() but without awaiting: confirm has to answer
556
- // inside the pairing request's lifetime, and any read here drops it. Rank
557
- // validation is synchronous and catches the dangerous case (an app role
558
- // name silently grants nothing); the cap against our own rank is enforced
559
- // at apply, where add-writer refuses to admit what it cannot grant.
552
+ // synchronous on purpose, the cap against our own rank is enforced at apply
560
553
  if (!isRank(role)) {
561
554
  throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
562
555
  }
563
556
 
564
- // confirm must answer within the incoming pairing request's lifetime
565
- // any await before it (even ~100ms) and the response is dropped, the
566
- // joiner times out. So the key is revealed before the membership writes
567
- // land; if they fail the joiner holds the key un-admitted, which is
568
- // recoverable (re-pair) and surfaced via onerror in _wireAccept.
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.
569
560
  // Epoch secrets ride along so a post-rotation joiner reads full history.
570
561
  const epochs = this.store.keyring.all()
571
562
  await candidate.confirm({
@@ -598,8 +589,7 @@ export class Handle extends ReadyResource {
598
589
  sig,
599
590
  master: this.identity.publicKey,
600
591
  writer: writerKey,
601
- // the device binds to the joiner; add-member in the same transaction
602
- // decides the rank, and a refusal discards both
592
+ // add-member in the same transaction decides the rank, a refusal discards both
603
593
  memberId: member.id,
604
594
  ts: member.updatedAt || Date.now()
605
595
  })
@@ -607,15 +597,13 @@ export class Handle extends ReadyResource {
607
597
  })
608
598
  }
609
599
 
610
- // Throws unless this handle may grant `role`: it must be a real rank, and
611
- // never above our own. `grants` treats an unknown role as "no" everywhere,
612
- // so an app role name must fail loudly here rather than silently downstream.
600
+ // `grants` treats an unknown role as "no", so an app role name must fail loudly
613
601
  async _checkGrant(role) {
614
602
  if (!isRank(role)) {
615
603
  throw CeroError.INVALID(`role '${role}' is not a rank (owner, admin, member, reader)`)
616
604
  }
617
605
  const { data: me } = await this.store.get('members', this.identity.id)
618
- // no member row yet = genesis (we are creating the room) — nothing to cap
606
+ // no member row yet = genesis, nothing to cap
619
607
  if (!me) return
620
608
  if (!grants(me.role, role)) {
621
609
  throw CeroError.DENIED(null, `role '${role}' exceeds your own role '${me.role}'`)
@@ -652,13 +640,11 @@ export class Handle extends ReadyResource {
652
640
  namespace: `${NS}/handle/${type}/${writer.id}`,
653
641
  routes,
654
642
  keyPair: writer,
655
- // each room gets its own key — inheriting identity.encryptionKey would let
656
- // any member decrypt every room this identity ever created, plus its root db
643
+ // inheriting identity.encryptionKey would let any member decrypt every room
657
644
  encryptionKey: Identity.randomBytes(32)
658
645
  })
659
646
  )
660
- // a failure after the child opens must close it else it leaks its Database,
661
- // swarm session and attached bee, never reaped by the parent (not yet a child)
647
+ // a failure after the child opens leaks its Database, swarm session and bee
662
648
  let publish = null
663
649
  let abort = null
664
650
  let inflightId = null
@@ -667,10 +653,8 @@ export class Handle extends ReadyResource {
667
653
  child.name = name
668
654
 
669
655
  const id = toId(child.store.key)
670
- // Close the create/open race: the `add-handle` write below makes the row
671
- // visible before this create finishes, so a concurrent _load for the id
672
- // must share this child — a duplicate Handle over the same core
673
- // deadlocks in ready().
656
+ // add-handle makes the row visible before this create finishes, and a
657
+ // duplicate Handle over the same core deadlocks in ready()
674
658
  const inflight = new Promise((resolve, reject) => {
675
659
  publish = resolve
676
660
  abort = reject
@@ -682,8 +666,7 @@ export class Handle extends ReadyResource {
682
666
 
683
667
  const ts = Date.now()
684
668
  const writerKey = child.store.writerKey
685
- // admit the core and seat the owner in one append the same shape as
686
- // accept(), so a room's first two ops land together like every later pair
669
+ // same shape as accept(), so a room's first two ops land together
687
670
  await child.store.tx(async (tx) => {
688
671
  await tx.call('add-writer', {
689
672
  master: this.identity.publicKey,
@@ -738,10 +721,7 @@ export class Handle extends ReadyResource {
738
721
  * @returns {Promise<Handle>}
739
722
  */
740
723
  async _join(invite, type, opts = {}) {
741
- // Coalesce concurrent joins of the same room (a retry racing a pending
742
- // join, a double tap): the pairing layer allows one candidate per invite,
743
- // so the second attempt must attach to the in-flight join, not start
744
- // another handshake.
724
+ // the pairing layer allows one candidate per invite, attach to the in-flight join
745
725
  const target = Pairing.inviteTopic(invite)
746
726
  const key = target && `${type}/${b4a.toString(target, 'hex')}`
747
727
  if (!key) return this._pair(invite, type, opts, target)
@@ -766,14 +746,10 @@ export class Handle extends ReadyResource {
766
746
  async _pair(invite, type, { routes, timeout } = {}, target = null) {
767
747
  const deadline = timeout || TIMEOUT
768
748
 
769
- // Idempotent: if the invite targets a handle we already have AND our
770
- // stored writer is still admitted, return it without pairing, so
771
- // there's no waiting on a peer to confirm. A removed writer's core is
772
- // frozen: the old session can never become writable again, and reusing
773
- // its keypair would only get it re-removed. Holding a fresh invite is
774
- // exactly the re-admission path, so fall through to a real pairing —
775
- // which mints a fresh keypair the host admits. Our member row can outlive
776
- // our writer (one revoked device), so the device row is the real test.
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.
777
753
  if (target) {
778
754
  const { data: joined } = await this.store.get('handles')
779
755
  const existing = joined.find(
@@ -788,8 +764,7 @@ export class Handle extends ReadyResource {
788
764
  }
789
765
  }
790
766
 
791
- // offline join: with nearby sync on, also rendezvous on the invite-derived
792
- // BLE UUID for the duration of the join — pairing rides the injected link
767
+ // offline join: rendezvous on the invite-derived BLE UUID for the join
793
768
  const stopNearby = this.root.bluetooth ? this.root.bluetooth.announce(invite) : null
794
769
 
795
770
  const child = /** @type {Child} */ (
@@ -801,8 +776,7 @@ export class Handle extends ReadyResource {
801
776
  timeout
802
777
  }).finally(() => stopNearby?.())
803
778
  )
804
- // whenWritable timing out (host offline) is a normal failure close the
805
- // fully-opened child rather than leak its Database/pairing/swarm session
779
+ // whenWritable timing out (host offline) is normal, don't leak the opened child
806
780
  let publish = null
807
781
  let abort = null
808
782
  let inflightId = null
@@ -811,8 +785,7 @@ export class Handle extends ReadyResource {
811
785
  if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
812
786
 
813
787
  const id = toId(child.store.key)
814
- // same create/open race as _create: the add-handle row is visible before
815
- // this join finishes — a concurrent _load must share this child
788
+ // same create/open race as _create, a concurrent _load must share this child
816
789
  const inflight = new Promise((resolve, reject) => {
817
790
  publish = resolve
818
791
  abort = reject
@@ -880,8 +853,9 @@ export class Handle extends ReadyResource {
880
853
  async _reopen(type, id, opts) {
881
854
  const { data } = await this.store.get('handles', id)
882
855
  if (!data) throw CeroError.UNKNOWN('handle', id)
883
- if (data.type !== type)
856
+ if (data.type !== type) {
884
857
  throw CeroError.INVALID(`handle ${id} is type ${data.type}, not ${type}`)
858
+ }
885
859
  let writer = await this._loadKeyPair(id)
886
860
  const firstTime = !writer
887
861
  if (firstTime) {
@@ -902,9 +876,7 @@ export class Handle extends ReadyResource {
902
876
  if (firstTime) {
903
877
  await this._saveKeyPair(id, writer)
904
878
  }
905
- // a reopen must not silently re-arm auto-accept: `accept: false` is a
906
- // host-approval gate, and one that comes back on the next open is worse
907
- // than none at all
879
+ // `accept: false` is a host-approval gate, re-arming it silently is worse
908
880
  if (opts?.accept !== false) this._wireAccept(child, { role: opts?.role })
909
881
  bind(child, type)
910
882
  this.children.add(child)
@@ -989,9 +961,7 @@ export class Handle extends ReadyResource {
989
961
 
990
962
  let epochs = null
991
963
  if (additional?.byteLength) {
992
- // the epoch set is load-bearing for a rotated room a malformed
993
- // delivery must fail the join loudly, not produce a silently inert
994
- // member (hosts predating rotation send no additional payload at all)
964
+ // the epoch set is load-bearing, a malformed delivery must fail the join
995
965
  try {
996
966
  epochs = c.decode(epochEntries, additional)
997
967
  } catch {
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import Hypercore from 'hypercore'
1
2
  import HypercoreStorage from 'hypercore-storage'
2
3
  import Corestore from 'corestore'
3
4
  import safetyCatch from 'safety-catch'
@@ -121,9 +122,9 @@ export async function cero(dir, spec, opts = {}) {
121
122
  if (local) {
122
123
  const stored = (await local.store.get('environment')).data?.channel ?? null
123
124
  const wanted = opts.channel ?? null
124
- if (stored == null && wanted != null)
125
+ if (stored == null && wanted != null) {
125
126
  await local.store.set('environment', { channel: wanted })
126
- else if (stored != null && stored !== wanted) throw CeroError.CHANNEL_MISMATCH()
127
+ } else if (stored != null && stored !== wanted) throw CeroError.CHANNEL_MISMATCH()
127
128
  }
128
129
 
129
130
  network = new Network({
@@ -159,20 +160,35 @@ export async function cero(dir, spec, opts = {}) {
159
160
 
160
161
  // a supplied or stored identity may already have history elsewhere —
161
162
  // authoring genesis twice forks the seed-derived writer core (a writable
162
- // core has one author, ever). Give discovery a moment; any replicated
163
- // history means the caller wanted { recovery: true }. Offline reuse is
164
- // undetectable — this catches the reachable-peer case loudly.
163
+ // core has one author, ever). Ask the peers directly with a plain session
164
+ // on the genesis core: the base never replicates an empty local core.
165
+ // Offline reuse is undetectable — this catches the reachable-peer case.
165
166
  if (!writer && !opts.key && !opts.recovery && !fresh) {
166
- const until = Date.now() + 1500
167
- while (Date.now() < until) {
168
- if (network.connections.size > 0) await me.store.bee.update().catch(safetyCatch)
169
- if (me.store.length > 0 || me.store.bee.local.length > 0) {
170
- throw CeroError.CONFLICT('identity already has history — open with { recovery: true }')
167
+ const genesis = store.get({
168
+ key: Hypercore.key({
169
+ version: store.manifestVersion,
170
+ signers: [{ publicKey: identity.publicKey }]
171
+ })
172
+ })
173
+ await genesis.ready()
174
+ network.attach(genesis)
175
+ try {
176
+ const until = Date.now() + 1500
177
+ while (Date.now() < until) {
178
+ if (genesis.length > 0 || genesis.peers.some((p) => p.remoteLength > 0)) {
179
+ throw CeroError.CONFLICT('identity already has history — open with { recovery: true }')
180
+ }
181
+ await new Promise((r) => setTimeout(r, 100))
171
182
  }
172
- await new Promise((r) => setTimeout(r, 100))
183
+ } finally {
184
+ network.detach(genesis)
185
+ await genesis.close()
173
186
  }
174
187
  }
175
- if (!writer && me.store.length === 0 && (!opts.key || opts.recovery)) {
188
+ // decided by the stored writer and the caller's intent, never by the local
189
+ // core's length: a same-identity device opens on the genesis core, which
190
+ // fills with the first device's blocks as soon as a peer connects
191
+ if (!writer && (!opts.key || opts.recovery)) {
176
192
  const result = await me.bootstrap({
177
193
  name: opts.name || null,
178
194
  isMobile: opts.isMobile === true,
@@ -38,8 +38,9 @@ export function resolveFile(handle, id, name) {
38
38
  * @param {Record<string, any>} row
39
39
  * @returns {Promise<SingleResult>}
40
40
  */
41
- export const put = (ref, row) =>
42
- ref.name === 'files' ? putFile(ref, row) : ref.handle.store.put(ref.name, row)
41
+ export function put(ref, row) {
42
+ return ref.name === 'files' ? putFile(ref, row) : ref.handle.store.put(ref.name, row)
43
+ }
43
44
 
44
45
  async function putFile(ref, row) {
45
46
  const handle = ref.handle
@@ -63,7 +64,9 @@ async function putFile(ref, row) {
63
64
  * @param {{ upsert?: boolean }} [opts]
64
65
  * @returns {Promise<SingleResult>}
65
66
  */
66
- export const set = (ref, row, opts) => ref.handle.store.set(ref.name, row, opts)
67
+ export function set(ref, row, opts) {
68
+ return ref.handle.store.set(ref.name, row, opts)
69
+ }
67
70
 
68
71
  /**
69
72
  * Delete a row by id (collection refs), or wipe the row (single refs).
@@ -72,7 +75,9 @@ export const set = (ref, row, opts) => ref.handle.store.set(ref.name, row, opts)
72
75
  * @param {string} [id]
73
76
  * @returns {Promise<void>}
74
77
  */
75
- export const del = (ref, id) => ref.handle.store.del(ref.name, id)
78
+ export function del(ref, id) {
79
+ return ref.handle.store.del(ref.name, id)
80
+ }
76
81
 
77
82
  /**
78
83
  * Count rows on `ref`, optionally filtered.
@@ -81,7 +86,9 @@ export const del = (ref, id) => ref.handle.store.del(ref.name, id)
81
86
  * @param {Record<string, any>} [q]
82
87
  * @returns {Promise<{ data: number }>}
83
88
  */
84
- export const count = (ref, q) => ref.handle.store.count(ref.name, q)
89
+ export function count(ref, q) {
90
+ return ref.handle.store.count(ref.name, q)
91
+ }
85
92
 
86
93
  /**
87
94
  * Invoke an `action`-kind ref (a custom mutation declared in the schema).
@@ -90,7 +97,9 @@ export const count = (ref, q) => ref.handle.store.count(ref.name, q)
90
97
  * @param {Record<string, any>} [d]
91
98
  * @returns {Promise<any>}
92
99
  */
93
- export const call = (ref, d) => ref.handle.store.call(ref.name, d)
100
+ export function call(ref, d) {
101
+ return ref.handle.store.call(ref.name, d)
102
+ }
94
103
 
95
104
  // Write ops per ref kind, for `before`/`after` subscriptions.
96
105
  const WRITES = { single: ['set'], collection: ['put', 'set', 'del'] }
@@ -105,7 +114,7 @@ const WRITES = { single: ['set'], collection: ['put', 'set', 'del'] }
105
114
  * @param {{ signal?: AbortSignal }} [opts]
106
115
  * @returns {() => void}
107
116
  */
108
- export const before = (ref, fn, opts) => {
117
+ export function before(ref, fn, opts) {
109
118
  const db = ref.handle.store
110
119
  const ops = WRITES[ref.kind] || ['set']
111
120
  const offs = ops.map((op) =>
@@ -130,7 +139,7 @@ export const before = (ref, fn, opts) => {
130
139
  * @param {{ signal?: AbortSignal }} [opts]
131
140
  * @returns {() => void}
132
141
  */
133
- export const after = (ref, fn, opts) => {
142
+ export function after(ref, fn, opts) {
134
143
  const db = ref.handle.store
135
144
  const ops = WRITES[ref.kind] || ['set']
136
145
  const handler = (ctx) => ctx.name === ref.name && fn(ctx)
@@ -194,7 +203,7 @@ function resolveRow(ref, row) {
194
203
  * @param {string | Record<string, any>} [q]
195
204
  * @returns {Promise<SingleResult | ListResult | GetByIdResult>}
196
205
  */
197
- export const get = async (ref, q) => {
206
+ export async function get(ref, q) {
198
207
  if (ref.kind === 'handle') {
199
208
  const { data } = await parentStore(ref).get('handles', q)
200
209
  return normalize(data, ref.name)
@@ -225,7 +234,7 @@ const bindStream = (owner, stream, opts) => {
225
234
  * @param {{ signal?: AbortSignal }} [opts]
226
235
  * @returns {import('streamx').Readable}
227
236
  */
228
- export const watch = (ref, q, opts) => {
237
+ export function watch(ref, q, opts) {
229
238
  const owner = ref.handle
230
239
  if (ref.kind !== 'handle') {
231
240
  const src = owner.store.watch(ref.name, q)
@@ -243,7 +252,7 @@ export const watch = (ref, q, opts) => {
243
252
  * batch, and any batch after a view swap, replays current state as inserts
244
253
  * with `reset: true`). File-typed fields resolve on both sides.
245
254
  */
246
- export const changes = (ref, q, opts) => {
255
+ export function changes(ref, q, opts) {
247
256
  const owner = ref.handle
248
257
  if (ref.kind === 'handle') throw CeroError.INVALID('changes does not support handle refs')
249
258
  const src = owner.store.changes(ref.name, q)
@@ -338,7 +347,7 @@ function snapshotStream(src, map) {
338
347
  * @param {string | { invite?: string, id?: string, name?: string, routes?: any, role?: string, accept?: boolean } | undefined} [arg]
339
348
  * @returns {Promise<CeroHandle>} The resolved child handle.
340
349
  */
341
- export const open = (ref, arg) => {
350
+ export function open(ref, arg) {
342
351
  if (typeof arg === 'string') return ref.handle._join(arg, ref.name)
343
352
  if (arg && typeof arg.invite === 'string') return ref.handle._join(arg.invite, ref.name)
344
353
  if (arg && typeof arg.id === 'string') return ref.handle._load(ref.name, arg.id, arg)
@@ -357,7 +366,9 @@ export const open = (ref, arg) => {
357
366
  * @param {any} handle
358
367
  * @returns {Promise<{ epoch: number }>}
359
368
  */
360
- export const rotate = (handle) => handle.store.rotate()
369
+ export function rotate(handle) {
370
+ return handle.store.rotate()
371
+ }
361
372
 
362
373
  // ─── custom operators ──────────────────────────────────────────────────────
363
374
  // App business logic lives as custom operators: pure functions whose first arg
@@ -25,8 +25,9 @@ export class Local extends ReadyResource {
25
25
  */
26
26
  constructor(dir, spec, { root, store, storageKey } = {}) {
27
27
  super()
28
- if (!root && !store && (typeof dir !== 'string' || !dir))
28
+ if (!root && !store && (typeof dir !== 'string' || !dir)) {
29
29
  throw CeroError.REQUIRED('dir, root, or store')
30
+ }
30
31
  if (!spec?.local?.database) throw CeroError.REQUIRED('spec.local.database')
31
32
  if (!spec?.meta?.local) throw CeroError.REQUIRED('spec.meta.local')
32
33
 
package/src/rpc/server.js CHANGED
@@ -278,7 +278,7 @@ export class Server extends RPCServer {
278
278
  })
279
279
 
280
280
  this.rpc.onRevoke(async ({ handle, invite }) => {
281
- const ok = this._resolve(handle).revoke(invite)
281
+ const ok = await this._resolve(handle).revoke(invite)
282
282
  return { ok }
283
283
  })
284
284
 
@@ -1,43 +1,3 @@
1
- export namespace refs {
2
- namespace main {
3
- namespace members {
4
- let type: string;
5
- }
6
- namespace devices {
7
- let type_1: string;
8
- export { type_1 as type };
9
- }
10
- namespace invites {
11
- let type_2: string;
12
- export { type_2 as type };
13
- }
14
- namespace handles {
15
- let type_3: string;
16
- export { type_3 as type };
17
- }
18
- namespace files {
19
- let type_4: string;
20
- export { type_4 as type };
21
- }
22
- }
23
- let local: {
24
- master: {
25
- type: string;
26
- kind: string;
27
- };
28
- keypair: {
29
- type: string;
30
- kind: string;
31
- };
32
- 'handle-keypairs': {
33
- type: string;
34
- };
35
- environment: {
36
- type: string;
37
- kind: string;
38
- };
39
- };
40
- }
41
1
  export function getHyperdbType(prim: any): any;
42
2
  export function builtinRefs(ns: any, scope: any): {
43
3
  [k: string]: {
@@ -98,3 +58,43 @@ export function rpcCommands(ns: any): ({
98
58
  stream: boolean;
99
59
  };
100
60
  })[];
61
+ export namespace refs {
62
+ namespace main {
63
+ namespace members {
64
+ let type: string;
65
+ }
66
+ namespace devices {
67
+ let type_1: string;
68
+ export { type_1 as type };
69
+ }
70
+ namespace invites {
71
+ let type_2: string;
72
+ export { type_2 as type };
73
+ }
74
+ namespace handles {
75
+ let type_3: string;
76
+ export { type_3 as type };
77
+ }
78
+ namespace files {
79
+ let type_4: string;
80
+ export { type_4 as type };
81
+ }
82
+ }
83
+ let local: {
84
+ master: {
85
+ type: string;
86
+ kind: string;
87
+ };
88
+ keypair: {
89
+ type: string;
90
+ kind: string;
91
+ };
92
+ 'handle-keypairs': {
93
+ type: string;
94
+ };
95
+ environment: {
96
+ type: string;
97
+ kind: string;
98
+ };
99
+ };
100
+ }
@@ -242,9 +242,9 @@ export class Handle extends ReadyResource {
242
242
  * Revoke a previously-minted invite by its string form.
243
243
  *
244
244
  * @param {string} invite
245
- * @returns {boolean} `true` if the invite was found and removed.
245
+ * @returns {Promise<boolean>} `true` if the invite was found and removed.
246
246
  */
247
- revoke(invite: string): boolean;
247
+ revoke(invite: string): Promise<boolean>;
248
248
  /**
249
249
  * Accept a paired candidate — adds them as a writer (or read-only member)
250
250
  * and confirms the pairing so they receive this handle's keys.
@@ -22,38 +22,64 @@ export function resolveFile(handle: object, id: string, name?: string): {
22
22
  name?: string;
23
23
  };
24
24
  /**
25
- * Put custom operators on `handle`, currying it as their first argument so
26
- * `handle.ns.fn(args)` calls `fn(handle, args)`. `arg` is either:
27
- * - a `{ ns: module }` map → bind exactly those, or
28
- * - `null` → the registered root operators, or
29
- * - a child-handle type → the registered operators for that type.
30
- * The scope forms are how cero binds handles automatically; pass a map yourself
31
- * for manual binding.
25
+ * Insert (or overwrite by id) a row on `ref`. The `files` builtin is special:
26
+ * `put(handle.files, { data, type, name? })` uploads the bytes to this handle's
27
+ * blob store, records `{ id, name }`, and resolves the file.
32
28
  *
33
- * @param {any} handle
34
- * @param {Record<string, any> | string | null} arg
35
- * @returns {any} handle
29
+ * @param {Ref} ref
30
+ * @param {Record<string, any>} row
31
+ * @returns {Promise<SingleResult>}
36
32
  */
37
- export function bind(handle: any, arg: Record<string, any> | string | null): any;
33
+ export function put(ref: Ref, row: Record<string, any>): Promise<SingleResult>;
38
34
  /**
39
- * Register custom operators by scope. A bare key binds on the root handle; a key
40
- * that names a child-handle type binds on every handle of that type. Call once
41
- * at startup, before `cero()` / `connect()`, in both processes.
35
+ * Upsert a row on `ref` merges with the existing row and preserves
36
+ * `createdAt`. Pass `{ upsert: false }` to update-only: a missing row is left
37
+ * untouched instead of created (atomic never resurrects a deleted row).
42
38
  *
43
- * @param {Record<string, any>} map
39
+ * @param {Ref} ref
40
+ * @param {Record<string, any>} row
41
+ * @param {{ upsert?: boolean }} [opts]
42
+ * @returns {Promise<SingleResult>}
44
43
  */
45
- export function define(map: Record<string, any>): void;
46
- /** Test seam: clear all registered operators. */
47
- export function _clearDefined(): void;
48
- export function put(ref: Ref, row: Record<string, any>): Promise<SingleResult>;
49
44
  export function set(ref: Ref, row: Record<string, any>, opts?: {
50
45
  upsert?: boolean;
51
46
  }): Promise<SingleResult>;
47
+ /**
48
+ * Delete a row by id (collection refs), or wipe the row (single refs).
49
+ *
50
+ * @param {Ref} ref
51
+ * @param {string} [id]
52
+ * @returns {Promise<void>}
53
+ */
52
54
  export function del(ref: Ref, id?: string): Promise<void>;
55
+ /**
56
+ * Count rows on `ref`, optionally filtered.
57
+ *
58
+ * @param {Ref} ref
59
+ * @param {Record<string, any>} [q]
60
+ * @returns {Promise<{ data: number }>}
61
+ */
53
62
  export function count(ref: Ref, q?: Record<string, any>): Promise<{
54
63
  data: number;
55
64
  }>;
65
+ /**
66
+ * Invoke an `action`-kind ref (a custom mutation declared in the schema).
67
+ *
68
+ * @param {Ref} ref
69
+ * @param {Record<string, any>} [d]
70
+ * @returns {Promise<any>}
71
+ */
56
72
  export function call(ref: Ref, d?: Record<string, any>): Promise<any>;
73
+ /**
74
+ * Intercept writes to `ref` before they commit — `fn(ctx)` runs in-path
75
+ * (awaited). Return `false` to cancel the write, or mutate `ctx.row`.
76
+ * Returns an unsubscribe fn; pass `{ signal }` to unsubscribe on abort.
77
+ *
78
+ * @param {Ref} ref
79
+ * @param {(ctx: { op: string, name: string, row: any }) => any} fn
80
+ * @param {{ signal?: AbortSignal }} [opts]
81
+ * @returns {() => void}
82
+ */
57
83
  export function before(ref: Ref, fn: (ctx: {
58
84
  op: string;
59
85
  name: string;
@@ -61,6 +87,16 @@ export function before(ref: Ref, fn: (ctx: {
61
87
  }) => any, opts?: {
62
88
  signal?: AbortSignal;
63
89
  }): () => void;
90
+ /**
91
+ * Subscribe to writes on `ref` — fires after each committed write,
92
+ * non-blocking (observe only). Returns an unsubscribe fn; pass `{ signal }`
93
+ * to unsubscribe on abort.
94
+ *
95
+ * @param {Ref} ref
96
+ * @param {(ctx: { op: string, name: string, row: any }) => void} fn
97
+ * @param {{ signal?: AbortSignal }} [opts]
98
+ * @returns {() => void}
99
+ */
64
100
  export function after(ref: Ref, fn: (ctx: {
65
101
  op: string;
66
102
  name: string;
@@ -68,11 +104,50 @@ export function after(ref: Ref, fn: (ctx: {
68
104
  }) => void, opts?: {
69
105
  signal?: AbortSignal;
70
106
  }): () => void;
107
+ /**
108
+ * Read from `ref`. For data refs, dispatches to the underlying store. For
109
+ * `handle`-kind refs, lists existing child handles of that type from the
110
+ * parent's `handles` collection.
111
+ *
112
+ * @param {Ref} ref
113
+ * @param {string | Record<string, any>} [q]
114
+ * @returns {Promise<SingleResult | ListResult | GetByIdResult>}
115
+ */
71
116
  export function get(ref: Ref, q?: string | Record<string, any>): Promise<SingleResult | ListResult | GetByIdResult>;
117
+ /**
118
+ * Live snapshot stream on `ref` — re-emits the latest `get()` result on
119
+ * every underlying mutation. Tied to `ref.handle`'s lifecycle: closing the
120
+ * handle destroys it. Pass `{ signal }` to bind it to a finer scope, or
121
+ * destroy the stream directly to stop watching sooner.
122
+ *
123
+ * @param {Ref} ref
124
+ * @param {Record<string, any>} [q]
125
+ * @param {{ signal?: AbortSignal }} [opts]
126
+ * @returns {import('streamx').Readable}
127
+ */
72
128
  export function watch(ref: Ref, q?: Record<string, any>, opts?: {
73
129
  signal?: AbortSignal;
74
130
  }): import("streamx").Readable;
131
+ /**
132
+ * Delta subscription: batches of `{ prev, next }` row pairs instead of
133
+ * full snapshots — lossless under backpressure, self-contained (the first
134
+ * batch, and any batch after a view swap, replays current state as inserts
135
+ * with `reset: true`). File-typed fields resolve on both sides.
136
+ */
75
137
  export function changes(ref: any, q: any, opts: any): any;
138
+ /**
139
+ * Open (or create / join / load) a child handle through a `handle`-kind
140
+ * ref. Dispatches on the normalize of `arg`:
141
+ *
142
+ * - `string` → join via an invite string
143
+ * - `{ invite: string }` → join via invite (object form)
144
+ * - `{ id: string }` → load an existing handle by id
145
+ * - `object | undefined` → create a new handle with the given opts
146
+ *
147
+ * @param {Ref} ref
148
+ * @param {string | { invite?: string, id?: string, name?: string, routes?: any, role?: string, accept?: boolean } | undefined} [arg]
149
+ * @returns {Promise<CeroHandle>} The resolved child handle.
150
+ */
76
151
  export function open(ref: Ref, arg?: string | {
77
152
  invite?: string;
78
153
  id?: string;
@@ -81,9 +156,45 @@ export function open(ref: Ref, arg?: string | {
81
156
  role?: string;
82
157
  accept?: boolean;
83
158
  } | undefined): Promise<CeroHandle>;
159
+ /**
160
+ * Rotate a handle's encryption epoch. A fresh secret is sealed to every
161
+ * current member and announced through the log — members removed before the
162
+ * rotation cannot decrypt anything written after it. Requires the remove
163
+ * permission (admin or owner). Compose with removal:
164
+ *
165
+ * await cero.del(room.members, memberId)
166
+ * await cero.rotate(room)
167
+ *
168
+ * @param {any} handle
169
+ * @returns {Promise<{ epoch: number }>}
170
+ */
84
171
  export function rotate(handle: any): Promise<{
85
172
  epoch: number;
86
173
  }>;
174
+ /**
175
+ * Put custom operators on `handle`, currying it as their first argument so
176
+ * `handle.ns.fn(args)` calls `fn(handle, args)`. `arg` is either:
177
+ * - a `{ ns: module }` map → bind exactly those, or
178
+ * - `null` → the registered root operators, or
179
+ * - a child-handle type → the registered operators for that type.
180
+ * The scope forms are how cero binds handles automatically; pass a map yourself
181
+ * for manual binding.
182
+ *
183
+ * @param {any} handle
184
+ * @param {Record<string, any> | string | null} arg
185
+ * @returns {any} handle
186
+ */
187
+ export function bind(handle: any, arg: Record<string, any> | string | null): any;
188
+ /**
189
+ * Register custom operators by scope. A bare key binds on the root handle; a key
190
+ * that names a child-handle type binds on every handle of that type. Call once
191
+ * at startup, before `cero()` / `connect()`, in both processes.
192
+ *
193
+ * @param {Record<string, any>} map
194
+ */
195
+ export function define(map: Record<string, any>): void;
196
+ /** Test seam: clear all registered operators. */
197
+ export function _clearDefined(): void;
87
198
  export type Ref = import("./utils.js").Ref;
88
199
  export type CeroHandle = import("../handle/index.js").CeroHandle;
89
200
  export type SingleResult = {