@cero-base/core 1.12.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 -10
- 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/ble.js +0 -1109
- package/src/network/transports/dht.js +0 -110
- package/src/network/transports/gatt.js +0 -42
- package/src/network/transports/l2cap.js +0 -90
- package/types/network/transports/ble.d.ts +0 -180
- package/types/network/transports/dht.d.ts +0 -75
- package/types/network/transports/gatt.d.ts +0 -21
- package/types/network/transports/l2cap.d.ts +0 -37
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,14 +41,6 @@
|
|
|
41
41
|
"types": "./types/network/index.d.ts",
|
|
42
42
|
"default": "./src/network/index.js"
|
|
43
43
|
},
|
|
44
|
-
"./network/transports/ble": {
|
|
45
|
-
"types": "./types/network/transports/ble.d.ts",
|
|
46
|
-
"default": "./src/network/transports/ble.js"
|
|
47
|
-
},
|
|
48
|
-
"./network/transports/dht": {
|
|
49
|
-
"types": "./types/network/transports/dht.d.ts",
|
|
50
|
-
"default": "./src/network/transports/dht.js"
|
|
51
|
-
},
|
|
52
44
|
"./database": {
|
|
53
45
|
"types": "./types/database/index.d.ts",
|
|
54
46
|
"default": "./src/database/index.js"
|
|
@@ -144,7 +136,7 @@
|
|
|
144
136
|
},
|
|
145
137
|
"dependencies": {
|
|
146
138
|
"@hyperswarm/secret-stream": "^6.9.1",
|
|
147
|
-
"autobee": "2.0.0-rc.
|
|
139
|
+
"autobee": "2.0.0-rc.21",
|
|
148
140
|
"autobee-encryption": "0.1.3",
|
|
149
141
|
"b4a": "^1.8.1",
|
|
150
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
|
+
}
|