@cero-base/core 1.13.0 → 1.14.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/core",
3
- "version": "1.13.0",
3
+ "version": "1.14.1",
4
4
  "description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,10 +41,6 @@
41
41
  "types": "./types/network/index.d.ts",
42
42
  "default": "./src/network/index.js"
43
43
  },
44
- "./network/transports/dht": {
45
- "types": "./types/network/transports/dht.d.ts",
46
- "default": "./src/network/transports/dht.js"
47
- },
48
44
  "./database": {
49
45
  "types": "./types/database/index.d.ts",
50
46
  "default": "./src/database/index.js"
@@ -140,17 +136,17 @@
140
136
  },
141
137
  "dependencies": {
142
138
  "@hyperswarm/secret-stream": "^6.9.1",
143
- "autobee": "2.0.0-rc.19",
139
+ "autobee": "2.0.0-rc.25",
144
140
  "autobee-encryption": "0.1.3",
145
141
  "b4a": "^1.8.1",
146
142
  "bare-crypto": "^1.15.3",
147
- "bare-fs": "^4.8.0",
143
+ "bare-fs": "^4.8.1",
148
144
  "bare-path": "^3.1.1",
149
145
  "bip39-mnemonic": "^2.5.0",
150
146
  "blind-pairing": "^2.3.1",
151
147
  "blind-peering": "^2.6.3",
152
148
  "compact-encoding": "^3.3.2",
153
- "corestore": "^7.12.0",
149
+ "corestore": "^7.12.2",
154
150
  "framed-stream": "^1.0.1",
155
151
  "hrpc": "^4.3.1",
156
152
  "hyperblobs": "^2.12.1",
@@ -168,17 +164,17 @@
168
164
  "ready-resource": "^1.2.0",
169
165
  "safety-catch": "^1.0.3",
170
166
  "sodium-universal": "^5.0.1",
171
- "streamx": "^2.28.0",
167
+ "streamx": "^2.28.1",
172
168
  "z32": "^1.1.0"
173
169
  },
174
170
  "devDependencies": {
175
171
  "@hyperswarm/testnet": "^3.1.4",
176
172
  "bare-abort-controller": "^1.1.2",
177
- "bare-events": "^2.9.1",
173
+ "bare-events": "^2.9.2",
178
174
  "bare-fetch": "^3.2.0",
179
175
  "bare-process": "^4.5.1",
180
176
  "bare-url": "^2.5.2",
181
- "blind-peer": "^3.13.3",
177
+ "blind-peer": "^3.14.0",
182
178
  "brittle": "^4.1.0",
183
179
  "typescript": "^5.9.3",
184
180
  "which-runtime": "^1.4.0"
@@ -110,9 +110,13 @@ async function saveWriter(db, writerKey, { name, isMobile }) {
110
110
  ])
111
111
  }
112
112
 
113
+ // Writer rotation by reboot: close the bee and boot again with the device
114
+ // keyPair — autobee's fully-supported path (a fresh boot re-drains the log
115
+ // and rebuilds its own state, no upstream userData is touched). setLocal is
116
+ // not usable here: appends made through the base right after a live rotation
117
+ // queue without ever flushing (keet pre-writes the first op raw instead,
118
+ // which needs autobee's private oplog encoding).
113
119
  async function swapWriter(db, keyPair, manifest) {
114
- const head = await db.bee.local.getUserData('autobee/head')
115
- const enc = await db.bee.local.getUserData('autobee/encryption')
116
120
  const epochs = await db.bee.local.getUserData('cero/epochs')
117
121
 
118
122
  if (db.network) db.network.detach(db.bee)
@@ -121,8 +125,6 @@ async function swapWriter(db, keyPair, manifest) {
121
125
 
122
126
  const deviceCore = db.store.namespace(db.namespace).get({ keyPair, manifest })
123
127
  await deviceCore.ready()
124
- if (head) await deviceCore.setUserData('autobee/head', head)
125
- if (enc) await deviceCore.setUserData('autobee/encryption', enc)
126
128
  if (epochs) await deviceCore.setUserData('cero/epochs', epochs)
127
129
  await deviceCore.close()
128
130
 
@@ -1,18 +1,14 @@
1
1
  import { Readable } from 'streamx'
2
2
  import b4a from 'b4a'
3
3
 
4
- // the pinned hyperbee2 snapshot inside a hyperdb snapshot — internals reach
5
- // (pear-sdk does the same); kept in one helper so an upstream rename is a
6
- // one-line fix
7
- const beeOf = (snap) => snap.engineSnapshot.snapshot
8
-
9
4
  /**
10
5
  * Pull-driven delta stream over a collection: batches of `{ prev, next }`
11
- * pairs diffed between per-stream snapshot cursors. Update ticks only mark
12
- * the stream dirty — the diff runs when the reader demands, so backpressure
13
- * folds bursts into one bigger batch and nothing is ever dropped. The first
14
- * batch, and any batch after the view is swapped or fast-forwarded to a new
15
- * core, carries the full matching state as inserts with `reset: true`.
6
+ * pairs diffed between per-stream head cursors (`db.diff(col, { from })`).
7
+ * Update ticks only mark the stream dirty — the diff runs when the reader
8
+ * demands, so backpressure folds bursts into one bigger batch and nothing is
9
+ * ever dropped. The first batch, and any batch after the view is swapped or
10
+ * fast-forwarded to a new core, carries the full matching state as inserts
11
+ * with `reset: true`.
16
12
  *
17
13
  * @param {import('./index.js').Database} db
18
14
  * @param {string} name Ref name (scopes the update ticks).
@@ -39,9 +35,6 @@ export function makeChanges(db, name, col, matches) {
39
35
  },
40
36
  predestroy() {
41
37
  if (off) off()
42
- },
43
- destroy(cb) {
44
- release().then(() => cb(null), cb)
45
38
  }
46
39
  })
47
40
 
@@ -69,51 +62,32 @@ export function makeChanges(db, name, col, matches) {
69
62
  }
70
63
 
71
64
  const diff = async () => {
72
- const snap = db.view.snapshot()
65
+ const view = db.view
66
+ // snapshot() and engine.head() are synchronous — captured back to back
67
+ // they describe the same state, so the cursor is exactly-once
68
+ const snap = view.snapshot()
69
+ const head = view.engine.head()
73
70
  try {
74
- const fresh =
75
- !cursor ||
76
- cursorView !== db.view ||
77
- !b4a.equals(beeOf(cursor).core.key, beeOf(snap).core.key)
78
- const batch = fresh ? await initial(snap) : await delta(snap)
79
- if (cursor) await cursor.close()
80
- cursor = snap
81
- cursorView = db.view
82
- return batch
83
- } catch (err) {
71
+ const fresh = !cursor || cursorView !== view || !head || !b4a.equals(cursor.key, head.key)
72
+ const changes = []
73
+ if (fresh) {
74
+ for (const next of await snap.find(col, {}).toArray()) {
75
+ if (matches(next)) changes.push({ prev: null, next })
76
+ }
77
+ } else {
78
+ for await (const { left, right } of snap.diff(col, { from: cursor })) {
79
+ const prev = left && matches(left) ? left : null
80
+ const next = right && matches(right) ? right : null
81
+ if (prev || next) changes.push({ prev, next })
82
+ }
83
+ }
84
+ cursor = head
85
+ cursorView = view
86
+ if (fresh) return { changes, reset: true }
87
+ return changes.length ? { changes, reset: false } : null
88
+ } finally {
84
89
  await snap.close().catch(() => {})
85
- throw err
86
- }
87
- }
88
-
89
- const initial = async (snap) => {
90
- const changes = []
91
- for (const next of await snap.find(col, {}).toArray()) {
92
- if (matches(next)) changes.push({ prev: null, next })
93
90
  }
94
- return { changes, reset: true }
95
- }
96
-
97
- const delta = async (snap) => {
98
- const collection = db.view.definition.resolveCollection(col)
99
- const range = collection.encodeKeyRange({})
100
- const changes = []
101
- for await (const { left, right } of beeOf(cursor).createDiffStream(beeOf(snap), range)) {
102
- const prev = left ? collection.reconstruct(db.view.versions, left.key, left.value) : null
103
- const next = right ? collection.reconstruct(db.view.versions, right.key, right.value) : null
104
- const from = prev && matches(prev) ? prev : null
105
- const to = next && matches(next) ? next : null
106
- if (!from && !to) continue
107
- changes.push({ prev: from, next: to })
108
- }
109
- return changes.length ? { changes, reset: false } : null
110
- }
111
-
112
- const release = async () => {
113
- if (!cursor) return
114
- const held = cursor
115
- cursor = null
116
- await held.close().catch(() => {})
117
91
  }
118
92
 
119
93
  return stream
@@ -43,24 +43,23 @@ AutobeeEncryption.prototype.getKeys = async function (id, ctx) {
43
43
  if (!id) return baseGetKeys.call(this, id, ctx)
44
44
 
45
45
  const keyring = this.auto?.keyring
46
- if (!keyring) throw CeroError.UNKNOWN_EPOCH(id)
47
-
48
- let entropy = keyring.entropy(id)
49
- if (!entropy && !keyring.primed && this.auto.local) {
50
- // first epoch miss of a session may happen inside autobee's own boot
51
- // (reading back this device's post-rotation state) — prime lazily from
52
- // the local core's userData, once
53
- keyring.primed = true
54
- await primeKeyring(keyring, this.auto.local)
55
- entropy = keyring.entropy(id)
46
+ const entropy = keyring && keyring.entropy(id)
47
+ if (!entropy) {
48
+ // record which core stalled and arm the retry — a writer autobee freezes
49
+ // over this throw detaches silently, and only a wakeup() re-adds it
50
+ if (ctx?.key && this.auto?._epochStalled) {
51
+ this.auto._epochStalled.add(b4a.toString(ctx.key, 'hex'))
52
+ this.auto._scheduleEpochRetry()
53
+ }
54
+ throw CeroError.UNKNOWN_EPOCH(id)
56
55
  }
57
- if (!entropy) throw CeroError.UNKNOWN_EPOCH(id)
58
56
 
59
57
  const block = this.blockKey(entropy, ctx)
60
58
  return { id, block, hash: crypto.hash([NS_HASH_KEY, block]) }
61
59
  }
62
60
 
63
- async function primeKeyring(keyring, local) {
61
+ /** Prime a keyring from a local core's persisted epoch stash. */
62
+ export async function loadEpochs(keyring, local) {
64
63
  const saved = await local.getUserData('cero/epochs').catch(() => null)
65
64
  if (!saved) return
66
65
  try {
@@ -140,7 +139,6 @@ export class Keyring {
140
139
  this.seq = 0
141
140
  /** bumped on every add/remove — cheap change detection for retries */
142
141
  this.version = 0
143
- this.primed = false
144
142
  }
145
143
 
146
144
  /** @returns {Array<{ epoch: number, stamp: number, entropy: Uint8Array }>} ascending by seq */
@@ -216,28 +214,17 @@ export class EpochAutobee extends Autobee {
216
214
  constructor(store, key, handlers = {}) {
217
215
  super(store, key, handlers)
218
216
  this.keyring = handlers.keyring || null
217
+ this._epochStalled = new Set()
219
218
  this._epochRetry = null
220
219
  this._epochRetryDelay = 1000
221
220
  this._epochRetrySeen = 0
222
221
  }
223
222
 
224
- // Prime the keyring from local userData as soon as boot resolves the local
225
- // core (in-boot epoch misses are covered by the provider's lazy prime).
226
- async _bootState() {
227
- await super._bootState()
228
- if (!this.keyring || this.keyring.primed || !this.local) return
229
- this.keyring.primed = true
230
- await primeKeyring(this.keyring, this.local)
231
- }
232
-
233
- // A writer whose next blocks sit at an epoch we haven't learned yet parks:
234
- // its next() reports "nothing to offer" instead of throwing, so upstream's
235
- // drain keeps selecting among the other writers — the announcement carrying
236
- // the missing epoch lives in one of THEIR cores and unlocks the parked
237
- // writer on a later bump (the scheduled retry pokes the drain). The outer
238
- // catch backstops an UNKNOWN_EPOCH surfacing from batch processing itself.
223
+ // An UNKNOWN_EPOCH surfacing from the drain (a system read at an epoch we
224
+ // haven't learned yet) parks the whole pass the scheduled retry wakes the
225
+ // stalled cores once the announcement lands. Oplog blocks at unknown epochs
226
+ // never reach here: autobee's own reader freezes that writer.
239
227
  async _bumpPendingWriters() {
240
- for (const w of this.writers.pending) this._parkOnUnknownEpoch(w)
241
228
  try {
242
229
  return await super._bumpPendingWriters()
243
230
  } catch (err) {
@@ -247,25 +234,22 @@ export class EpochAutobee extends Autobee {
247
234
  }
248
235
  }
249
236
 
250
- _parkOnUnknownEpoch(w) {
251
- if (w._epochParking) return
252
- w._epochParking = true
253
- const next = w.next.bind(w)
254
- w.next = async () => {
255
- try {
256
- return await next()
257
- } catch (err) {
258
- if (err?.code !== 'UNKNOWN_EPOCH') throw err
259
- this._scheduleEpochRetry()
260
- return null
261
- }
237
+ // hints read the system bee outside upstream's guarded drain — an escaping
238
+ // UNKNOWN_EPOCH would close the bee; park and retry like everything else
239
+ async _applyWakeupHints() {
240
+ try {
241
+ return await super._applyWakeupHints()
242
+ } catch (err) {
243
+ if (err?.code !== 'UNKNOWN_EPOCH') throw err
244
+ this._scheduleEpochRetry()
245
+ return new Map()
262
246
  }
263
247
  }
264
248
 
265
- // a parked writer produces no wake-up of its own — poke the bee until the
266
- // pending announcement applies and the parked blocks decrypt. Exponential
267
- // backoff (reset when the keyring advances) so a removed member, who will
268
- // never learn the epoch, settles into a slow idle poll instead of a hot loop
249
+ // a stalled core produces no wake-up of its own — and a writer autobee froze
250
+ // over one only comes back through wakeup(), update() never re-adds it.
251
+ // Exponential backoff (reset when the keyring advances) so a removed member,
252
+ // who will never learn the epoch, settles into a slow idle poll
269
253
  _scheduleEpochRetry() {
270
254
  if (this._epochRetry || this.closing) return
271
255
  const version = this.keyring ? this.keyring.version : 0
@@ -275,7 +259,12 @@ export class EpochAutobee extends Autobee {
275
259
  }
276
260
  this._epochRetry = setTimeout(() => {
277
261
  this._epochRetry = null
278
- if (!this.closing) this.update().catch(noop)
262
+ if (this.closing) return
263
+ for (const hex of this._epochStalled) {
264
+ this.wakeup({ key: b4a.from(hex, 'hex'), length: 0 }).catch(noop)
265
+ }
266
+ this._epochStalled.clear()
267
+ this.update().catch(noop)
279
268
  }, this._epochRetryDelay)
280
269
  this._epochRetryDelay = Math.min(this._epochRetryDelay * 2, 60000)
281
270
  }
@@ -6,7 +6,16 @@ import ReadyResource from 'ready-resource'
6
6
  import safetyCatch from 'safety-catch'
7
7
  import b4a from 'b4a'
8
8
 
9
- import { NAMESPACE, SINGLE, COLLECTION, ACTION, ACTIVE, PASSIVE, REMOVE } from '../lib/constants.js'
9
+ import {
10
+ NAMESPACE,
11
+ SINGLE,
12
+ COLLECTION,
13
+ ACTION,
14
+ ACTIVE,
15
+ PASSIVE,
16
+ REMOVE,
17
+ QUERY_RESERVED
18
+ } from '../lib/constants.js'
10
19
  import {
11
20
  genId,
12
21
  toId,
@@ -14,10 +23,11 @@ import {
14
23
  can,
15
24
  subscribe,
16
25
  addWriterPayload,
17
- claimWriterPayload
26
+ claimWriterPayload,
27
+ searchHit
18
28
  } from '../lib/utils.js'
19
29
  import { wrap, unwrap } from './envelope.js'
20
- import { EpochAutobee, Keyring, wraps, epochEntries } from './encryption.js'
30
+ import { EpochAutobee, Keyring, wraps, epochEntries, loadEpochs } from './encryption.js'
21
31
  import { Identity } from '../identity/index.js'
22
32
  import { CeroError } from '../lib/errors.js'
23
33
  import { bootstrap } from './bootstrap.js'
@@ -119,7 +129,9 @@ export class Database extends ReadyResource {
119
129
  this._healTimer = null
120
130
  }
121
131
  if (this._discovery) {
122
- await this._discovery.destroy()
132
+ // unannounce in the background — awaiting the DHT round-trip here
133
+ // blocks every close (and the writer-swap reboot) for seconds
134
+ this._discovery.destroy().catch(safetyCatch)
123
135
  this._discovery = null
124
136
  }
125
137
  if (this.bee) {
@@ -133,6 +145,20 @@ export class Database extends ReadyResource {
133
145
 
134
146
  /** Open the underlying autobee, wire dispatcher + apply, attach to network. */
135
147
  async openBee() {
148
+ // prime the keyring from the local core's epoch stash BEFORE boot — the
149
+ // bee's own boot reads post-rotation state through the epoch provider
150
+ const local = this.store.namespace(this.namespace).get({
151
+ keyPair: this.keyPair,
152
+ manifest: {
153
+ version: this.store.manifestVersion,
154
+ signers: [{ publicKey: this.keyPair.publicKey }]
155
+ },
156
+ active: false
157
+ })
158
+ await local.ready()
159
+ await loadEpochs(this.keyring, local)
160
+ await local.close()
161
+
136
162
  this.dispatcher = makeDispatcher(
137
163
  this.spec,
138
164
  this.ns,
@@ -228,12 +254,14 @@ export class Database extends ReadyResource {
228
254
 
229
255
  if (this.network) {
230
256
  this.network.attach(bee)
231
- // a writer swap re-opens the bee tear down the prior discovery session
232
- // first so it isn't orphaned by the reassignment below
233
- if (this._discovery) await this._discovery.destroy()
257
+ // a writer swap re-opens the bee on the SAME topic join first so the
258
+ // old session's destroy is a refcounted detach, not a DHT unannounce
259
+ // round-trip (which blocked the swap for seconds)
260
+ const prev = this._discovery
234
261
  this._discovery = this.network.join(bee.discoveryKey, {
235
262
  mode: this.passive ? PASSIVE : ACTIVE
236
263
  })
264
+ if (prev) await prev.destroy()
237
265
  }
238
266
  }
239
267
 
@@ -676,7 +704,7 @@ export class Database extends ReadyResource {
676
704
  try {
677
705
  const rows = await this.view.find(`@${this.ns}/epochs`, {}).toArray()
678
706
  for (const row of rows) {
679
- if (this.keyring.entropy(row.epoch)) continue
707
+ if (this.keyring.entropy(row.stamp)) continue
680
708
  await this._onEpoch(row)
681
709
  }
682
710
  } catch {
@@ -735,31 +763,33 @@ export class Database extends ReadyResource {
735
763
  }
736
764
 
737
765
  /**
738
- * Batch every write performed inside `fn` into a single autobee append.
739
- * Nested calls reuse the outer queue.
766
+ * Batch every write made through the transaction handle passed to `fn`
767
+ * into a single autobee append. The handle is this database with its own
768
+ * write queue — same surface (`put`/`set`/`del`/`call`), but writes buffer
769
+ * until `fn` returns, and are discarded if it throws. Writes made on the
770
+ * database itself meanwhile append normally: an unrelated concurrent write
771
+ * can never be swallowed into (and rolled back with) a transaction.
772
+ * Nested `tx()` on the handle joins the same batch.
740
773
  *
741
774
  * @template T
742
- * @param {() => Promise<T> | T} fn
775
+ * @param {(tx: Database) => Promise<T> | T} fn
743
776
  * @returns {Promise<T>}
744
777
  */
745
778
  async tx(fn) {
746
- // Nested tx() (called while an outer tx's fn is running) joins the outer queue.
747
- if (this.txQueue) return fn()
748
- // Outer transactions serialize the run is deferred onto a chain so two
749
- // concurrent tx() calls each get their own queue instead of the second
750
- // buffering into the first's (and being dropped if the first rolls back).
779
+ // The old form batched ambiently writes on the database itself would
780
+ // silently lose atomicity now, so refuse a fn that ignores the handle.
781
+ if (typeof fn !== 'function' || fn.length < 1) {
782
+ throw CeroError.INVALID('tx(fn) fn must accept the transaction handle: tx((tx) => ...)')
783
+ }
784
+ // Nested tx() (called on a transaction handle) joins the same batch.
785
+ if (this.txQueue) return fn(this)
786
+ // Outer transactions serialize so their appends land in call order.
751
787
  const run = async () => {
752
- const queue = []
753
- this.txQueue = queue
754
- try {
755
- const result = await fn()
756
- this.txQueue = null
757
- if (queue.length) await this.write(queue)
758
- return result
759
- } catch (err) {
760
- this.txQueue = null
761
- throw err
762
- }
788
+ const batch = Object.create(this)
789
+ batch.txQueue = []
790
+ const result = await fn(batch)
791
+ if (batch.txQueue.length) await this.write(batch.txQueue)
792
+ return result
763
793
  }
764
794
  const prev = this._txChain || Promise.resolve()
765
795
  // chain past prev's outcome so one failure doesn't wedge later transactions
@@ -1139,7 +1169,7 @@ export class Database extends ReadyResource {
1139
1169
  matchIndex(name, query) {
1140
1170
  const indexes = this.refs[name]?.indexes
1141
1171
  if (!indexes || !query) return null
1142
- const fields = Object.keys(query).filter((k) => !INDEX_RESERVED.has(k))
1172
+ const fields = Object.keys(query).filter((k) => !QUERY_RESERVED.has(k))
1143
1173
  if (fields.length === 0) return null
1144
1174
  for (const [idx, idxFields] of Object.entries(indexes)) {
1145
1175
  if (idxFields.length === fields.length && idxFields.every((f) => fields.includes(f))) {
@@ -1182,17 +1212,6 @@ export class Database extends ReadyResource {
1182
1212
  }
1183
1213
  }
1184
1214
 
1185
- const INDEX_RESERVED = new Set([
1186
- 'gt',
1187
- 'gte',
1188
- 'lt',
1189
- 'lte',
1190
- 'limit',
1191
- 'reverse',
1192
- 'search',
1193
- 'fields',
1194
- 'total'
1195
- ])
1196
1215
  // Fields the write path stamps itself — always allowed even if not user-declared.
1197
1216
  const SYSTEM_FIELDS = new Set(['id', 'memberId', 'index', 'createdAt', 'updatedAt'])
1198
1217
 
@@ -1232,7 +1251,7 @@ function paginate(rows, query) {
1232
1251
  // index is an optimization, not a correctness gate — so an unindexed field, or
1233
1252
  // one passed alongside search/range, still filters instead of silently dropping.
1234
1253
  for (const key of Object.keys(query)) {
1235
- if (INDEX_RESERVED.has(key)) continue
1254
+ if (QUERY_RESERVED.has(key)) continue
1236
1255
  out = out.filter((r) => valueEq(r[key], query[key]))
1237
1256
  }
1238
1257
  if (query.gt !== undefined) out = out.filter((r) => r.id > query.gt)
@@ -1244,22 +1263,3 @@ function paginate(rows, query) {
1244
1263
  if (query.limit !== undefined) out = out.slice(0, query.limit)
1245
1264
  return out
1246
1265
  }
1247
-
1248
- // Lowercase, fold diacritics ('jose' → 'José'), drop whitespace.
1249
- const fold = (s) =>
1250
- s
1251
- .normalize('NFD')
1252
- .replace(/\p{Diacritic}/gu, '')
1253
- .toLowerCase()
1254
- .replace(/\s/g, '')
1255
-
1256
- // keet's message-search style: every term must be a substring of `fields` (or
1257
- // every string field). 'smith' → 'John Smith', 'jo sm' → both must appear.
1258
- function searchHit(row, term, fields) {
1259
- const terms = String(term).split(/\s+/).map(fold).filter(Boolean)
1260
- // default to all fields except memberId — a framework-stamped random z32 whose value would
1261
- // otherwise produce spurious, non-deterministic search hits (user-set id/code stay searchable)
1262
- const keys = fields && fields.length ? fields : Object.keys(row).filter((k) => k !== 'memberId')
1263
- const hay = keys.map((k) => (typeof row[k] === 'string' ? fold(row[k]) : '')).join(' ')
1264
- return terms.every((t) => hay.includes(t))
1265
- }
@@ -40,6 +40,20 @@ export const RANK = { [OWNER]: 3, [ADMIN]: 2, [MEMBER]: 1, [READER]: 0 }
40
40
  // Identity / hypercore namespace
41
41
  export const NAMESPACE = 'cero'
42
42
 
43
+ // Query-grammar keys — everything else in a query object is an equality field.
44
+ // One set shared by both backends (Database and Storage) so they cannot drift.
45
+ export const QUERY_RESERVED = new Set([
46
+ 'gt',
47
+ 'gte',
48
+ 'lt',
49
+ 'lte',
50
+ 'limit',
51
+ 'reverse',
52
+ 'search',
53
+ 'fields',
54
+ 'total'
55
+ ])
56
+
43
57
  // Internal counter collection (assigns monotonic `index` to every row in every collection)
44
58
  export const COUNTERS = 'counters'
45
59
  // Internal key-rotation epoch collection (rotation announcements with sealed envelopes)
package/src/lib/utils.js CHANGED
@@ -115,3 +115,22 @@ export function subscribe({ get, watch }) {
115
115
  push()
116
116
  return stream
117
117
  }
118
+
119
+ // Lowercase, fold diacritics ('jose' → 'José'), drop whitespace.
120
+ const fold = (s) =>
121
+ s
122
+ .normalize('NFD')
123
+ .replace(/\p{Diacritic}/gu, '')
124
+ .toLowerCase()
125
+ .replace(/\s/g, '')
126
+
127
+ // keet's message-search style: every term must be a substring of `fields` (or
128
+ // every string field). 'smith' → 'John Smith', 'jo sm' → both must appear.
129
+ export function searchHit(row, term, fields) {
130
+ const terms = String(term).split(/\s+/).map(fold).filter(Boolean)
131
+ // default to all fields except memberId — a framework-stamped random z32 whose value would
132
+ // otherwise produce spurious, non-deterministic search hits (user-set id/code stay searchable)
133
+ const keys = fields && fields.length ? fields : Object.keys(row).filter((k) => k !== 'memberId')
134
+ const hay = keys.map((k) => (typeof row[k] === 'string' ? fold(row[k]) : '')).join(' ')
135
+ return terms.every((t) => hay.includes(t))
136
+ }
@@ -1,18 +1,22 @@
1
1
  import NoiseSecretStream from '@hyperswarm/secret-stream'
2
+ import Autobee from 'autobee'
2
3
  import BlindPairing from 'blind-pairing'
3
4
  import BlindPeering from 'blind-peering'
4
5
  import ProtomuxWakeup from 'protomux-wakeup'
5
6
  import ReadyResource from 'ready-resource'
6
7
  import safetyCatch from 'safety-catch'
7
8
  import { decode as decodeKey } from 'hypercore-id-encoding'
9
+ import { hash } from 'hypercore-crypto'
10
+ import Hyperswarm from 'hyperswarm'
8
11
  import b4a from 'b4a'
9
12
 
10
13
  import { ACTIVE, PASSIVE } from '../lib/constants.js'
11
14
  import { CeroError } from '../lib/errors.js'
12
15
  import { Discovery } from './discovery.js'
13
- import { DHTTransport, channelTopic } from './transports/dht.js'
14
16
 
15
- export { channelTopic }
17
+ export function channelTopic(topic, channel) {
18
+ return channel ? hash([topic, b4a.from(channel)]) : topic
19
+ }
16
20
 
17
21
  /**
18
22
  * @typedef {object} NetworkOpts
@@ -43,7 +47,7 @@ export class Network extends ReadyResource {
43
47
  this.store = store || null
44
48
  this.mirrors = (mirrors || []).map((k) => (typeof k === 'string' ? decodeKey(k) : k))
45
49
 
46
- this._dht = null
50
+ this._swarm = null
47
51
  this.wakeup = new ProtomuxWakeup()
48
52
 
49
53
  this._replicateables = new Set()
@@ -55,7 +59,7 @@ export class Network extends ReadyResource {
55
59
 
56
60
  /** @returns {any} The underlying hyperswarm, or null before ready / after close. */
57
61
  get swarm() {
58
- return this._dht ? this._dht.swarm : null
62
+ return this._swarm
59
63
  }
60
64
 
61
65
  /**
@@ -108,6 +112,7 @@ export class Network extends ReadyResource {
108
112
  * @returns {Promise<any>}
109
113
  */
110
114
  async blind() {
115
+ if (this.closing || this.closed) throw CeroError.CLOSED('Network')
111
116
  if (!this._blind) {
112
117
  const blind = new BlindPairing(this.swarm)
113
118
  this._blind = blind.ready().then(() => {
@@ -156,17 +161,15 @@ export class Network extends ReadyResource {
156
161
  }
157
162
 
158
163
  async _open() {
159
- this._dht = new DHTTransport({
160
- identity: this.identity,
161
- bootstrap: this.bootstrap,
162
- firewall: this.firewall,
163
- relayThrough: this.relayThrough,
164
- channel: this.channel
165
- })
164
+ const opts = {}
165
+ if (this.identity) {
166
+ opts.keyPair = { publicKey: this.identity.publicKey, secretKey: this.identity.secretKey }
167
+ }
168
+ if (this.bootstrap) opts.bootstrap = this.bootstrap
169
+ if (this.firewall) opts.firewall = this.firewall
170
+ if (this.relayThrough) opts.relayThrough = this.relayThrough
166
171
 
167
- // DHTTransport owns the swarm's lifecycle; Network subscribes to its events
168
- // because the handlers touch Network state (wakeup, replicateables, emit).
169
- const swarm = this._dht.swarm
172
+ const swarm = (this._swarm = new Hyperswarm(opts))
170
173
  swarm.on('connection', (stream, info) => {
171
174
  if (this.closing || this.closed) {
172
175
  stream.destroy()
@@ -176,12 +179,10 @@ export class Network extends ReadyResource {
176
179
  for (const r of this._replicateables) replicateInto(r, stream)
177
180
  this.emit('connection', stream, info)
178
181
  })
179
- swarm.on('peer-add', (peer) => this.emit('peer-add', peer))
180
- swarm.on('peer-remove', (peer) => this.emit('peer-remove', peer))
181
182
 
182
183
  if (this.store && this.mirrors.length) {
183
184
  this._blindPeering = new BlindPeering(swarm.dht, this.store, {
184
- keys: this.mirrors,
185
+ blindPeers: this.mirrors.map((key) => ({ key })),
185
186
  wakeup: this.wakeup,
186
187
  pick: 2
187
188
  })
@@ -194,8 +195,9 @@ export class Network extends ReadyResource {
194
195
  * @param {{ timeout?: number }} [opts]
195
196
  * @returns {Promise<void>}
196
197
  */
197
- async flush(opts) {
198
- await this._dht?.flush(opts)
198
+ async flush({ timeout = 500 } = {}) {
199
+ if (!this._swarm) return
200
+ await Promise.race([this._swarm.flush(), new Promise((r) => setTimeout(r, timeout))])
199
201
  }
200
202
 
201
203
  /**
@@ -206,7 +208,11 @@ export class Network extends ReadyResource {
206
208
  async suspend() {
207
209
  if (this.closing || this.closed) return
208
210
  await this._blindPeering?.suspend()
209
- await this._dht?.suspend()
211
+ try {
212
+ await this._swarm?.suspend()
213
+ } catch (err) {
214
+ safetyCatch(err)
215
+ }
210
216
  }
211
217
 
212
218
  /**
@@ -216,7 +222,11 @@ export class Network extends ReadyResource {
216
222
  */
217
223
  async resume() {
218
224
  if (this.closing || this.closed) return
219
- await this._dht?.resume()
225
+ try {
226
+ await this._swarm?.resume()
227
+ } catch (err) {
228
+ safetyCatch(err)
229
+ }
220
230
  await this._blindPeering?.resume()
221
231
  }
222
232
 
@@ -230,12 +240,24 @@ export class Network extends ReadyResource {
230
240
  }
231
241
  this._injected.clear()
232
242
 
233
- for (const d of [...this._discoveries]) {
234
- try {
235
- await d.destroy()
236
- } catch (err) {
237
- safetyCatch(err)
238
- }
243
+ // stop every discovery session NOW (timers die synchronously) but never
244
+ // await the DHT unannounce round-trips — the force destroy below skips
245
+ // upstream's own clear(), so un-destroyed sessions would leak live timers
246
+ for (const d of [...this._discoveries]) d.destroy().catch(safetyCatch)
247
+ this._discoveries.clear()
248
+
249
+ // the swarm dies FIRST, with force: every polite per-topic DHT unannounce
250
+ // (blind-pairing's included) becomes an instant no-op instead of a network
251
+ // round-trip — a vanished node ages out of the DHT regardless
252
+ const swarm = this._swarm
253
+ this._swarm = null
254
+ if (swarm) {
255
+ // even force destroy awaits a graceful server.close DHT round-trip
256
+ // upstream — cap the wait and let the teardown finish in the background
257
+ await Promise.race([
258
+ swarm.destroy({ force: true }).catch(safetyCatch),
259
+ new Promise((r) => setTimeout(r, 750))
260
+ ])
239
261
  }
240
262
 
241
263
  if (this._blindPeering) {
@@ -256,15 +278,6 @@ export class Network extends ReadyResource {
256
278
  this._blind = null
257
279
  }
258
280
 
259
- if (this._dht) {
260
- try {
261
- await this._dht.destroy()
262
- } catch (err) {
263
- safetyCatch(err)
264
- }
265
- this._dht = null
266
- }
267
-
268
281
  if (this.wakeup) {
269
282
  try {
270
283
  await this.wakeup.destroy()
@@ -289,10 +302,10 @@ export class Network extends ReadyResource {
289
302
  if (!this.swarm) throw CeroError.NOT_READY('Network', 'network')
290
303
  if (!isTopic(topic)) throw CeroError.INVALID('topic must be a 32-byte buffer')
291
304
 
292
- const session =
293
- mode === ACTIVE
294
- ? this.swarm.join(topic, { client: true, server: true })
295
- : this.swarm.join(topic, { client: false, server: true })
305
+ const session = this.swarm.join(channelTopic(topic, this.channel), {
306
+ client: mode === ACTIVE,
307
+ server: true
308
+ })
296
309
 
297
310
  const discovery = new Discovery(this, session, mode)
298
311
  this._discoveries.add(discovery)
@@ -313,7 +326,7 @@ export class Network extends ReadyResource {
313
326
  // mirror the core so it stays available when its writers are offline —
314
327
  // autobees announce their whole writer set, plain cores (blobs) just themselves
315
328
  if (this._blindPeering) {
316
- if (core.wakeupCapability) this._blindPeering.addAutobaseBackground(core)
329
+ if (Autobee.isAutobee(core)) this._blindPeering.addAutobaseBackground(core)
317
330
  else this._blindPeering.addCoreBackground(core)
318
331
  }
319
332
  }
@@ -6,6 +6,7 @@ import c from 'compact-encoding'
6
6
  import { discoveryKey, keyPair, sign } from 'hypercore-crypto'
7
7
 
8
8
  import { Invite } from './invite.js'
9
+ import { channelTopic } from '../network/index.js'
9
10
  import { getEncoding } from '../lib/spec/index.js'
10
11
  import { CeroError } from '../lib/errors.js'
11
12
 
@@ -103,7 +104,8 @@ export class Pairing extends ReadyResource {
103
104
  // identity throw 'Active member already exist'
104
105
  if (this.host) {
105
106
  this._member = this._blind.addMember({
106
- discoveryKey: discoveryKey(this.topic),
107
+ // channel-scoped; the candidate hashes identically (no channel → identity)
108
+ discoveryKey: channelTopic(discoveryKey(this.topic), this.network.channel),
107
109
  onadd: (req) => this._onCandidate(req).catch(this._onerror)
108
110
  })
109
111
  }
@@ -486,6 +488,8 @@ class Candidate {
486
488
  this._candidate = this.pairing._blind.addCandidate({
487
489
  invite: this.invite.blind,
488
490
  userData: this.userData,
491
+ // must hash exactly like the member side — see Pairing._open
492
+ discoveryKey: channelTopic(this.invite.discoveryKey, this.pairing.network.channel),
489
493
  onadd: (result) => this._done(result)
490
494
  })
491
495
  this._candidate.request.on('rejected', (err) => this._fail(fromBlindError(err)))
@@ -560,7 +564,9 @@ function decodeUserData(buf, encoding) {
560
564
  }
561
565
 
562
566
  function fromBlindError(err) {
563
- if (!err || !err.code) return CeroError.DENIED(err?.message || null)
567
+ // code-less rejections are protocol failures (undecodable reply, key
568
+ // mismatch, bad signature) — never a host decision, don't report DENIED
569
+ if (!err || !err.code) return CeroError.NETWORK_ERROR(err?.message || 'pairing failed')
564
570
  switch (err.code) {
565
571
  case 'INVITE_EXPIRED':
566
572
  return CeroError.EXPIRED(err.message)
package/src/rpc/peer.js CHANGED
@@ -25,8 +25,11 @@ export class RPCPeer extends ReadyResource {
25
25
  this.ipc = ipc
26
26
  this.spec = spec
27
27
  this.framed = new FramedStream(ipc)
28
- this.framed.pause()
29
28
  this.rpc = new spec.rpc(this.framed)
29
+ // pause AFTER hrpc attaches its 'data' listener — streamx re-resumes a
30
+ // paused stream the moment a listener attaches, so pausing first holds
31
+ // nothing and early frames would dispatch before the codec is bound
32
+ this.framed.pause()
30
33
  }
31
34
 
32
35
  async _open() {
@@ -5,11 +5,19 @@ import Corestore from 'corestore'
5
5
  import HyperDB from 'hyperdb'
6
6
  import ReadyResource from 'ready-resource'
7
7
 
8
- import { ROCKS, BEE, SINGLE, COLLECTION } from '../lib/constants.js'
9
- import { genId, subscribe } from '../lib/utils.js'
8
+ import { ROCKS, BEE, SINGLE, COLLECTION, QUERY_RESERVED } from '../lib/constants.js'
9
+ import { genId, subscribe, searchHit } from '../lib/utils.js'
10
10
  import { CeroError } from '../lib/errors.js'
11
11
 
12
- const RANGE_OPS = new Set(['gt', 'gte', 'lt', 'lte', 'reverse', 'limit'])
12
+ // The subset of the query grammar hyperdb itself honors (pushed down);
13
+ // the rest of QUERY_RESERVED is applied in memory here.
14
+ const RANGE_OPS = ['gt', 'gte', 'lt', 'lte', 'reverse', 'limit']
15
+
16
+ const pick = (query, keys) => {
17
+ const out = {}
18
+ if (query) for (const k of keys) if (query[k] !== undefined) out[k] = query[k]
19
+ return out
20
+ }
13
21
 
14
22
  /**
15
23
  * @typedef {object} StorageOpts
@@ -204,17 +212,18 @@ export class Storage extends ReadyResource {
204
212
  return { data: (await this.db.get(col, { id: query })) ?? null }
205
213
  }
206
214
 
207
- // hyperdb only honors range/limit operators — equality fields must be
208
- // filtered here or they are silently ignored and every row comes back
209
- const eq = Object.keys(query || {}).filter((k) => !RANGE_OPS.has(k))
210
- if (eq.length) {
215
+ // hyperdb only honors range/limit operators — equality fields and search
216
+ // must be applied here or they are silently ignored and every row comes back
217
+ const eq = Object.keys(query || {}).filter((k) => !QUERY_RESERVED.has(k))
218
+ if (eq.length || query?.search) {
211
219
  const rows = await this.db.find(col, {}).toArray()
212
- const matched = rows.filter((r) => eq.every((k) => r[k] === query[k]))
220
+ let matched = rows.filter((r) => eq.every((k) => r[k] === query[k]))
221
+ if (query.search) matched = matched.filter((r) => searchHit(r, query.search, query.fields))
213
222
  const data = query.limit != null ? matched.slice(0, query.limit) : matched
214
223
  return { data, total: matched.length, size: data.length }
215
224
  }
216
225
 
217
- const data = await this.db.find(col, query || {}).toArray()
226
+ const data = await this.db.find(col, pick(query, RANGE_OPS)).toArray()
218
227
  // a limit caps `data`, so `total` needs a full count; with no query
219
228
  // `data` is already everything — skip the second scan.
220
229
  const filtered = query && Object.keys(query).length > 0
@@ -233,9 +242,11 @@ export class Storage extends ReadyResource {
233
242
  this._guard()
234
243
  const ref = this._ref(name)
235
244
  const col = this._col(ref)
236
- const eq = Object.keys(query || {}).filter((k) => !RANGE_OPS.has(k))
237
- const rows = await this.db.find(col, eq.length ? {} : query || {}).toArray()
238
- const matched = eq.length ? rows.filter((r) => eq.every((k) => r[k] === query[k])) : rows
245
+ const eq = Object.keys(query || {}).filter((k) => !QUERY_RESERVED.has(k))
246
+ const filtered = eq.length || query?.search
247
+ const rows = await this.db.find(col, filtered ? {} : pick(query, RANGE_OPS)).toArray()
248
+ let matched = filtered ? rows.filter((r) => eq.every((k) => r[k] === query[k])) : rows
249
+ if (query?.search) matched = matched.filter((r) => searchHit(r, query.search, query.fields))
239
250
  return { data: matched.length }
240
251
  }
241
252
 
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Pull-driven delta stream over a collection: batches of `{ prev, next }`
3
- * pairs diffed between per-stream snapshot cursors. Update ticks only mark
4
- * the stream dirty — the diff runs when the reader demands, so backpressure
5
- * folds bursts into one bigger batch and nothing is ever dropped. The first
6
- * batch, and any batch after the view is swapped or fast-forwarded to a new
7
- * core, carries the full matching state as inserts with `reset: true`.
3
+ * pairs diffed between per-stream head cursors (`db.diff(col, { from })`).
4
+ * Update ticks only mark the stream dirty — the diff runs when the reader
5
+ * demands, so backpressure folds bursts into one bigger batch and nothing is
6
+ * ever dropped. The first batch, and any batch after the view is swapped or
7
+ * fast-forwarded to a new core, carries the full matching state as inserts
8
+ * with `reset: true`.
8
9
  *
9
10
  * @param {import('./index.js').Database} db
10
11
  * @param {string} name Ref name (scopes the update ticks).
@@ -7,6 +7,8 @@
7
7
  * @returns {Uint8Array}
8
8
  */
9
9
  export function blobEpochKey(entropy: Uint8Array): Uint8Array;
10
+ /** Prime a keyring from a local core's persisted epoch stash. */
11
+ export function loadEpochs(keyring: any, local: any): Promise<void>;
10
12
  /**
11
13
  * Wire codec for a rotation announcement's envelope list — one sealed box
12
14
  * per remaining member, addressed by member id.
@@ -46,7 +48,6 @@ export class Keyring {
46
48
  seq: number;
47
49
  /** bumped on every add/remove — cheap change detection for retries */
48
50
  version: number;
49
- primed: boolean;
50
51
  /** @returns {Array<{ epoch: number, stamp: number, entropy: Uint8Array }>} ascending by seq */
51
52
  all(): Array<{
52
53
  epoch: number;
@@ -86,12 +87,12 @@ export class EpochEncryption {
86
87
  export class EpochAutobee {
87
88
  constructor(store: any, key: any, handlers?: {});
88
89
  keyring: any;
90
+ _epochStalled: Set<any>;
89
91
  _epochRetry: any;
90
92
  _epochRetryDelay: number;
91
93
  _epochRetrySeen: number;
92
- _bootState(): Promise<void>;
93
94
  _bumpPendingWriters(): Promise<any>;
94
- _parkOnUnknownEpoch(w: any): void;
95
+ _applyWakeupHints(): Promise<any>;
95
96
  _scheduleEpochRetry(): void;
96
97
  _close(): Promise<any>;
97
98
  }
@@ -90,7 +90,7 @@ export class Database extends ReadyResource {
90
90
  onApplyHooks: Set<any>;
91
91
  _applySeq: number;
92
92
  _touched: Set<any>;
93
- txQueue: any[];
93
+ txQueue: any;
94
94
  _discovery: import("../network/discovery.js").Discovery;
95
95
  /** Open the underlying autobee, wire dispatcher + apply, attach to network. */
96
96
  openBee(): Promise<void>;
@@ -258,14 +258,19 @@ export class Database extends ReadyResource {
258
258
  */
259
259
  _healEpochs(): Promise<void>;
260
260
  /**
261
- * Batch every write performed inside `fn` into a single autobee append.
262
- * Nested calls reuse the outer queue.
261
+ * Batch every write made through the transaction handle passed to `fn`
262
+ * into a single autobee append. The handle is this database with its own
263
+ * write queue — same surface (`put`/`set`/`del`/`call`), but writes buffer
264
+ * until `fn` returns, and are discarded if it throws. Writes made on the
265
+ * database itself meanwhile append normally: an unrelated concurrent write
266
+ * can never be swallowed into (and rolled back with) a transaction.
267
+ * Nested `tx()` on the handle joins the same batch.
263
268
  *
264
269
  * @template T
265
- * @param {() => Promise<T> | T} fn
270
+ * @param {(tx: Database) => Promise<T> | T} fn
266
271
  * @returns {Promise<T>}
267
272
  */
268
- tx<T>(fn: () => Promise<T> | T): Promise<T>;
273
+ tx<T>(fn: (tx: Database) => Promise<T> | T): Promise<T>;
269
274
  _txChain: any;
270
275
  /**
271
276
  * Encode and append dispatch ops. Buffers into the active `tx` queue if one
@@ -33,5 +33,6 @@ export namespace RANK {
33
33
  export { reader_1 as reader };
34
34
  }
35
35
  export const NAMESPACE: "cero";
36
+ export const QUERY_RESERVED: Set<string>;
36
37
  export const COUNTERS: "counters";
37
38
  export const EPOCHS: "epochs";
@@ -27,6 +27,7 @@ export function subscribe<T>({ get, watch }: {
27
27
  get: () => Promise<T> | T;
28
28
  watch: (fn: () => void) => (() => void) | void;
29
29
  }): import("streamx").Readable<T>;
30
+ export function searchHit(row: any, term: any, fields: any): boolean;
30
31
  /**
31
32
  * z32-encode a 32-byte key into a canonical string id.
32
33
  *
@@ -1,4 +1,4 @@
1
- export { channelTopic };
1
+ export function channelTopic(topic: any, channel: any): any;
2
2
  /**
3
3
  * @typedef {object} NetworkOpts
4
4
  * @property {import('../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
@@ -28,7 +28,7 @@ export class Network extends ReadyResource {
28
28
  channel: string;
29
29
  store: any;
30
30
  mirrors: any[];
31
- _dht: DHTTransport;
31
+ _swarm: any;
32
32
  wakeup: any;
33
33
  _replicateables: Set<any>;
34
34
  _discoveries: Set<any>;
@@ -82,7 +82,7 @@ export class Network extends ReadyResource {
82
82
  * @param {{ timeout?: number }} [opts]
83
83
  * @returns {Promise<void>}
84
84
  */
85
- flush(opts?: {
85
+ flush({ timeout }?: {
86
86
  timeout?: number;
87
87
  }): Promise<void>;
88
88
  /**
@@ -168,7 +168,5 @@ export type NetworkOpts = {
168
168
  export type Replicable = {
169
169
  replicate: (stream: any) => any;
170
170
  };
171
- import { channelTopic } from './transports/dht.js';
172
171
  import ReadyResource from 'ready-resource';
173
- import { DHTTransport } from './transports/dht.js';
174
172
  import { Discovery } from './discovery.js';
@@ -1,110 +0,0 @@
1
- import Hyperswarm from 'hyperswarm'
2
- import b4a from 'b4a'
3
- import { hash } from 'hypercore-crypto'
4
- import safetyCatch from 'safety-catch'
5
-
6
- /**
7
- * @typedef {object} DHTTransportOpts
8
- * @property {import('../../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
9
- * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
10
- * @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
11
- * @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
12
- * @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
13
- */
14
-
15
- /**
16
- * The internet transport: a Hyperswarm that finds peers over the DHT. Owns the
17
- * swarm's whole lifecycle — construction (identity keyPair, bootstrap, firewall,
18
- * relay), the channel-topic join/leave wrapping, flush, suspend/resume, and
19
- * teardown. {@link Network} subscribes to `this.swarm`'s connection/peer events
20
- * and drives topic joins; the swarm-specific wiring lives here.
21
- */
22
- export class DHTTransport {
23
- /** @param {DHTTransportOpts} [opts] */
24
- constructor({ identity, bootstrap, firewall, relayThrough, channel } = {}) {
25
- const opts = {}
26
- if (identity) opts.keyPair = { publicKey: identity.publicKey, secretKey: identity.secretKey }
27
- if (bootstrap) opts.bootstrap = bootstrap
28
- if (firewall) opts.firewall = firewall
29
- if (relayThrough) opts.relayThrough = relayThrough
30
-
31
- this.swarm = new Hyperswarm(opts)
32
-
33
- if (channel) {
34
- const join = this.swarm.join.bind(this.swarm)
35
- const leave = this.swarm.leave.bind(this.swarm)
36
- this.swarm.join = (topic, opts) => join(channelTopic(topic, channel), opts)
37
- this.swarm.leave = (topic) => leave(channelTopic(topic, channel))
38
- }
39
- }
40
-
41
- /** @returns {boolean} */
42
- get suspended() {
43
- return this.swarm?.suspended === true
44
- }
45
-
46
- /**
47
- * Wait for pending DHT announces and lookups to settle, bounded by timeout.
48
- *
49
- * @param {{ timeout?: number }} [opts]
50
- * @returns {Promise<void>}
51
- */
52
- async flush({ timeout = 500 } = {}) {
53
- if (!this.swarm) return
54
- await Promise.race([this.swarm.flush(), new Promise((r) => setTimeout(r, timeout))])
55
- }
56
-
57
- /**
58
- * Pause the swarm — keeps state, drops sockets. Idempotent.
59
- *
60
- * @returns {Promise<void>}
61
- */
62
- async suspend() {
63
- if (!this.swarm || this.swarm.suspended) return
64
- try {
65
- await this.swarm.suspend()
66
- } catch (err) {
67
- safetyCatch(err)
68
- }
69
- }
70
-
71
- /**
72
- * Resume a suspended swarm. Idempotent.
73
- *
74
- * @returns {Promise<void>}
75
- */
76
- async resume() {
77
- if (!this.swarm || !this.swarm.suspended) return
78
- try {
79
- await this.swarm.resume()
80
- } catch (err) {
81
- safetyCatch(err)
82
- }
83
- }
84
-
85
- /**
86
- * Flush pending discovery, then tear down the swarm. Idempotent.
87
- *
88
- * @returns {Promise<void>}
89
- */
90
- async destroy() {
91
- if (!this.swarm) return
92
- try {
93
- await this.flush()
94
- } catch (err) {
95
- safetyCatch(err)
96
- }
97
- try {
98
- await this.swarm.destroy()
99
- } catch (err) {
100
- safetyCatch(err)
101
- }
102
- this.swarm = null
103
- }
104
- }
105
-
106
- // A channel re-namespaces every swarm topic so only same-channel peers meet.
107
- // No channel → identity (unchanged, back-compat).
108
- export function channelTopic(topic, channel) {
109
- return channel ? hash([topic, b4a.from(channel)]) : topic
110
- }
@@ -1,75 +0,0 @@
1
- export function channelTopic(topic: any, channel: any): any;
2
- /**
3
- * @typedef {object} DHTTransportOpts
4
- * @property {import('../../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
5
- * @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
6
- * @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
7
- * @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
8
- * @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
9
- */
10
- /**
11
- * The internet transport: a Hyperswarm that finds peers over the DHT. Owns the
12
- * swarm's whole lifecycle — construction (identity keyPair, bootstrap, firewall,
13
- * relay), the channel-topic join/leave wrapping, flush, suspend/resume, and
14
- * teardown. {@link Network} subscribes to `this.swarm`'s connection/peer events
15
- * and drives topic joins; the swarm-specific wiring lives here.
16
- */
17
- export class DHTTransport {
18
- /** @param {DHTTransportOpts} [opts] */
19
- constructor({ identity, bootstrap, firewall, relayThrough, channel }?: DHTTransportOpts);
20
- swarm: any;
21
- /** @returns {boolean} */
22
- get suspended(): boolean;
23
- /**
24
- * Wait for pending DHT announces and lookups to settle, bounded by timeout.
25
- *
26
- * @param {{ timeout?: number }} [opts]
27
- * @returns {Promise<void>}
28
- */
29
- flush({ timeout }?: {
30
- timeout?: number;
31
- }): Promise<void>;
32
- /**
33
- * Pause the swarm — keeps state, drops sockets. Idempotent.
34
- *
35
- * @returns {Promise<void>}
36
- */
37
- suspend(): Promise<void>;
38
- /**
39
- * Resume a suspended swarm. Idempotent.
40
- *
41
- * @returns {Promise<void>}
42
- */
43
- resume(): Promise<void>;
44
- /**
45
- * Flush pending discovery, then tear down the swarm. Idempotent.
46
- *
47
- * @returns {Promise<void>}
48
- */
49
- destroy(): Promise<void>;
50
- }
51
- export type DHTTransportOpts = {
52
- /**
53
- * Long-lived keypair used as the swarm identity.
54
- */
55
- identity?: import("../../identity/index.js").Identity;
56
- /**
57
- * Custom DHT bootstrap nodes.
58
- */
59
- bootstrap?: Array<{
60
- host: string;
61
- port: number;
62
- }>;
63
- /**
64
- * Incoming-connection filter.
65
- */
66
- firewall?: (remotePublicKey: Uint8Array, payload: any) => boolean;
67
- /**
68
- * Relay public keys to tunnel through.
69
- */
70
- relayThrough?: Uint8Array[];
71
- /**
72
- * Optional network-isolation label; only same-channel peers meet.
73
- */
74
- channel?: string;
75
- };