@cero-base/cero 1.18.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,9 +52,8 @@ Ships with TypeScript declarations (`.d.ts`) generated from JSDoc.
52
52
  | `channel` | Optional network-isolation label. Peers connect only to peers on the **same** channel (it salts every swarm topic); omit it for the global network. A storage remembers its channel and refuses to reopen under a different one. Any string works. |
53
53
  | `name` / `isMobile` | Stamped on the device's `add-writer` event. |
54
54
  | `seed` / `phrase` | Restore from explicit 16-/32-byte entropy or a BIP-39 mnemonic. Without either, a stored identity is loaded if present, else a fresh one is generated. |
55
- | `key` | Open against an existing bee key (multi-device flow). |
56
- | `recovery` | `true` triggers `bootstrap({ recovering: true })` — for a second device opening with `key` + the same identity. Needed under autobee 1.0.3 so the second device swaps off the identity-keyed bootstrap-writer slot and starts applying remote appends. |
57
- | `recoveryTimeout` | Bound on the recovery wait. |
55
+ | `key` | Existing database key to recover into, skipping the pointer lookup. |
56
+ | `recoveryTimeout` | Max wait to find another device and be admitted, in ms. Defaults to 30000. A supplied `seed`/`phrase` on a device with no writer recovers there is no flag. |
58
57
  | `routes` | Custom action handlers keyed by route name. |
59
58
  | `encryptionKey` | Override the per-identity encryption key. |
60
59
  | `storageKey` | 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it, and the same key must be passed on every open. Without it, key material sits plaintext on disk: rely on full-disk encryption. |
@@ -496,7 +495,7 @@ import { spec } from './spec/index.js'
496
495
 
497
496
  // serve builds and owns the root cero — pass storage + the built spec, not a handle.
498
497
  // Returns a Server instance (call server.close() to shut down).
499
- const server = await serve(Bare.IPC, { storage: './data', spec, phrase: '…' })
498
+ const server = await serve(Bare.IPC, spec, { storage: './data', phrase: '…' })
500
499
  ```
501
500
 
502
501
  ### Client (the UI process)
@@ -566,7 +565,7 @@ function streamPair() {
566
565
  return [a, b]
567
566
  }
568
567
  const [s, c] = streamPair()
569
- await serve(s, { storage: './data', spec })
568
+ await serve(s, spec, { storage: './data' })
570
569
  const remote = await connect(c, spec)
571
570
  ```
572
571
 
@@ -661,7 +660,7 @@ Point your app's `mirrors` at that key. Storage grows with the rooms it holds; `
661
660
  | ---------------------------- | ------------------------------------------------------------------------ |
662
661
  | `@cero-base/cero` | factory + operators + schema DSL + `define`/`bind` |
663
662
  | `@cero-base/cero/client` | `connect(ipc, spec)` — talk to a cero running in another process |
664
- | `@cero-base/cero/server` | `serve(ipc, { storage, spec })` — run + expose a cero over an IPC stream |
663
+ | `@cero-base/cero/server` | `serve(ipc, spec, { storage })` — run + expose a cero over an IPC stream |
665
664
  | `@cero-base/cero/build` | `build(specDir, schema)` — generate the on-disk spec |
666
665
  | `@cero-base/cero/extensions` | bundled extensions (`profileSync`, …) |
667
666
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "1.18.1",
3
+ "version": "1.19.0",
4
4
  "description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -85,28 +85,29 @@
85
85
  }
86
86
  },
87
87
  "scripts": {
88
- "build:test": "rm -rf test/fixture/spec && node test/fixture/build.js",
88
+ "build:test": "rm -rf test/fixtures/spec && node test/fixtures/build.js",
89
89
  "build:types": "rm -rf types && tsc -p .",
90
90
  "pretest": "npm run build:test",
91
91
  "prepublishOnly": "npm run build:types",
92
92
  "test": "npm run test:bare",
93
93
  "pretest:bare": "npm run build:test",
94
- "test:bare": "ls test/*.test.js | xargs -P1 -n1 brittle-bare test/bare.js",
95
- "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
94
+ "test:bare": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-bare test/bare.js",
95
+ "test:node": "find test -name '*.test.js' | sort | xargs -P1 -n1 brittle-node"
96
96
  },
97
97
  "dependencies": {
98
- "@cero-base/core": "^1.18.1",
98
+ "@cero-base/core": "^1.19.0",
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
103
  "bare-path": "^3.1.2",
104
104
  "ble-swarm": "^2.3.0",
105
- "compact-encoding": "^3.3.2",
106
- "corestore": "^7.12.2",
105
+ "compact-encoding": "^3.4.0",
106
+ "corestore": "^7.12.3",
107
107
  "hrpc": "^4.3.1",
108
- "hypercore": "^11.35.2",
108
+ "hypercore": "^11.35.4",
109
109
  "hypercore-crypto": "^3.7.0",
110
+ "hypercore-id-encoding": "^1.3.0",
110
111
  "hypercore-storage": "^3.2.1",
111
112
  "hyperdb": "^6.9.0",
112
113
  "hyperdispatch": "^1.6.0",
@@ -124,7 +125,7 @@
124
125
  "bare-process": "^4.5.1",
125
126
  "bare-url": "^2.5.4",
126
127
  "brittle": "^4.1.1",
127
- "typescript": "^5.9.3"
128
+ "typescript": "^7.0.2"
128
129
  },
129
130
  "license": "Apache-2.0"
130
131
  }
@@ -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,17 +75,14 @@ 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
  /**
@@ -171,7 +169,7 @@ export class Handle extends ReadyResource {
171
169
  this._invitesSync = () => this._syncInvites().catch(safetyCatch)
172
170
  this.store.on('update', this._invitesSync)
173
171
  }
174
- attachRefs(this, this.store.refs)
172
+ Ref.attach(this, this.store.refs)
175
173
  this.root._coreKeys.set(b4a.toString(this.store.key, 'hex'), this.store.encryptionKey)
176
174
  if (!this.parent) await this.fileServer.listen()
177
175
  }
@@ -406,14 +404,14 @@ export class Handle extends ReadyResource {
406
404
  /** Canonical id — identity id for the root handle, store key for children. */
407
405
  get id() {
408
406
  if (!this.parent) return this.identity.id
409
- return this.store?.key ? toId(this.store.key) : null
407
+ return this.store?.key ? hid.encode(this.store.key) : null
410
408
  }
411
409
 
412
410
  /** This device's id + name. `null` on child handles. */
413
411
  get device() {
414
412
  if (this.parent) return null
415
413
  const k = this.store?.writerKey
416
- return k ? { id: toId(k), name: this._opts.name || null } : null
414
+ return k ? { id: hid.encode(k), name: this._opts.name || null } : null
417
415
  }
418
416
 
419
417
  get suspended() {
@@ -441,20 +439,6 @@ export class Handle extends ReadyResource {
441
439
  return this.store.claim()
442
440
  }
443
441
 
444
- /**
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
442
  /**
459
443
  * Flip this handle's swarm announce mode — `setActive(false)` demotes an
460
444
  * idle/background room to server-only (still reachable, stops searching);
@@ -568,7 +552,7 @@ export class Handle extends ReadyResource {
568
552
  const ts = Date.now()
569
553
  const writerKey = Hypercore.key({ version: 2, signers: [{ publicKey: data.subarray(32, 64) }] })
570
554
  const member = {
571
- id: toId(data.subarray(0, 32)),
555
+ id: hid.encode(data.subarray(0, 32)),
572
556
  key: writerKey,
573
557
  role,
574
558
  name: name || null,
@@ -581,9 +565,7 @@ export class Handle extends ReadyResource {
581
565
  return
582
566
  }
583
567
 
584
- const sig = this.identity.sign(
585
- addWriterPayload(this.store.key, writerKey, this.store.writerKey)
586
- )
568
+ const sig = this.identity.sign(admission(this.store.key, writerKey, this.store.writerKey))
587
569
  await this.store.tx(async (tx) => {
588
570
  await tx.call('add-writer', {
589
571
  sig,
@@ -618,7 +600,7 @@ export class Handle extends ReadyResource {
618
600
  */
619
601
  async leave() {
620
602
  if (!this.parent) return
621
- await this.parent.store.call('del-handle', { id: toId(this.store.key) })
603
+ await this.parent.store.call('del-handle', { id: hid.encode(this.store.key) })
622
604
  await this.close()
623
605
  }
624
606
 
@@ -652,7 +634,7 @@ export class Handle extends ReadyResource {
652
634
  await child.ready()
653
635
  child.name = name
654
636
 
655
- const id = toId(child.store.key)
637
+ const id = hid.encode(child.store.key)
656
638
  // add-handle makes the row visible before this create finishes, and a
657
639
  // duplicate Handle over the same core deadlocks in ready()
658
640
  const inflight = new Promise((resolve, reject) => {
@@ -671,9 +653,7 @@ export class Handle extends ReadyResource {
671
653
  await tx.call('add-writer', {
672
654
  master: this.identity.publicKey,
673
655
  writer: writerKey,
674
- sig: this.identity.sign(
675
- addWriterPayload(child.store.key, writerKey, child.store.writerKey)
676
- ),
656
+ sig: this.identity.sign(admission(child.store.key, writerKey, child.store.writerKey)),
677
657
  ts
678
658
  })
679
659
  await tx.call('add-member', {
@@ -758,7 +738,7 @@ export class Handle extends ReadyResource {
758
738
  if (existing) {
759
739
  const known = await this._load(type, existing.id)
760
740
  const { data: me } = await known.store.get('members', this.identity.id)
761
- const { data: device } = await known.store.get('devices', toId(known.store.writerKey))
741
+ const { data: device } = await known.store.get('devices', hid.encode(known.store.writerKey))
762
742
  if (me && device) return known
763
743
  await known.close().catch(safetyCatch)
764
744
  }
@@ -784,7 +764,7 @@ export class Handle extends ReadyResource {
784
764
  await child.ready()
785
765
  if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
786
766
 
787
- const id = toId(child.store.key)
767
+ const id = hid.encode(child.store.key)
788
768
  // same create/open race as _create, a concurrent _load must share this child
789
769
  const inflight = new Promise((resolve, reject) => {
790
770
  publish = resolve
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ import Hypercore from 'hypercore'
2
2
  import HypercoreStorage from 'hypercore-storage'
3
3
  import Corestore from 'corestore'
4
4
  import safetyCatch from 'safety-catch'
5
+ import c from 'compact-encoding'
5
6
  import fs from 'fs'
6
7
 
7
8
  import { Identity } from '@cero-base/core/identity'
@@ -29,7 +30,7 @@ import {
29
30
  } from './lib/operators.js'
30
31
  import { peek } from './lib/peek.js'
31
32
  import { t, schema } from './lib/spec.js'
32
- import { FLUSH } from './lib/constants.js'
33
+ import { FLUSH, TIMEOUT } from './lib/constants.js'
33
34
  import { internal } from './lib/internal.js'
34
35
 
35
36
  export { Handle, Ref, Local }
@@ -68,12 +69,11 @@ export { t, schema } from './lib/spec.js'
68
69
  * @property {number[]} [backoffs] Swarm reconnect backoff tiers in ms (testing/tuning).
69
70
  * @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
70
71
  * @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
71
- * @property {Uint8Array} [key] Pre-existing database key (skip bootstrap).
72
+ * @property {Uint8Array} [key] Existing database key to recover into, skipping the pointer lookup.
72
73
  * @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
73
74
  * @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
74
75
  * @property {(err: any) => void} [onerror] Background-task error handler.
75
- * @property {boolean} [recovery] Recovery flow wipe local state and re-claim a writer slot.
76
- * @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
76
+ * @property {number} [recoveryTimeout] Max wait to find another device and be admitted, in ms. Defaults to 30000.
77
77
  * @property {Uint8Array} [storageKey] 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it.
78
78
  * @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
79
79
  * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number, pipe?: 'l2cap' | 'gatt' }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. `pipe` picks the data pipe — `'l2cap'` (default, faster) or `'gatt'`; both peers must match. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
@@ -115,6 +115,11 @@ export async function cero(dir, spec, opts = {}) {
115
115
 
116
116
  const { identity, fresh } = await resolveIdentity(opts, local)
117
117
  const writer = local ? (await local.store.get('keypair')).data : null
118
+ // a supplied identity on a device with no writer is recovering: it never
119
+ // authors the identity's pointer core or any core that exists elsewhere.
120
+ // Only an identity cero generated here creates a database
121
+ const recovering = !writer && (!!opts.key || !fresh)
122
+ const timeout = opts.recoveryTimeout || TIMEOUT
118
123
 
119
124
  // Channel stamp: a storage remembers its channel; reopening it under a different channel —
120
125
  // including no channel at all, which would silently rejoin the global network — is a
@@ -140,6 +145,20 @@ export async function cero(dir, spec, opts = {}) {
140
145
  // before proceeding only delays first onboarding
141
146
  if (!fresh) await Promise.race([discovery.flush(), new Promise((r) => setTimeout(r, FLUSH))])
142
147
 
148
+ // the pointer core: signed by the identity, written once by the device that
149
+ // created the identity, read by every device that recovers it. It holds the
150
+ // root database key, so a phrase alone finds the data
151
+ const manifest = pointerManifest(store, identity)
152
+ const pointer = store.get(
153
+ !writer && !recovering
154
+ ? { keyPair: { publicKey: identity.publicKey, secretKey: identity.secretKey }, manifest }
155
+ : { key: Hypercore.key(manifest) }
156
+ )
157
+ await pointer.ready()
158
+ network.attach(pointer)
159
+
160
+ const key = opts.key || (recovering ? await readPointer(pointer, timeout) : undefined)
161
+
143
162
  me = new Handle({
144
163
  storage,
145
164
  store,
@@ -151,57 +170,31 @@ export async function cero(dir, spec, opts = {}) {
151
170
  opts,
152
171
  dir,
153
172
  routes: opts.routes,
154
- key: opts.key,
173
+ key,
155
174
  encryptionKey: opts.encryptionKey,
156
175
  keyPair: writer ? { publicKey: writer.publicKey, secretKey: writer.secretKey } : undefined,
157
176
  pair: false
158
177
  })
159
178
  await me.ready()
179
+ me.once('close', () => {
180
+ network.detach(pointer)
181
+ pointer.close().catch(safetyCatch)
182
+ })
160
183
 
161
- // a supplied or stored identity may already have history elsewhere —
162
- // authoring genesis twice forks the seed-derived writer core (a writable
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.
166
- if (!writer && !opts.key && !opts.recovery && !fresh) {
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))
182
- }
183
- } finally {
184
- network.detach(genesis)
185
- await genesis.close()
186
- }
187
- }
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)) {
184
+ if (!writer) {
192
185
  const result = await me.bootstrap({
193
186
  name: opts.name || null,
194
187
  isMobile: opts.isMobile === true,
195
- recovering: opts.recovery === true,
196
- ...(opts.recoveryTimeout ? { timeout: opts.recoveryTimeout } : {})
188
+ recovering,
189
+ timeout
197
190
  })
198
- if (local && result?.writer) {
191
+ if (local) {
199
192
  await local.store.set('keypair', {
200
193
  publicKey: result.writer.publicKey,
201
194
  secretKey: result.writer.secretKey
202
195
  })
203
196
  }
204
- if (!opts.recovery) {
197
+ if (!recovering) {
205
198
  const ts = Date.now()
206
199
  await me.store.call('add-member', {
207
200
  id: identity.id,
@@ -211,9 +204,9 @@ export async function cero(dir, spec, opts = {}) {
211
204
  createdAt: ts,
212
205
  updatedAt: ts
213
206
  })
207
+ if (pointer.length === 0) await pointer.append(c.encode(c.fixed32, me.store.key))
214
208
  }
215
209
  }
216
- if (opts.recovery) await me.recover({ timeout: opts.recoveryTimeout })
217
210
 
218
211
  for (const ext of internal.extensions) {
219
212
  if (ext.bundled && opts.extensions === false) continue
@@ -263,8 +256,8 @@ export async function cero(dir, spec, opts = {}) {
263
256
 
264
257
  /**
265
258
  * Restore a cero instance from a mnemonic phrase. Closes the running
266
- * instance, wipes the on-disk `main/` tree and re-opens with `recovery: true`
267
- * so the writer slot is re-claimed.
259
+ * instance, wipes the on-disk `main/` tree and re-opens with the phrase, which
260
+ * recovers so the writer slot is re-claimed.
268
261
  *
269
262
  * @param {Handle} me Existing root handle to restore.
270
263
  * @param {string} phrase BIP-39 mnemonic phrase.
@@ -278,39 +271,18 @@ export async function restore(me, phrase) {
278
271
  const current = await Identity.fromSeed(Identity.toSeed(phrase))
279
272
  if (current.id === me.identity.id) return me
280
273
 
281
- const { _dir: dir, spec, _opts: opts } = me
282
- // channel must carry over — without it the recovered instance rejoins the
283
- // global identity topic and never meets its channeled peers (recovery timeout).
274
+ // everything carries over except the old identity the channel in
275
+ // particular, or the recovered instance never meets its peers
284
276
  const {
285
- name,
286
- bootstrap,
287
- isMobile,
288
- onerror,
289
- routes,
290
- recoveryTimeout,
291
- channel,
292
- mirrors,
293
- storageKey,
294
- extensions
295
- } = opts
277
+ _dir: dir,
278
+ spec,
279
+ _opts: { seed, identity, key, keyPair, ...opts }
280
+ } = me
296
281
 
297
282
  await me.close()
298
283
  await fs.promises.rm(`${dir}/main`, { recursive: true, force: true })
299
284
 
300
- return cero(dir, spec, {
301
- name,
302
- bootstrap,
303
- isMobile,
304
- onerror,
305
- routes,
306
- recoveryTimeout,
307
- channel,
308
- mirrors,
309
- storageKey,
310
- extensions,
311
- phrase,
312
- recovery: true
313
- })
285
+ return cero(dir, spec, { ...opts, phrase })
314
286
  }
315
287
 
316
288
  // Facade: expose the operators + helpers as properties on `cero` too, so both
@@ -349,6 +321,18 @@ cero.use = (...exts) => {
349
321
  }
350
322
  }
351
323
 
324
+ function pointerManifest(store, identity) {
325
+ return { version: store.manifestVersion, signers: [{ publicKey: identity.publicKey }] }
326
+ }
327
+
328
+ async function readPointer(pointer, timeout) {
329
+ try {
330
+ return c.decode(c.fixed32, await pointer.get(0, { timeout }))
331
+ } catch {
332
+ throw CeroError.TIMED_OUT('recovery: finding a device of this identity')
333
+ }
334
+ }
335
+
352
336
  async function resolveIdentity(opts, local) {
353
337
  if (opts.identity) return { identity: opts.identity, fresh: false }
354
338
 
@@ -3,10 +3,10 @@ import b4a from 'b4a'
3
3
 
4
4
  import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
5
5
  import { CeroError } from '@cero-base/core/errors'
6
- import { onAbort } from './utils.js'
6
+ import { onAbort } from '@cero-base/core/utils'
7
7
 
8
8
  /**
9
- * @typedef {import('./utils.js').Ref} Ref
9
+ * @typedef {import('./refs.js').Ref} Ref
10
10
  * @typedef {import('../handle/index.js').CeroHandle} CeroHandle
11
11
  * @typedef {{ data: any }} SingleResult
12
12
  * @typedef {{ data: any[], total: number, size: number }} ListResult
@@ -0,0 +1,49 @@
1
+ import { CeroError } from '@cero-base/core/errors'
2
+
3
+ /**
4
+ * @typedef {'collection' | 'single' | 'action' | 'handle'} RefKind
5
+ * @typedef {{ kind?: string, schema?: string }} RefInfo
6
+ * Shape of the entries in `meta.refs` — describes a single ref name.
7
+ * `kind` is one of {@link RefKind}, kept as `string` since it originates
8
+ * from a generated spec.
9
+ */
10
+
11
+ /**
12
+ * Typed pointer to a single ref (table or handle slot) on a `Handle` or
13
+ * `Local`. Operators (`put`/`get`/`open`/...) take a `Ref` as their first
14
+ * argument and dispatch through the owning handle's store.
15
+ */
16
+ export class Ref {
17
+ /**
18
+ * @param {any} handle Owner — a `Handle` (or `Local`) the ref lives on.
19
+ * @param {string} name Ref name as declared in the schema.
20
+ * @param {string} kind Ref kind: `'collection'`, `'single'`, `'action'`, or `'handle'`.
21
+ * @param {string | null} [schema] Fully-qualified schema id, if any.
22
+ */
23
+ constructor(handle, name, kind, schema = null) {
24
+ this.handle = handle
25
+ this.name = name
26
+ this.kind = kind
27
+ this.schema = schema
28
+ }
29
+
30
+ /**
31
+ * Attach a `Ref` property to `target` for every entry in `refs`, so callers
32
+ * write `handle.someRef` instead of looking refs up by name.
33
+ *
34
+ * @param {any} target
35
+ * @param {Record<string, RefInfo>} refs
36
+ */
37
+ static attach(target, refs) {
38
+ for (const [name, info] of Object.entries(refs || {})) {
39
+ // fail loud rather than silently overwrite a method or property (close,
40
+ // on, store) when a schema declares a ref named like a reserved member
41
+ if (name in target) {
42
+ throw CeroError.INVALID(
43
+ `schema ref '${name}' collides with a reserved ${target.constructor?.name || 'handle'} member — rename it`
44
+ )
45
+ }
46
+ target[name] = new Ref(target, name, info.kind, info.schema)
47
+ }
48
+ }
49
+ }
@@ -3,7 +3,7 @@ import ReadyResource from 'ready-resource'
3
3
  import { Storage } from '@cero-base/core/storage'
4
4
  import { CeroError } from '@cero-base/core/errors'
5
5
 
6
- import { attachRefs } from '../lib/utils.js'
6
+ import { Ref } from '../lib/refs.js'
7
7
 
8
8
  /**
9
9
  * @typedef {object} LocalOpts
@@ -43,7 +43,7 @@ export class Local extends ReadyResource {
43
43
 
44
44
  async _open() {
45
45
  await this.store.ready()
46
- attachRefs(this, this.store.refs)
46
+ Ref.attach(this, this.store.refs)
47
47
  }
48
48
 
49
49
  async _close() {
package/src/rpc/client.js CHANGED
@@ -4,7 +4,7 @@ import c from 'compact-encoding'
4
4
  import z32 from 'z32'
5
5
  import { decodeId } from '@cero-base/core/blobs/codec'
6
6
 
7
- import { attachRefs } from '../lib/utils.js'
7
+ import { Ref } from '../lib/refs.js'
8
8
  import {
9
9
  put,
10
10
  set,
@@ -12,6 +12,7 @@ import {
12
12
  del,
13
13
  count,
14
14
  watch,
15
+ changes,
15
16
  call,
16
17
  open,
17
18
  rotate,
@@ -20,7 +21,7 @@ import {
20
21
  } from '../lib/operators.js'
21
22
  import { t, schema } from '../lib/spec.js'
22
23
 
23
- export { put, set, get, del, count, watch, call, open, rotate, bind, define, t, schema }
24
+ export { put, set, get, del, count, watch, changes, call, open, rotate, bind, define, t, schema }
24
25
 
25
26
  /**
26
27
  * @typedef {import('@cero-base/core/rpc').RPCClient} BaseRPCClient
@@ -449,7 +450,7 @@ class LocalRefs {
449
450
  this._local = true
450
451
  const refs = client.spec.meta.local?.refs || {}
451
452
  const exposed = Object.fromEntries(Object.entries(refs).filter(([, info]) => !info.builtin))
452
- attachRefs(this, exposed)
453
+ Ref.attach(this, exposed)
453
454
  }
454
455
 
455
456
  /** Underlying RPC channel borrowed from the parent. */
@@ -491,7 +492,7 @@ export class Client extends RPCClient {
491
492
  this._fileBase = fileBase || ''
492
493
  this._fileToken = fileToken || ''
493
494
  this.identity = { id, toPhrase: async () => (await this.rpc.seed({})).phrase || null }
494
- attachRefs(this, /** @type {Spec} */ (this.spec).meta.refs)
495
+ Ref.attach(this, /** @type {Spec} */ (this.spec).meta.refs)
495
496
  bind(this, null)
496
497
  if (/** @type {Spec} */ (this.spec).meta.local?.refs) this.local = new LocalRefs(this)
497
498
  }
@@ -560,7 +561,7 @@ class Handle {
560
561
  this.spec = /** @type {Spec} */ (parent.spec).handles[type]
561
562
  if (!this.spec.codec) bindCodec(this.spec)
562
563
  this.store = this
563
- attachRefs(this, this.spec.meta.refs)
564
+ Ref.attach(this, this.spec.meta.refs)
564
565
  bind(this, this.type)
565
566
  }
566
567
 
@@ -618,6 +619,7 @@ cero.get = get
618
619
  cero.del = del
619
620
  cero.count = count
620
621
  cero.watch = watch
622
+ cero.changes = changes
621
623
  cero.call = call
622
624
  cero.open = open
623
625
  cero.rotate = rotate
package/src/rpc/server.js CHANGED
@@ -12,7 +12,6 @@ import { put, set, get, del, count, watch, changes, call } from '../lib/operator
12
12
  *
13
13
  * @typedef {object} ServerOpts
14
14
  * @property {string} storage Directory passed to `cero()` for the local store.
15
- * @property {object} spec Compiled cero spec (schema + rpc + handles).
16
15
  * @property {string} [name] Optional display name forwarded to `cero()`.
17
16
  * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap.
18
17
  * @property {boolean} [isMobile]
@@ -36,11 +35,12 @@ import { put, set, get, del, count, watch, changes, call } from '../lib/operator
36
35
  export class Server extends RPCServer {
37
36
  /**
38
37
  * @param {any} ipc Framed IPC stream (must be writable).
38
+ * @param {object} spec
39
39
  * @param {Partial<ServerOpts>} [opts]
40
40
  */
41
- constructor(ipc, { storage, spec, ...opts } = {}) {
42
- if (!storage) throw CeroError.REQUIRED('storage')
41
+ constructor(ipc, spec, { storage, ...opts } = {}) {
43
42
  if (!spec) throw CeroError.REQUIRED('spec')
43
+ if (!storage) throw CeroError.REQUIRED('storage')
44
44
  super(ipc, spec)
45
45
  if (spec.local?.schema && !spec.local.codec) bindCodec(spec.local)
46
46
  this.storage = storage
@@ -409,11 +409,12 @@ export class Server extends RPCServer {
409
409
  * Construct a `Server`, wait for it to be ready, and return it.
410
410
  *
411
411
  * @param {any} ipc
412
+ * @param {object} spec
412
413
  * @param {ServerOpts} opts
413
414
  * @returns {Promise<Server>}
414
415
  */
415
- export async function serve(ipc, opts) {
416
- const server = new Server(ipc, opts)
416
+ export async function serve(ipc, spec, opts) {
417
+ const server = new Server(ipc, spec, opts)
417
418
  await server.ready()
418
419
  return server
419
420
  }