@cero-base/cero 1.7.0 → 1.8.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
@@ -521,14 +521,14 @@ The same `spec/index.js` is imported on both sides. `@cero-base/cero/build` emit
521
521
 
522
522
  Everything the local API offers, plus lifecycle methods on returned handles:
523
523
 
524
- | Operator / method | Works over RPC |
525
- | -------------------------------------------------------------------------------------------------------------------- | -------------- |
526
- | `cero.put` / `set` / `get` / `del` / `count` / `watch` / `call` | ✓ |
527
- | `cero.open(ref, …)` — create, join, load by id | ✓ |
528
- | `room.invite({ role, expiresIn, multiUse })` — mint an invite (`multiUse: true` keeps it alive after the first join) | ✓ |
529
- | `room.revoke(invite)` — invalidate an outstanding invite; `true` if it existed | ✓ |
530
- | `room.close()` — release the handle on the server | ✓ |
531
- | `room.leave()` — remove yourself from the room and from your handles list | ✓ |
524
+ | Operator / method | Works over RPC |
525
+ | -------------------------------------------------------------------------------------------------------------- | -------------- |
526
+ | `cero.put` / `set` / `get` / `del` / `count` / `watch` / `call` | ✓ |
527
+ | `cero.open(ref, …)` — create, join, load by id | ✓ |
528
+ | `room.invite({ role, expiresIn, reuse })` — mint an invite (`reuse: true` keeps it alive after the first join) | ✓ |
529
+ | `room.revoke(invite)` — invalidate an outstanding invite; `true` if it existed | ✓ |
530
+ | `room.close()` — release the handle on the server | ✓ |
531
+ | `room.leave()` — remove yourself from the room and from your handles list | ✓ |
532
532
 
533
533
  `cero.watch(ref)` returns a Readable on both sides; snapshots flow as a server-streamed RPC.
534
534
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "1.7.0",
3
+ "version": "1.8.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,
@@ -89,27 +89,27 @@
89
89
  "build:types": "rm -rf types && tsc -p .",
90
90
  "pretest": "npm run build:test",
91
91
  "prepublishOnly": "npm run build:types",
92
- "test": "npm run test:node",
92
+ "test": "npm run test:bare",
93
93
  "pretest:bare": "npm run build:test",
94
94
  "test:bare": "ls test/*.test.js | xargs -P1 -n1 brittle-bare test/bare.js",
95
95
  "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
96
96
  },
97
97
  "dependencies": {
98
- "@cero-base/core": "^1.7.0",
98
+ "@cero-base/core": "^1.8.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.7.4",
103
103
  "bare-path": "^3.1.1",
104
104
  "compact-encoding": "^3.3.0",
105
- "corestore": "^7.11.1",
105
+ "corestore": "^7.12.0",
106
106
  "hrpc": "^4.3.0",
107
- "hypercore": "^11.34.1",
107
+ "hypercore": "^11.35.1",
108
108
  "hypercore-crypto": "^3.7.0",
109
109
  "hypercore-storage": "^3.2.0",
110
- "hyperdb": "^6.7.0",
110
+ "hyperdb": "^6.8.0",
111
111
  "hyperdispatch": "^1.6.0",
112
- "hyperschema": "^1.21.0",
112
+ "hyperschema": "^1.22.0",
113
113
  "ready-resource": "^1.2.0",
114
114
  "safety-catch": "^1.0.3",
115
115
  "streamx": "^2.28.0",
@@ -138,7 +138,7 @@ export const rpc = {
138
138
  handle: required(string),
139
139
  role: string,
140
140
  expiresIn: uint,
141
- multiUse: bool
141
+ reuse: bool
142
142
  },
143
143
  'req-revoke': {
144
144
  handle: required(string),
@@ -14,6 +14,7 @@ import { Pairing } from '@cero-base/core/pairing'
14
14
  import { toId, grants, addWriterPayload } from '@cero-base/core/utils'
15
15
  import { CeroError } from '@cero-base/core/errors'
16
16
  import { Blobs } from '@cero-base/core/blobs'
17
+ import { decodeId } from '@cero-base/core/blobs/codec'
17
18
  import { FileServer } from '@cero-base/core/blobs/server'
18
19
 
19
20
  import { NS, TIMEOUT } from '../lib/constants.js'
@@ -121,6 +122,7 @@ export class Handle extends ReadyResource {
121
122
  this._onerror = this._opts.onerror || safetyCatch
122
123
  this.children = parent ? null : new Set()
123
124
  this._loading = parent ? null : new Map()
125
+ this._joining = parent ? null : new Map()
124
126
  this._coreKeys = parent ? null : new Map()
125
127
  this._fileServer = null
126
128
  this._blobs = null
@@ -339,6 +341,50 @@ export class Handle extends ReadyResource {
339
341
  return blobs
340
342
  }
341
343
 
344
+ /**
345
+ * Remember the blob-core key a file id points at so the file server can
346
+ * open the core. Lives on the Handle (not the shared operators) because the
347
+ * epoch-key derivation pulls native crypto — the RPC client must stay
348
+ * bundleable without it.
349
+ *
350
+ * @param {string} id
351
+ * @param {number} [stamp]
352
+ */
353
+ _registerBlobCore(id, stamp) {
354
+ if (!id || !this.root?._coreKeys) return
355
+ try {
356
+ const { coreKey } = decodeId(id)
357
+ const hex = b4a.toString(coreKey, 'hex')
358
+ if (!this.root._coreKeys.has(hex)) {
359
+ // a file-field value carries no stamp — look it up from the files row
360
+ // (fire-and-forget: idempotent, resolution happens again per request)
361
+ if (stamp === undefined) {
362
+ this.store
363
+ .get('files', id)
364
+ .then(({ data }) => data && this._registerBlobCore(id, data.stamp || 0))
365
+ .catch(safetyCatch)
366
+ return
367
+ }
368
+ const key = this._blobCoreKey(stamp)
369
+ if (!key) return // unknown epoch — this device is not entitled to the core
370
+ this.root._coreKeys.set(hex, key)
371
+ }
372
+ // remember which handle read it, so close prunes the entry (re-registered
373
+ // on the next read if another handle still serves the same core)
374
+ if (this !== this.root) (this._blobKeys ??= new Set()).add(hex)
375
+ } catch {
376
+ // ignore invalid ids
377
+ }
378
+ }
379
+
380
+ // Base-era blob cores use the OWNING handle's key (not the root's — rooms
381
+ // have their own keys); rotated-era cores derive from the epoch entropy.
382
+ _blobCoreKey(stamp) {
383
+ if (!stamp) return this.store.encryptionKey
384
+ const entropy = this.store.keyring.entropy(stamp)
385
+ return entropy ? blobEpochKey(entropy) : null
386
+ }
387
+
342
388
  /** Canonical id — identity id for the root handle, store key for children. */
343
389
  get id() {
344
390
  if (!this.parent) return this.identity.id
@@ -585,7 +631,33 @@ export class Handle extends ReadyResource {
585
631
  * @param {JoinChildOpts} [opts]
586
632
  * @returns {Promise<Handle>}
587
633
  */
588
- async _join(invite, type, { routes, timeout } = {}) {
634
+ async _join(invite, type, opts = {}) {
635
+ // Coalesce concurrent joins of the same room (a retry racing a pending
636
+ // join, a double tap): the pairing layer allows one candidate per invite,
637
+ // so the second attempt must attach to the in-flight join, not start
638
+ // another handshake.
639
+ const target = Pairing.inviteTopic(invite)
640
+ const key = target && `${type}/${b4a.toString(target, 'hex')}`
641
+ if (!key) return this._pair(invite, type, opts, target)
642
+ const pending = this._joining.get(key)
643
+ if (pending) return pending
644
+ const joining = this._pair(invite, type, opts, target)
645
+ this._joining.set(key, joining)
646
+ try {
647
+ return await joining
648
+ } finally {
649
+ this._joining.delete(key)
650
+ }
651
+ }
652
+
653
+ /**
654
+ * @param {string} invite
655
+ * @param {string} type
656
+ * @param {JoinChildOpts} [opts]
657
+ * @param {Uint8Array | null} [target]
658
+ * @returns {Promise<Handle>}
659
+ */
660
+ async _pair(invite, type, { routes, timeout } = {}, target = null) {
589
661
  const deadline = timeout || TIMEOUT
590
662
 
591
663
  // Idempotent: if the invite targets a handle we already have AND we are
@@ -594,7 +666,6 @@ export class Handle extends ReadyResource {
594
666
  // removed), the stored writer is revoked and the old session can never
595
667
  // become writable again — holding a fresh invite is exactly the
596
668
  // re-admission path, so fall through to a real pairing instead.
597
- const target = Pairing.inviteTopic(invite)
598
669
  if (target) {
599
670
  const { data: joined } = await this.store.get('handles')
600
671
  const existing = joined.find(
@@ -767,7 +838,9 @@ export class Handle extends ReadyResource {
767
838
  if (!spec) throw CeroError.REQUIRED('spec')
768
839
 
769
840
  const writer = Identity.randomKeyPair()
770
- const pair = new Pairing({ network: net, identity: id })
841
+ // join-only: no member listener, so concurrent joins never collide on the
842
+ // identity-derived default topic ('Active member already exist')
843
+ const pair = new Pairing({ network: net, identity: id, host: false })
771
844
  await pair.ready()
772
845
 
773
846
  let key, encryptionKey, additional
@@ -2,7 +2,6 @@ import { Readable } from 'streamx'
2
2
  import b4a from 'b4a'
3
3
 
4
4
  import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
5
- import { blobEpochKey } from '@cero-base/core/database/encryption'
6
5
  import { CeroError } from '@cero-base/core/errors'
7
6
  import { onAbort } from './utils.js'
8
7
 
@@ -169,7 +168,7 @@ function resolveRow(ref, row) {
169
168
  if (ref.handle.rpc) return ref.handle._resolveRow(ref.name, ref.handle._refInfo(ref.name), row)
170
169
  const handle = ref.handle
171
170
  const resolve = (id, name, stamp) => {
172
- registerBlobCore(handle, id, stamp)
171
+ handle._registerBlobCore(id, stamp)
173
172
  return resolveFile(handle, id, name)
174
173
  }
175
174
  if (ref.name === 'files') {
@@ -186,41 +185,6 @@ function resolveRow(ref, row) {
186
185
  return out
187
186
  }
188
187
 
189
- function registerBlobCore(handle, id, stamp) {
190
- if (!id || !handle.root?._coreKeys) return
191
- try {
192
- const { coreKey } = decodeId(id)
193
- const hex = b4a.toString(coreKey, 'hex')
194
- if (!handle.root._coreKeys.has(hex)) {
195
- // a file-field value carries no stamp — look it up from the files row
196
- // (fire-and-forget: idempotent, resolution happens again per request)
197
- if (stamp === undefined) {
198
- handle.store
199
- .get('files', id)
200
- .then(({ data }) => data && registerBlobCore(handle, id, data.stamp || 0))
201
- .catch(() => {})
202
- return
203
- }
204
- const key = blobCoreKey(handle, stamp)
205
- if (!key) return // unknown epoch — this device is not entitled to the core
206
- handle.root._coreKeys.set(hex, key)
207
- }
208
- // remember which handle read it, so close prunes the entry (re-registered
209
- // on the next read if another handle still serves the same core)
210
- if (handle !== handle.root) (handle._blobKeys ??= new Set()).add(hex)
211
- } catch {
212
- // ignore invalid ids
213
- }
214
- }
215
-
216
- // Base-era blob cores use the OWNING handle's key (not the root's — rooms have
217
- // their own keys); rotated-era cores derive from the epoch entropy.
218
- function blobCoreKey(handle, stamp) {
219
- if (!stamp) return handle.store.encryptionKey
220
- const entropy = handle.store.keyring.entropy(stamp)
221
- return entropy ? blobEpochKey(entropy) : null
222
- }
223
-
224
188
  /**
225
189
  * Read from `ref`. For data refs, dispatches to the underlying store. For
226
190
  * `handle`-kind refs, lists existing child handles of that type from the
package/src/rpc/client.js CHANGED
@@ -385,12 +385,12 @@ const operators = {
385
385
  * @param {{ role?: string }} [opts]
386
386
  * @returns {Promise<string>}
387
387
  */
388
- async invite({ role, expiresIn, multiUse } = {}) {
388
+ async invite({ role, expiresIn, reuse } = {}) {
389
389
  const { invite } = await this.rpc.invite({
390
390
  handle: this.id,
391
391
  role: role || '',
392
392
  expiresIn: expiresIn || 0,
393
- multiUse: multiUse === true
393
+ reuse: reuse === true
394
394
  })
395
395
  return invite
396
396
  },
package/src/rpc/server.js CHANGED
@@ -267,12 +267,12 @@ export class Server extends RPCServer {
267
267
 
268
268
  /** Register invite/revoke/join RPC handlers. */
269
269
  _wirePairing() {
270
- this.rpc.onInvite(async ({ handle, role, expiresIn, multiUse }) => {
270
+ this.rpc.onInvite(async ({ handle, role, expiresIn, reuse }) => {
271
271
  const h = this._resolve(handle)
272
272
  const invite = await h.invite({
273
273
  role: role || undefined,
274
274
  expiresIn: expiresIn || undefined,
275
- multiUse: multiUse === true
275
+ reuse: reuse === true
276
276
  })
277
277
  return { invite }
278
278
  })
@@ -129,7 +129,7 @@ export const rpc: {
129
129
  handle: import("@cero-base/core").Prim;
130
130
  role: import("@cero-base/core").Prim;
131
131
  expiresIn: import("@cero-base/core").Prim;
132
- multiUse: import("@cero-base/core").Prim;
132
+ reuse: import("@cero-base/core").Prim;
133
133
  };
134
134
  'req-revoke': {
135
135
  handle: import("@cero-base/core").Prim;
@@ -91,6 +91,7 @@ export class Handle extends ReadyResource {
91
91
  _onerror: any;
92
92
  children: Set<any>;
93
93
  _loading: Map<any, any>;
94
+ _joining: Map<any, any>;
94
95
  _coreKeys: Map<any, any>;
95
96
  _fileServer: FileServer;
96
97
  _blobs: Blobs;
@@ -172,6 +173,17 @@ export class Handle extends ReadyResource {
172
173
  get blobs(): Blobs;
173
174
  _baseBlobs(): Blobs;
174
175
  _makeBlobs(name: any, encryptionKey: any, stamp: any): Blobs;
176
+ /**
177
+ * Remember the blob-core key a file id points at so the file server can
178
+ * open the core. Lives on the Handle (not the shared operators) because the
179
+ * epoch-key derivation pulls native crypto — the RPC client must stay
180
+ * bundleable without it.
181
+ *
182
+ * @param {string} id
183
+ * @param {number} [stamp]
184
+ */
185
+ _registerBlobCore(id: string, stamp?: number): void;
186
+ _blobCoreKey(stamp: any): Uint8Array<ArrayBufferLike>;
175
187
  /** Canonical id — identity id for the root handle, store key for children. */
176
188
  get id(): any;
177
189
  /** This device's id + name. `null` on child handles. */
@@ -266,7 +278,15 @@ export class Handle extends ReadyResource {
266
278
  * @param {JoinChildOpts} [opts]
267
279
  * @returns {Promise<Handle>}
268
280
  */
269
- _join(invite: string, type: string, { routes, timeout }?: JoinChildOpts): Promise<Handle>;
281
+ _join(invite: string, type: string, opts?: JoinChildOpts): Promise<Handle>;
282
+ /**
283
+ * @param {string} invite
284
+ * @param {string} type
285
+ * @param {JoinChildOpts} [opts]
286
+ * @param {Uint8Array | null} [target]
287
+ * @returns {Promise<Handle>}
288
+ */
289
+ _pair(invite: string, type: string, { routes, timeout }?: JoinChildOpts, target?: Uint8Array | null): Promise<Handle>;
270
290
  /**
271
291
  * Get an open child by id, or re-open it. Concurrent calls for the same id
272
292
  * share one in-flight load, so the child is built — and `handle` emitted —