@cero-base/core 1.1.1 → 1.3.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/README.md +14 -12
- package/package.json +13 -4
- package/src/blobs/index.js +1 -1
- package/src/database/bootstrap.js +2 -2
- package/src/database/dispatch.js +41 -6
- package/src/database/index.js +177 -17
- package/src/identity/index.js +15 -1
- package/src/lib/errors.js +8 -0
- package/src/lib/utils.js +33 -12
- package/src/network/bluetooth.js +804 -0
- package/src/network/gatt-stream.js +59 -0
- package/src/network/index.js +121 -8
- package/src/pairing/index.js +6 -6
- package/src/rpc/index.js +2 -2
- package/src/storage/index.js +30 -8
- package/types/database/dispatch.d.ts +2 -1
- package/types/database/index.d.ts +34 -8
- package/types/identity/index.d.ts +9 -0
- package/types/lib/errors.d.ts +6 -0
- package/types/lib/utils.d.ts +4 -6
- package/types/network/bluetooth.d.ts +162 -0
- package/types/network/gatt-stream.d.ts +26 -0
- package/types/network/index.d.ts +36 -1
- package/types/storage/index.d.ts +7 -1
- package/src/CLAUDE.md +0 -3
- package/src/database/CLAUDE.md +0 -3
- package/src/identity/CLAUDE.md +0 -3
- package/src/lib/CLAUDE.md +0 -3
- package/src/network/CLAUDE.md +0 -3
- package/src/rpc/CLAUDE.md +0 -3
- package/src/storage/CLAUDE.md +0 -3
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ db.before('put', (ctx) => {
|
|
|
92
92
|
|
|
93
93
|
## Blobs
|
|
94
94
|
|
|
95
|
-
Content-addressed Hyperblobs store.
|
|
95
|
+
Content-addressed Hyperblobs store. `put(bytes)` returns a blob id you hand back to `get` (or `clear` to reclaim space).
|
|
96
96
|
|
|
97
97
|
```js
|
|
98
98
|
import { Blobs } from '@cero-base/core/blobs'
|
|
@@ -186,17 +186,19 @@ try {
|
|
|
186
186
|
|
|
187
187
|
Generic:
|
|
188
188
|
|
|
189
|
-
| Code
|
|
190
|
-
|
|
|
191
|
-
| `REQUIRED`
|
|
192
|
-
| `INVALID`
|
|
193
|
-
| `CLOSED`
|
|
194
|
-
| `NOT_READY`
|
|
195
|
-
| `DESTROYED`
|
|
196
|
-
| `UNKNOWN`
|
|
197
|
-
| `NOT_WRITABLE`
|
|
198
|
-
| `TIMED_OUT`
|
|
199
|
-
| `UNSUPPORTED`
|
|
189
|
+
| Code | Thrown when |
|
|
190
|
+
| ------------------ | ------------------------------------------------------------------------------ |
|
|
191
|
+
| `REQUIRED` | A constructor or function arg is missing (`store`, `identity`, `spec`, …) |
|
|
192
|
+
| `INVALID` | Arg has the wrong shape/type (`publicKey must be a 32-byte buffer`) |
|
|
193
|
+
| `CLOSED` | You called a method on a closed `Database`, `Storage`, `Network`, `Pairing`, … |
|
|
194
|
+
| `NOT_READY` | You called a method before `await x.ready()` |
|
|
195
|
+
| `DESTROYED` | A `Discovery` is destroyed |
|
|
196
|
+
| `UNKNOWN` | Asked for a `ref`, `handle`, or other thing that doesn't exist |
|
|
197
|
+
| `NOT_WRITABLE` | Tried to write to a `Database` whose local writer isn't admitted yet |
|
|
198
|
+
| `TIMED_OUT` | A bounded wait elapsed (`until(...)` etc.) |
|
|
199
|
+
| `UNSUPPORTED` | Feature not yet wired (nested handles, …) |
|
|
200
|
+
| `CHANNEL_MISMATCH` | Storage stamped with one `channel` reopened under another (or none) |
|
|
201
|
+
| `CONFLICT` | The operation raced an existing state (e.g. double init) |
|
|
200
202
|
|
|
201
203
|
Pairing-specific:
|
|
202
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"src",
|
|
19
19
|
"types",
|
|
20
20
|
"README.md",
|
|
21
|
-
"LICENSE"
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"!**/CLAUDE.md"
|
|
22
23
|
],
|
|
23
24
|
"publishConfig": {
|
|
24
25
|
"access": "public"
|
|
@@ -40,6 +41,10 @@
|
|
|
40
41
|
"types": "./types/network/index.d.ts",
|
|
41
42
|
"default": "./src/network/index.js"
|
|
42
43
|
},
|
|
44
|
+
"./network/bluetooth": {
|
|
45
|
+
"types": "./types/network/bluetooth.d.ts",
|
|
46
|
+
"default": "./src/network/bluetooth.js"
|
|
47
|
+
},
|
|
43
48
|
"./database": {
|
|
44
49
|
"types": "./types/database/index.d.ts",
|
|
45
50
|
"default": "./src/database/index.js"
|
|
@@ -49,9 +54,11 @@
|
|
|
49
54
|
"default": "./src/blobs/index.js"
|
|
50
55
|
},
|
|
51
56
|
"./blobs/codec": {
|
|
57
|
+
"types": "./types/blobs/codec.d.ts",
|
|
52
58
|
"default": "./src/blobs/codec.js"
|
|
53
59
|
},
|
|
54
60
|
"./blobs/server": {
|
|
61
|
+
"types": "./types/blobs/server.d.ts",
|
|
55
62
|
"default": "./src/blobs/server.js"
|
|
56
63
|
},
|
|
57
64
|
"./rpc": {
|
|
@@ -118,9 +125,11 @@
|
|
|
118
125
|
"build:types": "rm -rf types && tsc -p .",
|
|
119
126
|
"pretest": "npm run build:test",
|
|
120
127
|
"prepublishOnly": "npm run build:types",
|
|
121
|
-
"test": "brittle-node test/*.test.js"
|
|
128
|
+
"test": "brittle-node test/*.test.js",
|
|
129
|
+
"test:bare": "npx bare test/bare-smoke.js"
|
|
122
130
|
},
|
|
123
131
|
"dependencies": {
|
|
132
|
+
"@hyperswarm/secret-stream": "^6.9.1",
|
|
124
133
|
"autobee": "^1.0.9",
|
|
125
134
|
"b4a": "^1.8.1",
|
|
126
135
|
"bare-crypto": "^1.15.3",
|
|
@@ -133,8 +142,8 @@
|
|
|
133
142
|
"framed-stream": "^1.0.1",
|
|
134
143
|
"hrpc": "^4.3.0",
|
|
135
144
|
"hyperblobs": "^2.12.1",
|
|
136
|
-
"hypercore-blob-server": "^1.12.0",
|
|
137
145
|
"hypercore": "^11.33.1",
|
|
146
|
+
"hypercore-blob-server": "^1.12.0",
|
|
138
147
|
"hypercore-crypto": "^3.7.0",
|
|
139
148
|
"hypercore-id-encoding": "^1.3.0",
|
|
140
149
|
"hypercore-storage": "^3.1.1",
|
package/src/blobs/index.js
CHANGED
|
@@ -103,7 +103,7 @@ export class Blobs extends ReadyResource {
|
|
|
103
103
|
async put(input) {
|
|
104
104
|
this._guard()
|
|
105
105
|
if (input == null) throw CeroError.REQUIRED('input')
|
|
106
|
-
if (b4a.isBuffer(input)) return this.hyperblobs.put(b4a.
|
|
106
|
+
if (b4a.isBuffer(input)) return this.hyperblobs.put(b4a.toBuffer(input))
|
|
107
107
|
if (isReadable(input)) return this._putStream(input)
|
|
108
108
|
throw CeroError.INVALID('input must be a buffer or a Readable stream')
|
|
109
109
|
}
|
|
@@ -2,7 +2,7 @@ import b4a from 'b4a'
|
|
|
2
2
|
import Hypercore from 'hypercore'
|
|
3
3
|
import { Identity } from '../identity/index.js'
|
|
4
4
|
import { CeroError } from '../lib/errors.js'
|
|
5
|
-
import { toId } from '../lib/utils.js'
|
|
5
|
+
import { toId, addWriterPayload } from '../lib/utils.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* First-run device provisioning: mint a device writer keypair, persist it as a
|
|
@@ -62,7 +62,7 @@ async function saveWriter(db, writerKey, { name, isMobile }) {
|
|
|
62
62
|
{
|
|
63
63
|
master: db.identity.publicKey,
|
|
64
64
|
writer: writerKey,
|
|
65
|
-
sig: db.identity.sign(
|
|
65
|
+
sig: db.identity.sign(addWriterPayload(db.key, writerKey, db.writerKey))
|
|
66
66
|
}
|
|
67
67
|
],
|
|
68
68
|
[
|
package/src/database/dispatch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import b4a from 'b4a'
|
|
2
|
+
import safetyCatch from 'safety-catch'
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
SINGLE,
|
|
@@ -10,7 +11,16 @@ import {
|
|
|
10
11
|
ASSIGN,
|
|
11
12
|
WRITE
|
|
12
13
|
} from '../lib/constants.js'
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
toId,
|
|
16
|
+
toKey,
|
|
17
|
+
can,
|
|
18
|
+
grants,
|
|
19
|
+
outranks,
|
|
20
|
+
addWriterPayload,
|
|
21
|
+
claimWriterPayload
|
|
22
|
+
} from '../lib/utils.js'
|
|
23
|
+
import { CeroError } from '../lib/errors.js'
|
|
14
24
|
import { Identity } from '../identity/index.js'
|
|
15
25
|
|
|
16
26
|
// Built-in collections wired into every cero spec.
|
|
@@ -22,6 +32,11 @@ export const BUILTINS = [
|
|
|
22
32
|
{ name: 'files', verb: 'file' }
|
|
23
33
|
]
|
|
24
34
|
|
|
35
|
+
// Remote op fields are variable-length buffers on the wire — reject wrong sizes
|
|
36
|
+
// before they reach sodium/toId, which throw instead of returning false.
|
|
37
|
+
const isKey = (b) => b?.byteLength === 32
|
|
38
|
+
const isSig = (b) => b?.byteLength === 64
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Build the hyperdispatch router for a spec: wires membership, builtin, and
|
|
27
42
|
* spec-defined collection/action ops, returning the router plus an apply loop.
|
|
@@ -29,9 +44,10 @@ export const BUILTINS = [
|
|
|
29
44
|
* @param {{ dispatch: { Router: Function }, meta?: { refs?: Record<string, { kind?: string, builtin?: boolean, verb?: string }> } }} spec Generated hyperdispatch spec.
|
|
30
45
|
* @param {string} ns Namespace prefix for collection and op names.
|
|
31
46
|
* @param {Record<string, Function>} routes Custom action handlers keyed by route name.
|
|
47
|
+
* @param {(err: Error) => void} [onerror] Called when a malformed node is skipped.
|
|
32
48
|
* @returns {{ dispatcher: object, apply: (nodes: Array<{ value: Buffer, key: Buffer }>, view: object, host: object) => Promise<void> }}
|
|
33
49
|
*/
|
|
34
|
-
export function makeDispatcher(spec, ns, routes) {
|
|
50
|
+
export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey = () => null) {
|
|
35
51
|
const dispatcher = new spec.dispatch.Router()
|
|
36
52
|
|
|
37
53
|
const countersCol = `@${ns}/${COUNTERS}`
|
|
@@ -85,7 +101,8 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
85
101
|
const add = (verb, fn) => dispatcher.add(`@${ns}/${verb}`, fn)
|
|
86
102
|
|
|
87
103
|
add('add-writer', async (op, ctx) => {
|
|
88
|
-
if (!
|
|
104
|
+
if (!isKey(op.master) || !isKey(op.writer) || !isSig(op.sig)) return
|
|
105
|
+
if (!Identity.verify(op.master, addWriterPayload(ctx.dbKey, op.writer, ctx.key), op.sig)) return
|
|
89
106
|
if (!(await isGenesis(ctx.view)) && !can(await getRole(ctx.view, op.master), INVITE)) return
|
|
90
107
|
await ctx.host.addWriter(op.writer, { isIndexer: op.isIndexer !== false })
|
|
91
108
|
const ts = Date.now()
|
|
@@ -98,6 +115,7 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
98
115
|
})
|
|
99
116
|
|
|
100
117
|
add('del-writer', async (op, ctx) => {
|
|
118
|
+
if (!isKey(op.writer)) return
|
|
101
119
|
const target = await getDevice(ctx.view, toId(op.writer))
|
|
102
120
|
if (target?.memberId !== (await getSignerMember(ctx.view, ctx.key))) {
|
|
103
121
|
if (!(await canRemoveMember(ctx.view, ctx.key, target?.memberId))) return
|
|
@@ -107,7 +125,8 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
107
125
|
})
|
|
108
126
|
|
|
109
127
|
add('claim-writer', async (op, ctx) => {
|
|
110
|
-
if (!
|
|
128
|
+
if (!isKey(op.identity) || !isKey(op.writer) || !isSig(op.sig)) return
|
|
129
|
+
if (!Identity.verify(op.identity, claimWriterPayload(ctx.dbKey, op.writer), op.sig)) return
|
|
111
130
|
if (!b4a.equals(ctx.key, op.writer)) return
|
|
112
131
|
const memberId = toId(op.identity)
|
|
113
132
|
if (!can(await getRole(ctx.view, op.identity), WRITE)) return
|
|
@@ -122,6 +141,7 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
122
141
|
})
|
|
123
142
|
|
|
124
143
|
add('add-member', async (op, ctx) => {
|
|
144
|
+
if (!isKey(op.key)) return
|
|
125
145
|
if (!(await isGenesis(ctx.view))) {
|
|
126
146
|
const r = await getSignerRole(ctx.view, ctx.key)
|
|
127
147
|
if (!can(r, INVITE) || !grants(r, op.role)) return
|
|
@@ -210,7 +230,15 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
210
230
|
if (info.builtin) continue
|
|
211
231
|
if (info.kind === 'handle') continue
|
|
212
232
|
if (info.kind === ACTION) {
|
|
213
|
-
|
|
233
|
+
// a replicated action with no local route is a peer misconfiguration —
|
|
234
|
+
// surface it instead of silently diverging from peers that ran it
|
|
235
|
+
add(
|
|
236
|
+
name,
|
|
237
|
+
routes[name] ||
|
|
238
|
+
(async () => {
|
|
239
|
+
onerror(CeroError.UNKNOWN('route', name))
|
|
240
|
+
})
|
|
241
|
+
)
|
|
214
242
|
continue
|
|
215
243
|
}
|
|
216
244
|
const col = `@${ns}/${name}`
|
|
@@ -228,8 +256,15 @@ export function makeDispatcher(spec, ns, routes) {
|
|
|
228
256
|
return {
|
|
229
257
|
dispatcher,
|
|
230
258
|
async apply(nodes, view, host) {
|
|
259
|
+
const dbKey = getDbKey()
|
|
231
260
|
for (const node of nodes) {
|
|
232
|
-
|
|
261
|
+
try {
|
|
262
|
+
await dispatcher.dispatch(node.value, { view, host, key: node.key, dbKey })
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// deterministic skip: dispatch depends only on node.value and view
|
|
265
|
+
// state, so every peer drops the same bad node and stays convergent
|
|
266
|
+
onerror(err)
|
|
267
|
+
}
|
|
233
268
|
}
|
|
234
269
|
await view.flush()
|
|
235
270
|
}
|
package/src/database/index.js
CHANGED
|
@@ -5,8 +5,8 @@ import ReadyResource from 'ready-resource'
|
|
|
5
5
|
import safetyCatch from 'safety-catch'
|
|
6
6
|
import b4a from 'b4a'
|
|
7
7
|
|
|
8
|
-
import { NAMESPACE, SINGLE, COLLECTION, ACTION } from '../lib/constants.js'
|
|
9
|
-
import { genId, subscribe } from '../lib/utils.js'
|
|
8
|
+
import { NAMESPACE, SINGLE, COLLECTION, ACTION, ACTIVE, PASSIVE } from '../lib/constants.js'
|
|
9
|
+
import { genId, subscribe, addWriterPayload, claimWriterPayload } from '../lib/utils.js'
|
|
10
10
|
import { CeroError } from '../lib/errors.js'
|
|
11
11
|
import { bootstrap } from './bootstrap.js'
|
|
12
12
|
import { makeDispatcher } from './dispatch.js'
|
|
@@ -22,12 +22,13 @@ import { makeDispatcher } from './dispatch.js'
|
|
|
22
22
|
* @property {Uint8Array | null} [encryptionKey] Optional encryption key; falls back to identity's key.
|
|
23
23
|
* @property {(nodes: any, view: any, host: any) => Promise<void>} [apply] Override the default apply function.
|
|
24
24
|
* @property {Uint8Array | null} [key] Existing autobee key to reopen.
|
|
25
|
+
* @property {boolean} [passive] Join discovery server-only (reachable but not searching). Flip at runtime with `setActive`.
|
|
25
26
|
* @property {import('../identity/index.js').KeyPair} [keyPair] Device writer keypair; defaults to identity's keypair.
|
|
26
|
-
* @property {(err: Error) => void} [onerror] Called when a background after-hook or onApply callback fails.
|
|
27
|
+
* @property {(err: Error) => void} [onerror] Called when a background after-hook or onApply callback fails, a malformed node is skipped, or the bee errors.
|
|
27
28
|
*
|
|
28
29
|
* @typedef {{ data: any | null }} SingleResult
|
|
29
|
-
* @typedef {{ data: any[], total: number, size: number }} ListResult
|
|
30
|
-
* @typedef {{ gt?: string, gte?: string, lt?: string, lte?: string, reverse?: boolean, limit?: number, search?: string, fields?: string[] }} Query
|
|
30
|
+
* @typedef {{ data: any[], total: number | null, size: number }} ListResult `total` is null when a limited read skipped the full count — pass `{ total: true }` to force it.
|
|
31
|
+
* @typedef {{ gt?: string, gte?: string, lt?: string, lte?: string, reverse?: boolean, limit?: number, search?: string, fields?: string[], total?: boolean }} Query
|
|
31
32
|
* @typedef {{ kind: string, verb: string, name: string }} Ref
|
|
32
33
|
* @typedef {(ctx: any) => any | Promise<any>} HookFn
|
|
33
34
|
*/
|
|
@@ -61,6 +62,7 @@ export class Database extends ReadyResource {
|
|
|
61
62
|
this.encryptionKey = opts.encryptionKey || opts.identity.encryptionKey || null
|
|
62
63
|
this.applyOverride = opts.apply || null
|
|
63
64
|
this.key = opts.key || null
|
|
65
|
+
this.passive = opts.passive === true
|
|
64
66
|
this.keyPair = opts.keyPair || {
|
|
65
67
|
publicKey: opts.identity.publicKey,
|
|
66
68
|
secretKey: opts.identity.secretKey
|
|
@@ -72,9 +74,10 @@ export class Database extends ReadyResource {
|
|
|
72
74
|
|
|
73
75
|
this.beforeHooks = new Map()
|
|
74
76
|
this.afterHooks = new Map()
|
|
75
|
-
this.updaters = new
|
|
77
|
+
this.updaters = new Map()
|
|
76
78
|
this.onApplyHooks = new Set()
|
|
77
79
|
this._applySeq = 0
|
|
80
|
+
this._touched = new Set()
|
|
78
81
|
this.txQueue = null
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -101,22 +104,41 @@ export class Database extends ReadyResource {
|
|
|
101
104
|
|
|
102
105
|
/** Open the underlying autobee, wire dispatcher + apply, attach to network. */
|
|
103
106
|
async openBee() {
|
|
104
|
-
this.dispatcher = makeDispatcher(this.spec, this.ns, this.routes)
|
|
107
|
+
this.dispatcher = makeDispatcher(this.spec, this.ns, this.routes, this._onerror, () => this.key)
|
|
108
|
+
|
|
109
|
+
// autobee-wakeup destroys whatever wakeup it is handed (its _owner guard is
|
|
110
|
+
// computed but never consulted upstream) — closing one bee must not tear
|
|
111
|
+
// down the network-shared protocol, so each bee gets a non-owning facade.
|
|
112
|
+
// Drop the facade once upstream honors _owner.
|
|
113
|
+
const shared = this.network?.wakeup
|
|
114
|
+
const wakeup = shared && {
|
|
115
|
+
addStream: (s) => shared.addStream(s),
|
|
116
|
+
session: (...args) => shared.session(...args),
|
|
117
|
+
destroy: () => {}
|
|
118
|
+
}
|
|
105
119
|
|
|
106
120
|
const bee = new Autobee(this.store.namespace(this.namespace), this.key, {
|
|
107
121
|
keyPair: this.keyPair,
|
|
108
122
|
encryptionKey: this.encryptionKey,
|
|
109
123
|
optimistic: true,
|
|
110
|
-
wakeup:
|
|
124
|
+
wakeup: wakeup || undefined,
|
|
111
125
|
open: (b) => HyperDB.bee2(b, this.spec.database, { autoUpdate: true }),
|
|
112
126
|
apply: async (nodes, view, host) => {
|
|
113
127
|
const result = await (this.applyOverride || this.dispatcher.apply)(nodes, view, host)
|
|
128
|
+
if (this.updaters.size) this._touch(nodes)
|
|
114
129
|
if (this.onApplyHooks.size) this._observe(nodes)
|
|
115
130
|
return result
|
|
116
131
|
},
|
|
117
132
|
update: async (db) => {
|
|
118
133
|
await db.update()
|
|
119
|
-
|
|
134
|
+
// fire only watchers whose ref was touched by this apply batch —
|
|
135
|
+
// unscoped watchers (and undecodable nodes, '*') fire everything
|
|
136
|
+
const touched = this._touched
|
|
137
|
+
this._touched = new Set()
|
|
138
|
+
const all = touched.size === 0 || touched.has('*')
|
|
139
|
+
for (const [fn, scope] of this.updaters) {
|
|
140
|
+
if (all || !scope || touched.has(scope)) fn()
|
|
141
|
+
}
|
|
120
142
|
this.emit('update')
|
|
121
143
|
}
|
|
122
144
|
})
|
|
@@ -128,16 +150,32 @@ export class Database extends ReadyResource {
|
|
|
128
150
|
this.key = bee.key
|
|
129
151
|
|
|
130
152
|
bee.on('writable', () => this.emit('writable'))
|
|
153
|
+
// without a listener autobee escalates apply/view errors to a process crash
|
|
154
|
+
bee.on('error', this._onerror)
|
|
131
155
|
|
|
132
156
|
if (this.network) {
|
|
133
157
|
this.network.attach(bee)
|
|
134
158
|
// a writer swap re-opens the bee — tear down the prior discovery session
|
|
135
159
|
// first so it isn't orphaned by the reassignment below
|
|
136
160
|
if (this._discovery) await this._discovery.destroy()
|
|
137
|
-
this._discovery = this.network.join(bee.discoveryKey
|
|
161
|
+
this._discovery = this.network.join(bee.discoveryKey, {
|
|
162
|
+
mode: this.passive ? PASSIVE : ACTIVE
|
|
163
|
+
})
|
|
138
164
|
}
|
|
139
165
|
}
|
|
140
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Flip announce mode at runtime — passive stays reachable (server) but
|
|
169
|
+
* stops actively looking (client). Cheap; use it to demote idle rooms.
|
|
170
|
+
*
|
|
171
|
+
* @param {boolean} active
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
async setActive(active) {
|
|
175
|
+
if (!this._discovery) return
|
|
176
|
+
await (active ? this._discovery.activate() : this._discovery.deactivate())
|
|
177
|
+
}
|
|
178
|
+
|
|
141
179
|
/** @returns {Uint8Array | null} discovery key of the underlying bee */
|
|
142
180
|
get discoveryKey() {
|
|
143
181
|
return this.bee?.discoveryKey || null
|
|
@@ -186,16 +224,43 @@ export class Database extends ReadyResource {
|
|
|
186
224
|
}
|
|
187
225
|
|
|
188
226
|
/**
|
|
189
|
-
* Subscribe to local apply notifications. Fires whenever the view updates
|
|
227
|
+
* Subscribe to local apply notifications. Fires whenever the view updates;
|
|
228
|
+
* pass `scope` (a ref name) to fire only when that ref was touched.
|
|
190
229
|
*
|
|
191
230
|
* @param {() => void} fn
|
|
231
|
+
* @param {string} [scope]
|
|
192
232
|
* @returns {() => void} disposer
|
|
193
233
|
*/
|
|
194
|
-
onUpdate(fn) {
|
|
195
|
-
this.updaters.
|
|
234
|
+
onUpdate(fn, scope) {
|
|
235
|
+
this.updaters.set(fn, scope || null)
|
|
196
236
|
return () => this.updaters.delete(fn)
|
|
197
237
|
}
|
|
198
238
|
|
|
239
|
+
// record which refs an apply batch mutated, so update only wakes their watchers
|
|
240
|
+
_touch(nodes) {
|
|
241
|
+
if (!this._byVerb) {
|
|
242
|
+
this._byVerb = new Map()
|
|
243
|
+
for (const [name, info] of Object.entries(this.refs)) {
|
|
244
|
+
this._byVerb.set(info.verb || name, name)
|
|
245
|
+
this._byVerb.set(name, name)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const node of nodes) {
|
|
249
|
+
try {
|
|
250
|
+
const { name } = this.spec.dispatch.decode(node.value)
|
|
251
|
+
const verb = name.slice(name.indexOf('/') + 1)
|
|
252
|
+
const dash = verb.indexOf('-')
|
|
253
|
+
const target = dash < 0 ? verb : verb.slice(dash + 1)
|
|
254
|
+
const ref = this._byVerb.get(target)
|
|
255
|
+
// an action's route handler writes wherever it wants — widen to all
|
|
256
|
+
if (!ref || this.refs[ref]?.kind === ACTION) this._touched.add('*')
|
|
257
|
+
else this._touched.add(ref)
|
|
258
|
+
} catch {
|
|
259
|
+
this._touched.add('*')
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
199
264
|
/**
|
|
200
265
|
* Observe every applied op — local AND replicated (apply processes the merged
|
|
201
266
|
* log). The callback receives `{ op, name, row, writerKey, seq }` and runs
|
|
@@ -469,6 +534,34 @@ export class Database extends ReadyResource {
|
|
|
469
534
|
return { data, total: rows.length, size: data.length }
|
|
470
535
|
}
|
|
471
536
|
|
|
537
|
+
// Hot path — reverse/limit only ("latest 50 messages"): read straight off
|
|
538
|
+
// the implicit `index` order index, touching ≤ limit rows instead of the
|
|
539
|
+
// whole collection. `total` is then lazy: pass { total: true } to pay for
|
|
540
|
+
// the full count, otherwise it's null when unknowable for free.
|
|
541
|
+
if (this.refs[name]?.orderIndex && pushable(query) && (await this._orderReady(name, col))) {
|
|
542
|
+
const rows = await this.view
|
|
543
|
+
.find(`@${this.ns}/${name}-index`, {
|
|
544
|
+
reverse: query?.reverse === true,
|
|
545
|
+
limit: query?.limit
|
|
546
|
+
})
|
|
547
|
+
.toArray()
|
|
548
|
+
const total = await this._total(col, query, rows)
|
|
549
|
+
return { data: rows, total, size: rows.length }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// id-range cursor: push the range down (bounded read), keep index order.
|
|
553
|
+
if (this.refs[name]?.orderIndex && pushableRange(query)) {
|
|
554
|
+
const range = {}
|
|
555
|
+
for (const k of ['gt', 'gte', 'lt', 'lte']) {
|
|
556
|
+
if (query[k] !== undefined) range[k] = { id: query[k] }
|
|
557
|
+
}
|
|
558
|
+
const rows = await this.view.find(col, range).toArray()
|
|
559
|
+
rows.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
560
|
+
if (query.reverse) rows.reverse()
|
|
561
|
+
const data = query.limit !== undefined ? rows.slice(0, query.limit) : rows
|
|
562
|
+
return { data, total: rows.length, size: data.length }
|
|
563
|
+
}
|
|
564
|
+
|
|
472
565
|
const all = await this.view.find(col, {}).toArray()
|
|
473
566
|
// collections carry an auto-increment `index`; return them in that order
|
|
474
567
|
all.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
@@ -477,6 +570,40 @@ export class Database extends ReadyResource {
|
|
|
477
570
|
return { data, total, size: data.length }
|
|
478
571
|
}
|
|
479
572
|
|
|
573
|
+
// hyperdb never backfills an index added after rows were written — a store
|
|
574
|
+
// upgraded to a spec with the implicit order index would silently drop its
|
|
575
|
+
// legacy rows from pushed-down reads. Verify completeness ONCE per open
|
|
576
|
+
// (two scans, cached), then trust the index; incomplete → fall back + warn.
|
|
577
|
+
async _orderReady(name, col) {
|
|
578
|
+
if (!this._orderOk) this._orderOk = new Map()
|
|
579
|
+
let ok = this._orderOk.get(name)
|
|
580
|
+
if (ok === undefined) {
|
|
581
|
+
const [rows, indexed] = await Promise.all([
|
|
582
|
+
this.view.find(col, {}).toArray(),
|
|
583
|
+
this.view.find(`@${this.ns}/${name}-index`, {}).toArray()
|
|
584
|
+
])
|
|
585
|
+
ok = rows.length === indexed.length
|
|
586
|
+
this._orderOk.set(name, ok)
|
|
587
|
+
if (!ok) {
|
|
588
|
+
this._onerror(
|
|
589
|
+
CeroError.INVALID(
|
|
590
|
+
`order index for '${name}' is incomplete (store predates it) — reads fall back to full scans; recreate the store to accelerate`
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return ok
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// total for a pushed-down read: free when the page IS the whole set (no
|
|
599
|
+
// limit, or a non-full page), a full count only when explicitly requested
|
|
600
|
+
// via { total: true }, null otherwise.
|
|
601
|
+
async _total(col, query, rows) {
|
|
602
|
+
if (query?.total) return (await this.view.find(col, {}).toArray()).length
|
|
603
|
+
if (query?.limit === undefined || rows.length < query.limit) return rows.length
|
|
604
|
+
return null
|
|
605
|
+
}
|
|
606
|
+
|
|
480
607
|
/**
|
|
481
608
|
* Number of rows that match `query` (or total if omitted).
|
|
482
609
|
*
|
|
@@ -503,7 +630,7 @@ export class Database extends ReadyResource {
|
|
|
503
630
|
this.guard()
|
|
504
631
|
return subscribe({
|
|
505
632
|
get: () => this.get(name, query),
|
|
506
|
-
watch: (fn) => this.onUpdate(fn)
|
|
633
|
+
watch: (fn) => this.onUpdate(fn, name)
|
|
507
634
|
})
|
|
508
635
|
}
|
|
509
636
|
|
|
@@ -530,7 +657,7 @@ export class Database extends ReadyResource {
|
|
|
530
657
|
if (this.bee.writable) return
|
|
531
658
|
|
|
532
659
|
const writerKey = this.writerKey
|
|
533
|
-
const sig = this.identity.sign(writerKey)
|
|
660
|
+
const sig = this.identity.sign(claimWriterPayload(this.key, writerKey))
|
|
534
661
|
const encoded = this.spec.dispatch.encode(`@${this.ns}/claim-writer`, {
|
|
535
662
|
identity: this.identity.publicKey,
|
|
536
663
|
writer: writerKey,
|
|
@@ -698,13 +825,23 @@ export class Database extends ReadyResource {
|
|
|
698
825
|
version: this.store.manifestVersion,
|
|
699
826
|
signers: [{ publicKey }]
|
|
700
827
|
})
|
|
701
|
-
const sig = this.identity.sign(
|
|
828
|
+
const sig = this.identity.sign(addWriterPayload(this.key, writerKey, this.writerKey))
|
|
702
829
|
await this.write([[verb, { master: this.identity.publicKey, writer: writerKey, sig }]])
|
|
703
830
|
await this.runAfter(hook, ctx)
|
|
704
831
|
}
|
|
705
832
|
}
|
|
706
833
|
|
|
707
|
-
const INDEX_RESERVED = new Set([
|
|
834
|
+
const INDEX_RESERVED = new Set([
|
|
835
|
+
'gt',
|
|
836
|
+
'gte',
|
|
837
|
+
'lt',
|
|
838
|
+
'lte',
|
|
839
|
+
'limit',
|
|
840
|
+
'reverse',
|
|
841
|
+
'search',
|
|
842
|
+
'fields',
|
|
843
|
+
'total'
|
|
844
|
+
])
|
|
708
845
|
// Fields the write path stamps itself — always allowed even if not user-declared.
|
|
709
846
|
const SYSTEM_FIELDS = new Set(['id', 'memberId', 'index', 'createdAt', 'updatedAt'])
|
|
710
847
|
|
|
@@ -714,6 +851,29 @@ const valueEq = (a, b) => {
|
|
|
714
851
|
return a === b
|
|
715
852
|
}
|
|
716
853
|
|
|
854
|
+
// reverse/limit (+ the total flag) only — safe to serve straight off the order index
|
|
855
|
+
function pushable(query) {
|
|
856
|
+
if (!query) return true
|
|
857
|
+
return Object.keys(query).every((k) => k === 'reverse' || k === 'limit' || k === 'total')
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// an id-range with no equality/search — the range pushes down, order stays in memory
|
|
861
|
+
function pushableRange(query) {
|
|
862
|
+
if (!query) return false
|
|
863
|
+
const keys = Object.keys(query)
|
|
864
|
+
if (!keys.some((k) => k === 'gt' || k === 'gte' || k === 'lt' || k === 'lte')) return false
|
|
865
|
+
return keys.every(
|
|
866
|
+
(k) =>
|
|
867
|
+
k === 'gt' ||
|
|
868
|
+
k === 'gte' ||
|
|
869
|
+
k === 'lt' ||
|
|
870
|
+
k === 'lte' ||
|
|
871
|
+
k === 'reverse' ||
|
|
872
|
+
k === 'limit' ||
|
|
873
|
+
k === 'total'
|
|
874
|
+
)
|
|
875
|
+
}
|
|
876
|
+
|
|
717
877
|
function paginate(rows, query) {
|
|
718
878
|
if (!query) return rows
|
|
719
879
|
let out = rows
|
package/src/identity/index.js
CHANGED
|
@@ -52,6 +52,20 @@ export class Identity {
|
|
|
52
52
|
Object.freeze(this)
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Redacted — secretKey/encryptionKey/seed must never reach JSON.stringify
|
|
57
|
+
* or a structured logger.
|
|
58
|
+
*
|
|
59
|
+
* @returns {{ id: string }}
|
|
60
|
+
*/
|
|
61
|
+
toJSON() {
|
|
62
|
+
return { id: this.id }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
66
|
+
return `Identity(${this.id})`
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
/**
|
|
56
70
|
* Detached Ed25519 signature over `message`.
|
|
57
71
|
*
|
|
@@ -220,7 +234,7 @@ export class Identity {
|
|
|
220
234
|
* @returns {Uint8Array}
|
|
221
235
|
*/
|
|
222
236
|
static randomSeed(words = 12) {
|
|
223
|
-
if (words !== 12 && words !== 24) throw
|
|
237
|
+
if (words !== 12 && words !== 24) throw CeroError.INVALID('words must be 12 or 24')
|
|
224
238
|
return Identity.randomBytes((words / 3) * 4)
|
|
225
239
|
}
|
|
226
240
|
}
|
package/src/lib/errors.js
CHANGED
|
@@ -72,6 +72,14 @@ export class CeroError extends Error {
|
|
|
72
72
|
static NOT_READY(resource, name) {
|
|
73
73
|
return new CeroError('NOT_READY', `${resource} is not ready — await ${name}.ready() first`)
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Operation raced an existing state (e.g. double init).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} msg
|
|
79
|
+
*/
|
|
80
|
+
static CONFLICT(msg) {
|
|
81
|
+
return new CeroError('CONFLICT', msg)
|
|
82
|
+
}
|
|
75
83
|
/**
|
|
76
84
|
* Resource has been destroyed.
|
|
77
85
|
*
|