@cero-base/core 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 +2 -6
- package/src/database/bootstrap.js +6 -4
- package/src/database/changes.js +29 -55
- package/src/database/encryption.js +35 -46
- package/src/database/index.js +51 -55
- package/src/lib/constants.js +14 -0
- package/src/lib/utils.js +19 -0
- package/src/network/index.js +40 -29
- package/src/pairing/index.js +8 -2
- package/src/rpc/peer.js +4 -1
- package/src/storage/index.js +23 -12
- package/types/database/changes.d.ts +6 -5
- package/types/database/encryption.d.ts +4 -3
- package/types/database/index.d.ts +10 -5
- package/types/lib/constants.d.ts +1 -0
- package/types/lib/utils.d.ts +1 -0
- package/types/network/index.d.ts +3 -5
- package/src/network/transports/dht.js +0 -110
- package/types/network/transports/dht.d.ts +0 -75
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
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,7 +136,7 @@
|
|
|
140
136
|
},
|
|
141
137
|
"dependencies": {
|
|
142
138
|
"@hyperswarm/secret-stream": "^6.9.1",
|
|
143
|
-
"autobee": "2.0.0-rc.
|
|
139
|
+
"autobee": "2.0.0-rc.21",
|
|
144
140
|
"autobee-encryption": "0.1.3",
|
|
145
141
|
"b4a": "^1.8.1",
|
|
146
142
|
"bare-crypto": "^1.15.3",
|
|
@@ -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
|
|
package/src/database/changes.js
CHANGED
|
@@ -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
|
|
12
|
-
* the stream dirty — the diff runs when the reader
|
|
13
|
-
* folds bursts into one bigger batch and nothing is
|
|
14
|
-
* batch, and any batch after the view is swapped or
|
|
15
|
-
* core, carries the full matching state as inserts
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
266
|
-
//
|
|
267
|
-
// backoff (reset when the keyring advances) so a removed member,
|
|
268
|
-
// never learn the epoch, settles into a slow idle poll
|
|
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 (
|
|
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
|
}
|
package/src/database/index.js
CHANGED
|
@@ -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 {
|
|
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'
|
|
@@ -133,6 +143,20 @@ export class Database extends ReadyResource {
|
|
|
133
143
|
|
|
134
144
|
/** Open the underlying autobee, wire dispatcher + apply, attach to network. */
|
|
135
145
|
async openBee() {
|
|
146
|
+
// prime the keyring from the local core's epoch stash BEFORE boot — the
|
|
147
|
+
// bee's own boot reads post-rotation state through the epoch provider
|
|
148
|
+
const local = this.store.namespace(this.namespace).get({
|
|
149
|
+
keyPair: this.keyPair,
|
|
150
|
+
manifest: {
|
|
151
|
+
version: this.store.manifestVersion,
|
|
152
|
+
signers: [{ publicKey: this.keyPair.publicKey }]
|
|
153
|
+
},
|
|
154
|
+
active: false
|
|
155
|
+
})
|
|
156
|
+
await local.ready()
|
|
157
|
+
await loadEpochs(this.keyring, local)
|
|
158
|
+
await local.close()
|
|
159
|
+
|
|
136
160
|
this.dispatcher = makeDispatcher(
|
|
137
161
|
this.spec,
|
|
138
162
|
this.ns,
|
|
@@ -676,7 +700,7 @@ export class Database extends ReadyResource {
|
|
|
676
700
|
try {
|
|
677
701
|
const rows = await this.view.find(`@${this.ns}/epochs`, {}).toArray()
|
|
678
702
|
for (const row of rows) {
|
|
679
|
-
if (this.keyring.entropy(row.
|
|
703
|
+
if (this.keyring.entropy(row.stamp)) continue
|
|
680
704
|
await this._onEpoch(row)
|
|
681
705
|
}
|
|
682
706
|
} catch {
|
|
@@ -735,31 +759,33 @@ export class Database extends ReadyResource {
|
|
|
735
759
|
}
|
|
736
760
|
|
|
737
761
|
/**
|
|
738
|
-
* Batch every write
|
|
739
|
-
*
|
|
762
|
+
* Batch every write made through the transaction handle passed to `fn`
|
|
763
|
+
* into a single autobee append. The handle is this database with its own
|
|
764
|
+
* write queue — same surface (`put`/`set`/`del`/`call`), but writes buffer
|
|
765
|
+
* until `fn` returns, and are discarded if it throws. Writes made on the
|
|
766
|
+
* database itself meanwhile append normally: an unrelated concurrent write
|
|
767
|
+
* can never be swallowed into (and rolled back with) a transaction.
|
|
768
|
+
* Nested `tx()` on the handle joins the same batch.
|
|
740
769
|
*
|
|
741
770
|
* @template T
|
|
742
|
-
* @param {() => Promise<T> | T} fn
|
|
771
|
+
* @param {(tx: Database) => Promise<T> | T} fn
|
|
743
772
|
* @returns {Promise<T>}
|
|
744
773
|
*/
|
|
745
774
|
async tx(fn) {
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
775
|
+
// The old form batched ambiently — writes on the database itself would
|
|
776
|
+
// silently lose atomicity now, so refuse a fn that ignores the handle.
|
|
777
|
+
if (typeof fn !== 'function' || fn.length < 1) {
|
|
778
|
+
throw CeroError.INVALID('tx(fn) — fn must accept the transaction handle: tx((tx) => ...)')
|
|
779
|
+
}
|
|
780
|
+
// Nested tx() (called on a transaction handle) joins the same batch.
|
|
781
|
+
if (this.txQueue) return fn(this)
|
|
782
|
+
// Outer transactions serialize so their appends land in call order.
|
|
751
783
|
const run = async () => {
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
if (queue.length) await this.write(queue)
|
|
758
|
-
return result
|
|
759
|
-
} catch (err) {
|
|
760
|
-
this.txQueue = null
|
|
761
|
-
throw err
|
|
762
|
-
}
|
|
784
|
+
const batch = Object.create(this)
|
|
785
|
+
batch.txQueue = []
|
|
786
|
+
const result = await fn(batch)
|
|
787
|
+
if (batch.txQueue.length) await this.write(batch.txQueue)
|
|
788
|
+
return result
|
|
763
789
|
}
|
|
764
790
|
const prev = this._txChain || Promise.resolve()
|
|
765
791
|
// chain past prev's outcome so one failure doesn't wedge later transactions
|
|
@@ -1139,7 +1165,7 @@ export class Database extends ReadyResource {
|
|
|
1139
1165
|
matchIndex(name, query) {
|
|
1140
1166
|
const indexes = this.refs[name]?.indexes
|
|
1141
1167
|
if (!indexes || !query) return null
|
|
1142
|
-
const fields = Object.keys(query).filter((k) => !
|
|
1168
|
+
const fields = Object.keys(query).filter((k) => !QUERY_RESERVED.has(k))
|
|
1143
1169
|
if (fields.length === 0) return null
|
|
1144
1170
|
for (const [idx, idxFields] of Object.entries(indexes)) {
|
|
1145
1171
|
if (idxFields.length === fields.length && idxFields.every((f) => fields.includes(f))) {
|
|
@@ -1182,17 +1208,6 @@ export class Database extends ReadyResource {
|
|
|
1182
1208
|
}
|
|
1183
1209
|
}
|
|
1184
1210
|
|
|
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
1211
|
// Fields the write path stamps itself — always allowed even if not user-declared.
|
|
1197
1212
|
const SYSTEM_FIELDS = new Set(['id', 'memberId', 'index', 'createdAt', 'updatedAt'])
|
|
1198
1213
|
|
|
@@ -1232,7 +1247,7 @@ function paginate(rows, query) {
|
|
|
1232
1247
|
// index is an optimization, not a correctness gate — so an unindexed field, or
|
|
1233
1248
|
// one passed alongside search/range, still filters instead of silently dropping.
|
|
1234
1249
|
for (const key of Object.keys(query)) {
|
|
1235
|
-
if (
|
|
1250
|
+
if (QUERY_RESERVED.has(key)) continue
|
|
1236
1251
|
out = out.filter((r) => valueEq(r[key], query[key]))
|
|
1237
1252
|
}
|
|
1238
1253
|
if (query.gt !== undefined) out = out.filter((r) => r.id > query.gt)
|
|
@@ -1244,22 +1259,3 @@ function paginate(rows, query) {
|
|
|
1244
1259
|
if (query.limit !== undefined) out = out.slice(0, query.limit)
|
|
1245
1260
|
return out
|
|
1246
1261
|
}
|
|
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
|
-
}
|
package/src/lib/constants.js
CHANGED
|
@@ -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
|
+
}
|
package/src/network/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
+
try {
|
|
226
|
+
await this._swarm?.resume()
|
|
227
|
+
} catch (err) {
|
|
228
|
+
safetyCatch(err)
|
|
229
|
+
}
|
|
220
230
|
await this._blindPeering?.resume()
|
|
221
231
|
}
|
|
222
232
|
|
|
@@ -256,13 +266,14 @@ export class Network extends ReadyResource {
|
|
|
256
266
|
this._blind = null
|
|
257
267
|
}
|
|
258
268
|
|
|
259
|
-
if (this.
|
|
269
|
+
if (this._swarm) {
|
|
260
270
|
try {
|
|
261
|
-
await this.
|
|
271
|
+
await this.flush()
|
|
272
|
+
await this._swarm.destroy()
|
|
262
273
|
} catch (err) {
|
|
263
274
|
safetyCatch(err)
|
|
264
275
|
}
|
|
265
|
-
this.
|
|
276
|
+
this._swarm = null
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
if (this.wakeup) {
|
|
@@ -289,10 +300,10 @@ export class Network extends ReadyResource {
|
|
|
289
300
|
if (!this.swarm) throw CeroError.NOT_READY('Network', 'network')
|
|
290
301
|
if (!isTopic(topic)) throw CeroError.INVALID('topic must be a 32-byte buffer')
|
|
291
302
|
|
|
292
|
-
const session =
|
|
293
|
-
mode === ACTIVE
|
|
294
|
-
|
|
295
|
-
|
|
303
|
+
const session = this.swarm.join(channelTopic(topic, this.channel), {
|
|
304
|
+
client: mode === ACTIVE,
|
|
305
|
+
server: true
|
|
306
|
+
})
|
|
296
307
|
|
|
297
308
|
const discovery = new Discovery(this, session, mode)
|
|
298
309
|
this._discoveries.add(discovery)
|
|
@@ -313,7 +324,7 @@ export class Network extends ReadyResource {
|
|
|
313
324
|
// mirror the core so it stays available when its writers are offline —
|
|
314
325
|
// autobees announce their whole writer set, plain cores (blobs) just themselves
|
|
315
326
|
if (this._blindPeering) {
|
|
316
|
-
if (core
|
|
327
|
+
if (Autobee.isAutobee(core)) this._blindPeering.addAutobaseBackground(core)
|
|
317
328
|
else this._blindPeering.addCoreBackground(core)
|
|
318
329
|
}
|
|
319
330
|
}
|
package/src/pairing/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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() {
|
package/src/storage/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
208
|
-
//
|
|
209
|
-
const eq = Object.keys(query || {}).filter((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
|
-
|
|
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
|
|
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) => !
|
|
237
|
-
const
|
|
238
|
-
const
|
|
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
|
|
4
|
-
* the stream dirty — the diff runs when the reader
|
|
5
|
-
* folds bursts into one bigger batch and nothing is
|
|
6
|
-
* batch, and any batch after the view is swapped or
|
|
7
|
-
* core, carries the full matching state as inserts
|
|
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
|
-
|
|
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
|
|
262
|
-
*
|
|
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
|
package/types/lib/constants.d.ts
CHANGED
package/types/lib/utils.d.ts
CHANGED
|
@@ -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
|
*
|
package/types/network/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export
|
|
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
|
-
|
|
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(
|
|
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
|
-
};
|