@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 +7 -11
- 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 +59 -59
- package/src/lib/constants.js +14 -0
- package/src/lib/utils.js +19 -0
- package/src/network/index.js +54 -41
- 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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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'
|
|
@@ -119,7 +129,9 @@ export class Database extends ReadyResource {
|
|
|
119
129
|
this._healTimer = null
|
|
120
130
|
}
|
|
121
131
|
if (this._discovery) {
|
|
122
|
-
|
|
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
|
|
232
|
-
//
|
|
233
|
-
|
|
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.
|
|
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
|
|
739
|
-
*
|
|
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
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
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
|
-
}
|
|
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) => !
|
|
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 (
|
|
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
|
-
}
|
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
|
|
|
@@ -230,12 +240,24 @@ export class Network extends ReadyResource {
|
|
|
230
240
|
}
|
|
231
241
|
this._injected.clear()
|
|
232
242
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
329
|
+
if (Autobee.isAutobee(core)) this._blindPeering.addAutobaseBackground(core)
|
|
317
330
|
else this._blindPeering.addCoreBackground(core)
|
|
318
331
|
}
|
|
319
332
|
}
|
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
|
-
};
|