@cero-base/cero 1.13.0 → 1.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/cero",
3
- "version": "1.13.0",
3
+ "version": "1.14.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,
@@ -95,13 +95,13 @@
95
95
  "test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
96
96
  },
97
97
  "dependencies": {
98
- "@cero-base/core": "^1.13.0",
98
+ "@cero-base/core": "^1.14.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.0",
103
103
  "bare-path": "^3.1.1",
104
- "ble-swarm": "github:holepunchto/ble-swarm#63566f02d4b3a1f3849b9fb009e18666411592a5",
104
+ "ble-swarm": "^2.2.0",
105
105
  "compact-encoding": "^3.3.2",
106
106
  "corestore": "^7.12.0",
107
107
  "hrpc": "^4.3.1",
@@ -114,6 +114,7 @@
114
114
  "ready-resource": "^1.2.0",
115
115
  "safety-catch": "^1.0.3",
116
116
  "streamx": "^2.28.0",
117
+ "suspendify": "^1.7.2",
117
118
  "z32": "^1.1.0"
118
119
  },
119
120
  "devDependencies": {
package/src/bluetooth.js CHANGED
@@ -107,8 +107,9 @@ export class Bluetooth extends ReadyResource {
107
107
  async stop() {
108
108
  this._clearAnnounce()
109
109
  await this.swarm.stop()
110
- // while stopped setTopic just sticks — the next start() is back on the mesh
111
- await this.swarm.setTopic(this._topic)
110
+ // while stopped setTopic just sticks — the next start() is back on the
111
+ // mesh. A start() + announce() that landed during the await wins.
112
+ if (!this._announce) await this.swarm.setTopic(this._topic)
112
113
  }
113
114
 
114
115
  /**
@@ -5,6 +5,7 @@ import Hypercore from 'hypercore'
5
5
  import { discoveryKey } from 'hypercore-crypto'
6
6
  import ReadyResource from 'ready-resource'
7
7
  import safetyCatch from 'safety-catch'
8
+ import Suspendify from 'suspendify'
8
9
  import z32 from 'z32'
9
10
 
10
11
  import { Identity } from '@cero-base/core/identity'
@@ -127,6 +128,10 @@ export class Handle extends ReadyResource {
127
128
  this._fileServer = null
128
129
  this._blobs = null
129
130
  this._epochBlobs = null
131
+ // root only — serializes suspend/resume, converges rapid bounces
132
+ this._sus = parent
133
+ ? null
134
+ : new Suspendify({ suspend: () => this._suspend(), resume: () => this._resume() })
130
135
  this._owned = new Set()
131
136
 
132
137
  this.store = new Database({
@@ -348,7 +353,10 @@ export class Handle extends ReadyResource {
348
353
  blobs
349
354
  .ready()
350
355
  .then(() => {
351
- if (blobs.key) this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
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)
358
+ if (this.closing || this.closed || !blobs.key) return
359
+ this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
352
360
  })
353
361
  .catch(this._onerror)
354
362
  return blobs
@@ -374,7 +382,9 @@ export class Handle extends ReadyResource {
374
382
  if (stamp === undefined) {
375
383
  this.store
376
384
  .get('files', id)
377
- .then(({ data }) => data && this._registerBlobCore(id, data.stamp || 0))
385
+ .then(({ data }) => {
386
+ if (data && !this.closing && !this.closed) this._registerBlobCore(id, data.stamp || 0)
387
+ })
378
388
  .catch(safetyCatch)
379
389
  return
380
390
  }
@@ -412,7 +422,7 @@ export class Handle extends ReadyResource {
412
422
  }
413
423
 
414
424
  get suspended() {
415
- return this._suspended === true
425
+ return this._sus?.suspended === true
416
426
  }
417
427
 
418
428
  /**
@@ -571,14 +581,14 @@ export class Handle extends ReadyResource {
571
581
  const sig = this.identity.sign(
572
582
  addWriterPayload(this.store.key, writerKey, this.store.writerKey)
573
583
  )
574
- await this.store.tx(async () => {
575
- await this.store.call('add-writer', {
584
+ await this.store.tx(async (tx) => {
585
+ await tx.call('add-writer', {
576
586
  sig,
577
587
  master: this.identity.publicKey,
578
588
  writer: writerKey,
579
589
  ts: member.updatedAt || Date.now()
580
590
  })
581
- await this.store.call('add-member', member)
591
+ await tx.call('add-member', member)
582
592
  })
583
593
  }
584
594
 
@@ -619,11 +629,25 @@ export class Handle extends ReadyResource {
619
629
  )
620
630
  // a failure after the child opens must close it — else it leaks its Database,
621
631
  // swarm session and attached bee, never reaped by the parent (not yet a child)
632
+ let publish = null
633
+ let abort = null
634
+ let inflightId = null
622
635
  try {
623
636
  await child.ready()
624
637
  child.name = name
625
638
 
626
639
  const id = toId(child.store.key)
640
+ // Close the create/open race: the `add-handle` write below makes the row
641
+ // visible before this create finishes, so a concurrent _load for the id
642
+ // must share this child — a duplicate Handle over the same core
643
+ // deadlocks in ready().
644
+ const inflight = new Promise((resolve, reject) => {
645
+ publish = resolve
646
+ abort = reject
647
+ })
648
+ inflight.catch(safetyCatch)
649
+ inflightId = id
650
+ this._loading?.set(id, inflight)
627
651
  await this._saveKeyPair(id, writer)
628
652
 
629
653
  const ts = Date.now()
@@ -658,8 +682,12 @@ export class Handle extends ReadyResource {
658
682
  bind(child, type)
659
683
  this.children.add(child)
660
684
  this.emit('handle', child, { name, role })
685
+ publish(child)
686
+ this._loading?.delete(id)
661
687
  return child
662
688
  } catch (err) {
689
+ if (abort) abort(err)
690
+ if (inflightId) this._loading?.delete(inflightId)
663
691
  await child.close().catch(safetyCatch)
664
692
  throw err
665
693
  }
@@ -738,11 +766,23 @@ export class Handle extends ReadyResource {
738
766
  )
739
767
  // whenWritable timing out (host offline) is a normal failure — close the
740
768
  // fully-opened child rather than leak its Database/pairing/swarm session
769
+ let publish = null
770
+ let abort = null
771
+ let inflightId = null
741
772
  try {
742
773
  await child.ready()
743
774
  if (!child.store.writable) await child.store.whenWritable({ timeout: deadline })
744
775
 
745
776
  const id = toId(child.store.key)
777
+ // same create/open race as _create: the add-handle row is visible before
778
+ // this join finishes — a concurrent _load must share this child
779
+ const inflight = new Promise((resolve, reject) => {
780
+ publish = resolve
781
+ abort = reject
782
+ })
783
+ inflight.catch(safetyCatch)
784
+ inflightId = id
785
+ this._loading?.set(id, inflight)
746
786
  await this._saveKeyPair(id, child.store.keyPair)
747
787
 
748
788
  const ts = Date.now()
@@ -759,8 +799,12 @@ export class Handle extends ReadyResource {
759
799
  bind(child, type)
760
800
  this.children.add(child)
761
801
  this.emit('handle', child, {})
802
+ publish(child)
803
+ this._loading?.delete(id)
762
804
  return child
763
805
  } catch (err) {
806
+ if (abort) abort(err)
807
+ if (inflightId) this._loading?.delete(inflightId)
764
808
  await child.close().catch(safetyCatch)
765
809
  throw err
766
810
  }
@@ -834,8 +878,11 @@ export class Handle extends ReadyResource {
834
878
  * @returns {Promise<void>}
835
879
  */
836
880
  async suspend() {
837
- if (this.parent || this.closing || this.closed || this._suspended) return
838
- this._suspended = true
881
+ if (this._sus) await this._sus.suspend()
882
+ }
883
+
884
+ async _suspend() {
885
+ if (this.closing || this.closed) return
839
886
  await Promise.all([...this.children].map((c) => c.pair?.suspend()))
840
887
  await this.bluetooth?.suspend()
841
888
  await this.network.suspend()
@@ -852,8 +899,11 @@ export class Handle extends ReadyResource {
852
899
  * @returns {Promise<void>}
853
900
  */
854
901
  async resume() {
855
- if (this.parent || this.closing || this.closed || !this._suspended) return
856
- this._suspended = false
902
+ if (this._sus) await this._sus.resume()
903
+ }
904
+
905
+ async _resume() {
906
+ if (this.closing || this.closed) return
857
907
  try {
858
908
  await this.store.store.resume()
859
909
  } catch (err) {
package/src/index.js CHANGED
@@ -74,7 +74,7 @@ export { t, schema } from './lib/spec.js'
74
74
  * @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
75
75
  * @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.
76
76
  * @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
77
- * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number }} [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. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
77
+ * @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'`.
78
78
  */
79
79
 
80
80
  /**
@@ -96,6 +96,7 @@ export class Handle extends ReadyResource {
96
96
  _fileServer: FileServer;
97
97
  _blobs: Blobs;
98
98
  _epochBlobs: any;
99
+ _sus: any;
99
100
  _owned: Set<any>;
100
101
  store: Database;
101
102
  pair: Pairing;
@@ -314,13 +315,14 @@ export class Handle extends ReadyResource {
314
315
  * @returns {Promise<void>}
315
316
  */
316
317
  suspend(): Promise<void>;
317
- _suspended: boolean;
318
+ _suspend(): Promise<void>;
318
319
  /**
319
320
  * Resume a suspended root handle. Idempotent; no-op on child handles.
320
321
  *
321
322
  * @returns {Promise<void>}
322
323
  */
323
324
  resume(): Promise<void>;
325
+ _resume(): Promise<void>;
324
326
  /**
325
327
  * @param {Handle} child
326
328
  * @param {{ role?: string }} [opts]
package/types/index.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
21
21
  * @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.
22
22
  * @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
23
- * @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number }} [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. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
23
+ * @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'`.
24
24
  */
25
25
  /**
26
26
  * Open (or create) a cero handle at `dir`. Sets up storage, network and
@@ -140,13 +140,14 @@ export type CeroOpts = {
140
140
  */
141
141
  extensions?: boolean;
142
142
  /**
143
- * `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. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
143
+ * `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'`.
144
144
  */
145
145
  bluetooth?: boolean | {
146
146
  autoStart?: boolean;
147
147
  backend?: any;
148
148
  maxOutbound?: number;
149
149
  maxInbound?: number;
150
+ pipe?: "l2cap" | "gatt";
150
151
  };
151
152
  };
152
153
  import { t } from './lib/spec.js';