@cero-base/core 1.10.1 → 1.12.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.10.1",
3
+ "version": "1.12.0",
4
4
  "description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -144,7 +144,7 @@
144
144
  },
145
145
  "dependencies": {
146
146
  "@hyperswarm/secret-stream": "^6.9.1",
147
- "autobee": "2.0.0-rc.6",
147
+ "autobee": "2.0.0-rc.19",
148
148
  "autobee-encryption": "0.1.3",
149
149
  "b4a": "^1.8.1",
150
150
  "bare-crypto": "^1.15.3",
@@ -152,18 +152,18 @@
152
152
  "bare-path": "^3.1.1",
153
153
  "bip39-mnemonic": "^2.5.0",
154
154
  "blind-pairing": "^2.3.1",
155
- "blind-peering": "^2.6.1",
156
- "compact-encoding": "^3.3.0",
155
+ "blind-peering": "^2.6.3",
156
+ "compact-encoding": "^3.3.2",
157
157
  "corestore": "^7.12.0",
158
158
  "framed-stream": "^1.0.1",
159
- "hrpc": "^4.3.0",
159
+ "hrpc": "^4.3.1",
160
160
  "hyperblobs": "^2.12.1",
161
- "hypercore": "^11.35.1",
161
+ "hypercore": "^11.35.2",
162
162
  "hypercore-blob-server": "^1.15.0",
163
163
  "hypercore-crypto": "^3.7.0",
164
164
  "hypercore-id-encoding": "^1.3.0",
165
- "hypercore-storage": "^3.2.0",
166
- "hyperdb": "^6.8.0",
165
+ "hypercore-storage": "^3.2.1",
166
+ "hyperdb": "^6.9.0",
167
167
  "hyperdispatch": "^1.6.0",
168
168
  "hyperschema": "^1.22.0",
169
169
  "hyperswarm": "^4.17.0",
@@ -182,7 +182,7 @@
182
182
  "bare-fetch": "^3.2.0",
183
183
  "bare-process": "^4.5.1",
184
184
  "bare-url": "^2.5.2",
185
- "blind-peer": "^3.13.2",
185
+ "blind-peer": "^3.13.3",
186
186
  "brittle": "^4.1.0",
187
187
  "typescript": "^5.9.3",
188
188
  "which-runtime": "^1.4.0"
@@ -1,13 +1,16 @@
1
- import b4a from 'b4a'
2
1
  import Hypercore from 'hypercore'
3
2
  import { Identity } from '../identity/index.js'
4
3
  import { CeroError } from '../lib/errors.js'
5
4
  import { toId, addWriterPayload } from '../lib/utils.js'
5
+ import { wrap } from './envelope.js'
6
6
 
7
7
  /**
8
8
  * First-run device provisioning: mint a device writer keypair, persist it as a
9
9
  * writer, then swap the autobee's local core over to it. With `recovering`,
10
- * waits for the first peer-replicated append before swapping.
10
+ * the swap happens FIRST and admission rides an optimistic append the
11
+ * backfilled genesis core is never appended to (autobee's contract: a writable
12
+ * core has exactly one author, ever; resuming a replicated core is unsupported
13
+ * and drops appends silently).
11
14
  *
12
15
  * @param {import('./index.js').Database} db
13
16
  * @param {{ name?: string | null, isMobile?: boolean, recovering?: boolean }} [opts]
@@ -22,87 +25,64 @@ export async function bootstrap(db, { name, isMobile, recovering = false, timeou
22
25
  const writerKey = Hypercore.key(manifest)
23
26
 
24
27
  if (recovering) {
25
- await waitForFirstPeerAppend(db, timeout)
26
- // reopen so the bee's writer state is built over the replicated genesis
27
- // core a writer opened before replication stamps ops at stale lengths
28
- // and autobee silently drops them
29
- await reopenBee(db)
30
- }
31
- if (recovering) {
32
- // gate the swap on the admission actually applying: write() can resolve
33
- // with the ops stalled in the drain (update() bumps heal that) or dropped
34
- // outright when they were stamped behind a mid-replication core — each
35
- // retry stamps past the collision, and the ops are idempotent
36
- await admitWriter(db, writerKey, { name, isMobile }, timeout)
28
+ // swap before anything else: the genesis core then replicates as a REMOTE
29
+ // writer, so its backfilled ops apply through the normal download path
30
+ await swapWriter(db, keyPair, manifest)
31
+ await waitForBackfill(db, timeout)
32
+ await claimWriter(db, writerKey, timeout)
33
+ await saveDevice(db, writerKey, { name, isMobile })
37
34
  } else {
38
35
  await saveWriter(db, writerKey, { name, isMobile })
36
+ await swapWriter(db, keyPair, manifest)
39
37
  }
40
- await swapWriter(db, keyPair, manifest)
41
38
 
42
39
  return { id: writerKey, writer: keyPair }
43
40
  }
44
41
 
45
- async function waitForFirstPeerAppend(db, timeout = 30000) {
46
- if (!db.network?.swarm) return
47
- if (db.bee.local.length === 0) await waitForLength(db, 1, timeout)
48
- await db.bee.update()
49
- // the first block may not be the whole core — writes stamped below the
50
- // length the system already attributes to this writer are silently dropped
51
- const info = await db.bee.system.get(db.bee.local.key).catch(() => null)
52
- if (info && info.length > db.bee.local.length) await waitForLength(db, info.length, timeout)
53
- }
54
-
55
- function waitForLength(db, length, timeout) {
56
- return new Promise((resolve, reject) => {
57
- let done = false
58
- const finish = (err) => {
59
- if (done) return
60
- done = true
61
- clearTimeout(timer)
62
- db.bee.local.off('append', onAppend)
63
- db.off('close', onClose)
64
- if (err) reject(err)
65
- else resolve()
66
- }
67
- // timeout + close handling so a peer that never replicates can't hang the
68
- // recovery forever, and the append listener is always removed.
69
- const onAppend = () => db.bee.local.length >= length && finish()
70
- const onClose = () => finish(CeroError.CLOSED('Database'))
71
- const timer = setTimeout(
72
- () => finish(CeroError.TIMED_OUT('recovery — no peer append')),
73
- timeout
74
- )
75
- db.bee.local.on('append', onAppend)
76
- db.on('close', onClose)
77
- })
78
- }
79
-
80
- async function admitWriter(db, writerKey, opts, timeout) {
81
- const deadline = Date.now() + timeout
82
- const id = toId(writerKey)
83
- while (true) {
84
- await saveWriter(db, writerKey, opts)
85
- if (await deviceApplied(db, id, Math.min(3000, deadline - Date.now()))) return
86
- if (Date.now() >= deadline) throw CeroError.TIMED_OUT('recovery — writer not admitted')
87
- }
88
- }
89
-
90
- async function deviceApplied(db, id, timeout) {
42
+ // recovery has state to recover by definition — wait until replication has
43
+ // delivered and applied it (the genesis device row is the first thing every
44
+ // db writes), so a peer that never replicates fails loud instead of hanging
45
+ async function waitForBackfill(db, timeout) {
91
46
  const deadline = Date.now() + timeout
92
47
  while (Date.now() < deadline) {
93
- const { data } = await db.get('devices', id)
94
- if (data) return true
48
+ if (db.closing || db.closed) throw CeroError.CLOSED('Database')
49
+ const { data } = await db.get('devices')
50
+ if (data.length > 0) return
95
51
  await db.bee.update()
96
- await new Promise((resolve) => setTimeout(resolve, 25))
52
+ await new Promise((resolve) => setTimeout(resolve, 50))
97
53
  }
98
- return false
54
+ throw CeroError.TIMED_OUT('recovery — no peer replicated')
99
55
  }
100
56
 
101
- async function reopenBee(db) {
102
- if (db.network) db.network.detach(db.bee)
103
- await db.bee.close()
104
- db.bee = null
105
- await db.openBee()
57
+ // self-admission from the fresh core: the same master-signed add-writer op a
58
+ // live device would write, appended optimistically so an unadmitted writer
59
+ // can carry it — apply verifies the attestation and admits the core
60
+ async function claimWriter(db, writerKey, timeout) {
61
+ const encoded = db.spec.dispatch.encode(`@${db.ns}/add-writer`, {
62
+ master: db.identity.publicKey,
63
+ writer: writerKey,
64
+ sig: db.identity.sign(addWriterPayload(db.key, writerKey, db.writerKey)),
65
+ ts: Date.now()
66
+ })
67
+ await db.bee.append(wrap(db.version, encoded), { optimistic: true })
68
+ await db.bee.update()
69
+ if (!db.bee.writable) await db.whenWritable({ timeout })
70
+ }
71
+
72
+ async function saveDevice(db, writerKey, { name, isMobile }) {
73
+ const ts = Date.now()
74
+ await db.write([
75
+ [
76
+ 'set-device',
77
+ {
78
+ id: toId(writerKey),
79
+ name: name || null,
80
+ isMobile: isMobile === true,
81
+ createdAt: ts,
82
+ updatedAt: ts
83
+ }
84
+ ]
85
+ ])
106
86
  }
107
87
 
108
88
  async function saveWriter(db, writerKey, { name, isMobile }) {
@@ -114,7 +114,11 @@ export function makeDispatcher(
114
114
  if (!isKey(op.master) || !isKey(op.writer) || !isSig(op.sig)) return
115
115
  if (!Identity.verify(op.master, addWriterPayload(ctx.dbKey, op.writer, ctx.key), op.sig)) return
116
116
  if (!(await isGenesis(ctx.view)) && !can(await getRole(ctx.view, op.master), INVITE)) return
117
- await ctx.host.addWriter(op.writer, { isIndexer: op.isIndexer !== false })
117
+ // always an indexer, like claim-writer below. The op's optional isIndexer
118
+ // bool decodes to false when absent (nothing sets it), which silently made
119
+ // every device a weight-1 writer — autobee 2.0 gc's caught-up non-indexer
120
+ // sessions, and a gc'd writer misses later appends until a wakeup revives it
121
+ await ctx.host.addWriter(op.writer, { isIndexer: true })
118
122
  const ts = op.ts || 0
119
123
  await insert(ctx.view, 'devices', `@${ns}/devices`, {
120
124
  id: toId(op.writer),
@@ -230,54 +230,36 @@ export class EpochAutobee extends Autobee {
230
230
  await primeKeyring(this.keyring, this.local)
231
231
  }
232
232
 
233
- // Upstream's drain body with one change: a writer whose next block sits at
234
- // an epoch we haven't learned yet parks (stays pending, so every later
235
- // bump re-examines it) instead of aborting the whole drain — the
236
- // announcement carrying that epoch lives in another writer's core and
237
- // applies on this or a later bump. The catch wraps the whole per-writer
238
- // step so an UNKNOWN_EPOCH surfacing from batch processing parks too
239
- // instead of crashing the bee.
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.
240
239
  async _bumpPendingWriters() {
241
- if (this._catchupMigratedNodes !== null) {
242
- await this._bumpMigratedWriters()
243
- this._catchupMigratedNodes = null
240
+ for (const w of this.writers.pending) this._parkOnUnknownEpoch(w)
241
+ try {
242
+ return await super._bumpPendingWriters()
243
+ } catch (err) {
244
+ if (err?.code !== 'UNKNOWN_EPOCH') throw err
245
+ this._scheduleEpochRetry()
246
+ return false
244
247
  }
248
+ }
245
249
 
246
- let updated = false
247
-
248
- const pending = this.writers.pending.slice()
249
-
250
- for (let i = pending.length - 1; i >= 0; i--) {
251
- const w = pending[i]
252
-
250
+ _parkOnUnknownEpoch(w) {
251
+ if (w._epochParking) return
252
+ w._epochParking = true
253
+ const next = w.next.bind(w)
254
+ w.next = async () => {
253
255
  try {
254
- const batch = await w.next()
255
- if (batch === null) continue
256
-
257
- if (w.isAdded || (w.isRemoved && w.hasReferrals())) {
258
- await this._processBatch(batch)
259
- w.notify(batch)
260
- updated = true
261
- continue
262
- }
263
-
264
- if (this.optimistic && !w.isRemoved && batch[0].optimistic) {
265
- if (!(await this._optimisticBatch(batch))) {
266
- w.removePending()
267
- continue
268
- }
269
- w.notify(batch)
270
- updated = true
271
- continue
272
- }
256
+ return await next()
273
257
  } catch (err) {
274
258
  if (err?.code !== 'UNKNOWN_EPOCH') throw err
275
259
  this._scheduleEpochRetry()
276
- continue
260
+ return null
277
261
  }
278
262
  }
279
-
280
- return updated
281
263
  }
282
264
 
283
265
  // a parked writer produces no wake-up of its own — poke the bee until the
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * First-run device provisioning: mint a device writer keypair, persist it as a
3
3
  * writer, then swap the autobee's local core over to it. With `recovering`,
4
- * waits for the first peer-replicated append before swapping.
4
+ * the swap happens FIRST and admission rides an optimistic append the
5
+ * backfilled genesis core is never appended to (autobee's contract: a writable
6
+ * core has exactly one author, ever; resuming a replicated core is unsupported
7
+ * and drops appends silently).
5
8
  *
6
9
  * @param {import('./index.js').Database} db
7
10
  * @param {{ name?: string | null, isMobile?: boolean, recovering?: boolean }} [opts]
@@ -11,13 +11,20 @@ export function blobEpochKey(entropy: Uint8Array): Uint8Array;
11
11
  * Wire codec for a rotation announcement's envelope list — one sealed box
12
12
  * per remaining member, addressed by member id.
13
13
  */
14
- export const wraps: any;
14
+ export const wraps: c.Encoder<any[], {
15
+ id: string;
16
+ box: Uint8Array<ArrayBufferLike>;
17
+ }[]>;
15
18
  /**
16
19
  * Wire codec for locally persisted / pairing-delivered epoch secrets.
17
20
  * `epoch` is the apply-order sequence; `stamp` is the content-addressed
18
21
  * uint32 written into block headers.
19
22
  */
20
- export const epochEntries: any;
23
+ export const epochEntries: c.Encoder<any[], {
24
+ epoch: number;
25
+ stamp: number;
26
+ entropy: Uint8Array<ArrayBufferLike>;
27
+ }[]>;
21
28
  /**
22
29
  * Per-database registry of rotation epochs. Stamp 0 is the base key era
23
30
  * (no entry needed — it derives from the upstream GENESIS_ENTROPY path).
@@ -83,8 +90,9 @@ export class EpochAutobee {
83
90
  _epochRetryDelay: number;
84
91
  _epochRetrySeen: number;
85
92
  _bootState(): Promise<void>;
86
- _bumpPendingWriters(): Promise<boolean>;
87
- _catchupMigratedNodes: any;
93
+ _bumpPendingWriters(): Promise<any>;
94
+ _parkOnUnknownEpoch(w: any): void;
88
95
  _scheduleEpochRetry(): void;
89
96
  _close(): Promise<any>;
90
97
  }
98
+ import c from 'compact-encoding';
@@ -56,7 +56,7 @@ export class Database extends ReadyResource {
56
56
  verb?: string;
57
57
  }>;
58
58
  version: any;
59
- behind: any;
59
+ behind: number;
60
60
  routes: Record<string, Function>;
61
61
  namespace: string;
62
62
  encryptionKey: Uint8Array<ArrayBufferLike>;