@cero-base/core 1.1.0 → 1.2.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 CHANGED
@@ -92,7 +92,7 @@ db.before('put', (ctx) => {
92
92
 
93
93
  ## Blobs
94
94
 
95
- Content-addressed Hyperblobs store. Returns z32 ids you can hand back to `get`/`read`/`has`.
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 | 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, …) |
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.1.0",
3
+ "version": "1.2.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",
@@ -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.from(input))
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(b4a.concat([writerKey, db.writerKey]))
65
+ sig: db.identity.sign(addWriterPayload(db.key, writerKey, db.writerKey))
66
66
  }
67
67
  ],
68
68
  [
@@ -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 { toId, toKey, can, grants, outranks } from '../lib/utils.js'
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 (!Identity.verify(op.master, b4a.concat([op.writer, ctx.key]), op.sig)) return
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 (!Identity.verify(op.identity, op.writer, op.sig)) return
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
- add(name, routes[name] || (async () => {}))
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
- await dispatcher.dispatch(node.value, { view, host, key: node.key })
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
  }
@@ -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 Set()
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: this.network?.wakeup || undefined,
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
- for (const fn of this.updaters) fn()
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.add(fn)
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(b4a.concat([writerKey, this.writerKey]))
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(['gt', 'gte', 'lt', 'lte', 'limit', 'reverse', 'search', 'fields'])
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
@@ -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 new RangeError('words must be 12 or 24')
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
  *