@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/core",
3
- "version": "1.12.0",
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.19",
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
 
@@ -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 snapshot cursors. Update ticks only mark
12
- * the stream dirty — the diff runs when the reader demands, so backpressure
13
- * folds bursts into one bigger batch and nothing is ever dropped. The first
14
- * batch, and any batch after the view is swapped or fast-forwarded to a new
15
- * core, carries the full matching state as inserts with `reset: true`.
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 snap = db.view.snapshot()
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
- !cursor ||
76
- cursorView !== db.view ||
77
- !b4a.equals(beeOf(cursor).core.key, beeOf(snap).core.key)
78
- const batch = fresh ? await initial(snap) : await delta(snap)
79
- if (cursor) await cursor.close()
80
- cursor = snap
81
- cursorView = db.view
82
- return batch
83
- } catch (err) {
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
- if (!keyring) throw CeroError.UNKNOWN_EPOCH(id)
47
-
48
- let entropy = keyring.entropy(id)
49
- if (!entropy && !keyring.primed && this.auto.local) {
50
- // first epoch miss of a session may happen inside autobee's own boot
51
- // (reading back this device's post-rotation state) — prime lazily from
52
- // the local core's userData, once
53
- keyring.primed = true
54
- await primeKeyring(keyring, this.auto.local)
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
- async function primeKeyring(keyring, local) {
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
- // Prime the keyring from local userData as soon as boot resolves the local
225
- // core (in-boot epoch misses are covered by the provider's lazy prime).
226
- async _bootState() {
227
- await super._bootState()
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
- _parkOnUnknownEpoch(w) {
251
- if (w._epochParking) return
252
- w._epochParking = true
253
- const next = w.next.bind(w)
254
- w.next = async () => {
255
- try {
256
- return await next()
257
- } catch (err) {
258
- if (err?.code !== 'UNKNOWN_EPOCH') throw err
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 parked writer produces no wake-up of its own — poke the bee until the
266
- // pending announcement applies and the parked blocks decrypt. Exponential
267
- // backoff (reset when the keyring advances) so a removed member, who will
268
- // never learn the epoch, settles into a slow idle poll instead of a hot loop
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 (!this.closing) this.update().catch(noop)
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
  }
@@ -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 { NAMESPACE, SINGLE, COLLECTION, ACTION, ACTIVE, PASSIVE, REMOVE } from '../lib/constants.js'
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.epoch)) continue
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 performed inside `fn` into a single autobee append.
739
- * Nested calls reuse the outer queue.
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
- // Nested tx() (called while an outer tx's fn is running) joins the outer queue.
747
- if (this.txQueue) return fn()
748
- // Outer transactions serialize the run is deferred onto a chain so two
749
- // concurrent tx() calls each get their own queue instead of the second
750
- // buffering into the first's (and being dropped if the first rolls back).
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 queue = []
753
- this.txQueue = queue
754
- try {
755
- const result = await fn()
756
- this.txQueue = null
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) => !INDEX_RESERVED.has(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 (INDEX_RESERVED.has(key)) continue
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
- }
@@ -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
+ }