@cero-base/core 1.4.0 → 1.5.1

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.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -121,6 +121,10 @@
121
121
  "crypto": {
122
122
  "bare": "bare-crypto",
123
123
  "default": "crypto"
124
+ },
125
+ "events": {
126
+ "bare": "bare-events",
127
+ "default": "events"
124
128
  }
125
129
  },
126
130
  "scripts": {
@@ -129,28 +133,31 @@
129
133
  "build:types": "rm -rf types && tsc -p .",
130
134
  "pretest": "npm run build:test",
131
135
  "prepublishOnly": "npm run build:types",
132
- "test": "brittle-node test/*.test.js",
133
- "test:bare": "npx bare test/bare-smoke.js"
136
+ "test": "npm run test:node",
137
+ "test:bare": "brittle-bare test/bare.js test/*.test.js",
138
+ "pretest:bare": "npm run build:test",
139
+ "test:node": "brittle-node test/*.test.js"
134
140
  },
135
141
  "dependencies": {
136
142
  "@hyperswarm/secret-stream": "^6.9.1",
137
- "autobee": "^1.0.9",
143
+ "autobee": "^1.0.10",
138
144
  "b4a": "^1.8.1",
139
145
  "bare-crypto": "^1.15.3",
140
- "bare-fs": "^4.7.2",
141
- "bare-path": "^3.0.1",
146
+ "bare-fs": "^4.7.4",
147
+ "bare-path": "^3.1.1",
142
148
  "bip39-mnemonic": "^2.5.0",
143
149
  "blind-pairing": "^2.3.1",
144
- "compact-encoding": "^3.2.0",
145
- "corestore": "^7.10.1",
150
+ "blind-peering": "^2.5.0",
151
+ "compact-encoding": "^3.3.0",
152
+ "corestore": "^7.11.1",
146
153
  "framed-stream": "^1.0.1",
147
154
  "hrpc": "^4.3.0",
148
155
  "hyperblobs": "^2.12.1",
149
- "hypercore": "^11.33.1",
150
- "hypercore-blob-server": "^1.12.0",
156
+ "hypercore": "^11.34.1",
157
+ "hypercore-blob-server": "^1.15.0",
151
158
  "hypercore-crypto": "^3.7.0",
152
159
  "hypercore-id-encoding": "^1.3.0",
153
- "hypercore-storage": "^3.1.1",
160
+ "hypercore-storage": "^3.2.0",
154
161
  "hyperdb": "^6.7.0",
155
162
  "hyperdispatch": "^1.6.0",
156
163
  "hyperschema": "^1.21.0",
@@ -165,7 +172,13 @@
165
172
  },
166
173
  "devDependencies": {
167
174
  "@hyperswarm/testnet": "^3.1.4",
168
- "brittle": "^4.0.2",
175
+ "bare-abort-controller": "^1.1.2",
176
+ "bare-events": "^2.9.1",
177
+ "bare-fetch": "^3.2.0",
178
+ "bare-process": "^4.5.1",
179
+ "bare-url": "^2.4.6",
180
+ "blind-peer": "^3.12.3",
181
+ "brittle": "^4.1.0",
169
182
  "typescript": "^5.9.3"
170
183
  },
171
184
  "license": "Apache-2.0"
@@ -21,16 +21,39 @@ export async function bootstrap(db, { name, isMobile, recovering = false, timeou
21
21
  }
22
22
  const writerKey = Hypercore.key(manifest)
23
23
 
24
- if (recovering) await waitForFirstPeerAppend(db, timeout)
25
- await saveWriter(db, writerKey, { name, isMobile })
24
+ 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)
37
+ } else {
38
+ await saveWriter(db, writerKey, { name, isMobile })
39
+ }
26
40
  await swapWriter(db, keyPair, manifest)
27
41
 
28
42
  return { id: writerKey, writer: keyPair }
29
43
  }
30
44
 
31
45
  async function waitForFirstPeerAppend(db, timeout = 30000) {
32
- if (db.bee.local.length > 0 || !db.network?.swarm) return
33
- await new Promise((resolve, reject) => {
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) => {
34
57
  let done = false
35
58
  const finish = (err) => {
36
59
  if (done) return
@@ -43,7 +66,7 @@ async function waitForFirstPeerAppend(db, timeout = 30000) {
43
66
  }
44
67
  // timeout + close handling so a peer that never replicates can't hang the
45
68
  // recovery forever, and the append listener is always removed.
46
- const onAppend = () => db.bee.local.length > 0 && finish()
69
+ const onAppend = () => db.bee.local.length >= length && finish()
47
70
  const onClose = () => finish(CeroError.CLOSED('Database'))
48
71
  const timer = setTimeout(
49
72
  () => finish(CeroError.TIMED_OUT('recovery — no peer append')),
@@ -52,17 +75,46 @@ async function waitForFirstPeerAppend(db, timeout = 30000) {
52
75
  db.bee.local.on('append', onAppend)
53
76
  db.on('close', onClose)
54
77
  })
55
- await db.bee.update()
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) {
91
+ const deadline = Date.now() + timeout
92
+ while (Date.now() < deadline) {
93
+ const { data } = await db.get('devices', id)
94
+ if (data) return true
95
+ await db.bee.update()
96
+ await new Promise((resolve) => setTimeout(resolve, 25))
97
+ }
98
+ return false
99
+ }
100
+
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()
56
106
  }
57
107
 
58
108
  async function saveWriter(db, writerKey, { name, isMobile }) {
109
+ const ts = Date.now()
59
110
  await db.write([
60
111
  [
61
112
  'add-writer',
62
113
  {
63
114
  master: db.identity.publicKey,
64
115
  writer: writerKey,
65
- sig: db.identity.sign(addWriterPayload(db.key, writerKey, db.writerKey))
116
+ sig: db.identity.sign(addWriterPayload(db.key, writerKey, db.writerKey)),
117
+ ts
66
118
  }
67
119
  ],
68
120
  [
@@ -70,7 +122,9 @@ async function saveWriter(db, writerKey, { name, isMobile }) {
70
122
  {
71
123
  id: toId(writerKey),
72
124
  name: name || null,
73
- isMobile: isMobile === true
125
+ isMobile: isMobile === true,
126
+ createdAt: ts,
127
+ updatedAt: ts
74
128
  }
75
129
  ]
76
130
  ])
@@ -0,0 +1,120 @@
1
+ import { Readable } from 'streamx'
2
+ import b4a from 'b4a'
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
+ /**
10
+ * 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`.
16
+ *
17
+ * @param {import('./index.js').Database} db
18
+ * @param {string} name Ref name (scopes the update ticks).
19
+ * @param {string} col Collection path (`@ns/name`).
20
+ * @param {(row: any) => boolean} matches
21
+ * @returns {import('streamx').Readable}
22
+ */
23
+ export function makeChanges(db, name, col, matches) {
24
+ let cursor = null
25
+ let cursorView = null
26
+ let dirty = true
27
+ let busy = false
28
+ let off = null
29
+
30
+ const stream = new Readable({
31
+ open(cb) {
32
+ off = db.onUpdate(mark, name)
33
+ pump()
34
+ cb(null)
35
+ },
36
+ read(cb) {
37
+ pump()
38
+ cb(null)
39
+ },
40
+ predestroy() {
41
+ if (off) off()
42
+ },
43
+ destroy(cb) {
44
+ release().then(() => cb(null), cb)
45
+ }
46
+ })
47
+
48
+ const mark = () => {
49
+ dirty = true
50
+ pump()
51
+ }
52
+
53
+ const pump = () => {
54
+ if (busy || !dirty || stream.destroyed) return
55
+ busy = true
56
+ run()
57
+ .catch((err) => stream.destroy(err))
58
+ .finally(() => {
59
+ busy = false
60
+ })
61
+ }
62
+
63
+ const run = async () => {
64
+ while (dirty && !stream.destroyed) {
65
+ dirty = false
66
+ const batch = await diff()
67
+ if (batch && stream.push(batch) === false) return
68
+ }
69
+ }
70
+
71
+ const diff = async () => {
72
+ const snap = db.view.snapshot()
73
+ 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) {
84
+ 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
+ }
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
+ }
118
+
119
+ return stream
120
+ }
@@ -105,7 +105,7 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
105
105
  if (!Identity.verify(op.master, addWriterPayload(ctx.dbKey, op.writer, ctx.key), op.sig)) return
106
106
  if (!(await isGenesis(ctx.view)) && !can(await getRole(ctx.view, op.master), INVITE)) return
107
107
  await ctx.host.addWriter(op.writer, { isIndexer: op.isIndexer !== false })
108
- const ts = Date.now()
108
+ const ts = op.ts || 0
109
109
  await insert(ctx.view, 'devices', `@${ns}/devices`, {
110
110
  id: toId(op.writer),
111
111
  memberId: toId(op.master),
@@ -131,7 +131,7 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
131
131
  const memberId = toId(op.identity)
132
132
  if (!can(await getRole(ctx.view, op.identity), WRITE)) return
133
133
  await ctx.host.addWriter(op.writer, { isIndexer: true })
134
- const ts = Date.now()
134
+ const ts = op.ts || 0
135
135
  await insert(ctx.view, 'devices', `@${ns}/devices`, {
136
136
  id: toId(op.writer),
137
137
  memberId,
@@ -149,13 +149,13 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
149
149
  await insert(ctx.view, 'members', `@${ns}/members`, op)
150
150
  const deviceId = toId(op.key)
151
151
  const existingDevice = await getDevice(ctx.view, deviceId)
152
- const ts = Date.now()
152
+ const ts = op.updatedAt || 0
153
153
  await insert(ctx.view, 'devices', `@${ns}/devices`, {
154
154
  id: deviceId,
155
155
  memberId: op.id,
156
156
  name: existingDevice?.name ?? null,
157
157
  isMobile: existingDevice?.isMobile ?? false,
158
- createdAt: existingDevice?.createdAt ?? ts,
158
+ createdAt: existingDevice?.createdAt || op.createdAt || ts,
159
159
  updatedAt: ts
160
160
  })
161
161
  })
@@ -203,11 +203,11 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
203
203
  })
204
204
  add(`set-${b.verb}`, async (op, ctx) => {
205
205
  const existing = await getDevice(ctx.view, op.id)
206
- const ts = Date.now()
206
+ const ts = op.updatedAt || 0
207
207
  await insert(ctx.view, b.name, col, {
208
208
  ...op,
209
209
  memberId: existing?.memberId ?? op.memberId ?? null,
210
- createdAt: existing?.createdAt ?? ts,
210
+ createdAt: existing?.createdAt || op.createdAt || ts,
211
211
  updatedAt: ts
212
212
  })
213
213
  })
@@ -0,0 +1,32 @@
1
+ import b4a from 'b4a'
2
+ import c from 'compact-encoding'
3
+
4
+ // 0xff is the c.uint prefix for a 64-bit route id — unreachable for real
5
+ // hyperdispatch routes, so it safely marks a version-enveloped op. Peers too
6
+ // old to know the envelope throw on it and skip deterministically.
7
+ const SENTINEL = 0xff
8
+
9
+ /**
10
+ * Prefix an encoded op with the app's contract version.
11
+ *
12
+ * @param {number} version
13
+ * @param {Uint8Array} body
14
+ * @returns {Uint8Array}
15
+ */
16
+ export function wrap(version, body) {
17
+ return b4a.concat([b4a.from([SENTINEL]), c.encode(c.uint, version), body])
18
+ }
19
+
20
+ /**
21
+ * Split an op into contract version and payload. Ops written before the
22
+ * envelope existed carry no sentinel and read as version 0.
23
+ *
24
+ * @param {Uint8Array} buf
25
+ * @returns {{ version: number, body: Uint8Array }}
26
+ */
27
+ export function unwrap(buf) {
28
+ if (buf.byteLength === 0 || buf[0] !== SENTINEL) return { version: 0, body: buf }
29
+ const state = { buffer: buf, start: 1, end: buf.byteLength }
30
+ const version = c.uint.decode(state)
31
+ return { version, body: buf.subarray(state.start) }
32
+ }
@@ -1,5 +1,6 @@
1
1
  import Autobee from 'autobee'
2
2
  import HyperDB from 'hyperdb'
3
+ import c from 'compact-encoding'
3
4
  import Hypercore from 'hypercore'
4
5
  import ReadyResource from 'ready-resource'
5
6
  import safetyCatch from 'safety-catch'
@@ -7,8 +8,10 @@ import b4a from 'b4a'
7
8
 
8
9
  import { NAMESPACE, SINGLE, COLLECTION, ACTION, ACTIVE, PASSIVE } from '../lib/constants.js'
9
10
  import { genId, subscribe, addWriterPayload, claimWriterPayload } from '../lib/utils.js'
11
+ import { wrap, unwrap } from './envelope.js'
10
12
  import { CeroError } from '../lib/errors.js'
11
13
  import { bootstrap } from './bootstrap.js'
14
+ import { makeChanges } from './changes.js'
12
15
  import { makeDispatcher } from './dispatch.js'
13
16
 
14
17
  /**
@@ -57,6 +60,8 @@ export class Database extends ReadyResource {
57
60
  this.meta = opts.spec.meta || { ns: NAMESPACE, refs: {} }
58
61
  this.ns = this.meta.ns || NAMESPACE
59
62
  this.refs = this.meta.refs || {}
63
+ this.version = this.meta.version || 1
64
+ this.behind = null
60
65
  this.routes = opts.routes || {}
61
66
  this.namespace = opts.namespace || NAMESPACE
62
67
  this.encryptionKey = opts.encryptionKey || opts.identity.encryptionKey || null
@@ -124,9 +129,21 @@ export class Database extends ReadyResource {
124
129
  wakeup: wakeup || undefined,
125
130
  open: (b) => HyperDB.bee2(b, this.spec.database, { autoUpdate: true }),
126
131
  apply: async (nodes, view, host) => {
127
- const result = await (this.applyOverride || this.dispatcher.apply)(nodes, view, host)
128
- if (this.updaters.size) this._touch(nodes)
129
- if (this.onApplyHooks.size) this._observe(nodes)
132
+ // envelope handling lives here, once: unwrap every node and skip ops
133
+ // from a newer app version (deterministic — they stay in the log), so
134
+ // the dispatcher AND custom apply overrides see plain op bytes
135
+ const ready = []
136
+ for (const node of nodes) {
137
+ const { version, body } = unwrap(node.value)
138
+ if (version > this.version) {
139
+ this._onFuture(version)
140
+ continue
141
+ }
142
+ ready.push(version === 0 ? node : { ...node, value: body })
143
+ }
144
+ const result = await (this.applyOverride || this.dispatcher.apply)(ready, view, host)
145
+ if (this.updaters.size) this._touch(ready)
146
+ if (this.onApplyHooks.size) this._observe(ready)
130
147
  return result
131
148
  },
132
149
  update: async (db) => {
@@ -149,6 +166,21 @@ export class Database extends ReadyResource {
149
166
  this.bee = bee
150
167
  this.key = bee.key
151
168
 
169
+ const behind = await bee.local.getUserData('cero/behind')
170
+ this.behind = behind ? c.decode(c.uint, behind) : null
171
+
172
+ // ops we once skipped are now within our contract — wipe the boot record
173
+ // so the next open replays the whole log through the current handlers
174
+ if (this.behind !== null && this.behind <= this.version) {
175
+ await bee.local.setUserData('autobee/head', null)
176
+ await bee.local.setUserData('cero/behind', null)
177
+ await bee.close()
178
+ this.behind = null
179
+ await this.openBee()
180
+ this.emit('rebuild')
181
+ return
182
+ }
183
+
152
184
  bee.on('writable', () => this.emit('writable'))
153
185
  // without a listener autobee escalates apply/view errors to a process crash
154
186
  bee.on('error', this._onerror)
@@ -503,7 +535,49 @@ export class Database extends ReadyResource {
503
535
  const encoded = ops.map(([op, payload]) =>
504
536
  this.spec.dispatch.encode(`@${this.ns}/${op}`, payload)
505
537
  )
506
- await this.bee.append(encoded.length === 1 ? encoded[0] : encoded)
538
+ await this._dryRun(encoded)
539
+ const wrapped = encoded.map((value) => wrap(this.version, value))
540
+ await this.bee.append(wrapped.length === 1 ? wrapped[0] : wrapped)
541
+ }
542
+
543
+ /**
544
+ * Record that the log contains ops from a newer app version than this
545
+ * peer understands. Fires `'behind'` once per version so apps can prompt
546
+ * an upgrade; the marker survives restarts via local core userData.
547
+ *
548
+ * @param {number} version
549
+ */
550
+ _onFuture(version) {
551
+ if (this.behind !== null && version <= this.behind) return
552
+ this.behind = version
553
+ this.bee.local.setUserData('cero/behind', c.encode(c.uint, version)).catch(this._onerror)
554
+ this.emit('behind', version)
555
+ }
556
+
557
+ /**
558
+ * Validate ops against the committed view before appending: each op runs in
559
+ * a throwaway transaction with host effects stubbed, so a throwing handler
560
+ * rejects the write and nothing enters the permanent log. Permission gates
561
+ * that `return` are not rejections — apply stays the authority at
562
+ * linearization time.
563
+ *
564
+ * @param {Uint8Array[]} encoded
565
+ */
566
+ async _dryRun(encoded) {
567
+ const tx = this.view.transaction()
568
+ const host = { addWriter: async () => {}, removeWriter: async () => {} }
569
+ try {
570
+ for (const value of encoded) {
571
+ await this.dispatcher.dispatcher.dispatch(value, {
572
+ view: tx,
573
+ host,
574
+ key: this.writerKey,
575
+ dbKey: this.key
576
+ })
577
+ }
578
+ } finally {
579
+ await tx.close()
580
+ }
507
581
  }
508
582
 
509
583
  /**
@@ -634,6 +708,31 @@ export class Database extends ReadyResource {
634
708
  })
635
709
  }
636
710
 
711
+ /**
712
+ * Delta subscription: batches of `{ prev, next }` row pairs instead of
713
+ * full snapshots. The first batch (and any batch after a view swap) carries
714
+ * the current matching rows as inserts with `reset: true` — replaying every
715
+ * batch into a Map keyed by row id always reconstructs current state.
716
+ * `limit`/`reverse` are not applied; deltas are unwindowed by design.
717
+ *
718
+ * @param {string} name
719
+ * @param {Query} [query]
720
+ * @returns {import('streamx').Readable}
721
+ */
722
+ changes(name, query = {}) {
723
+ this.guard()
724
+ const col = `@${this.ns}/${name}`
725
+ if (!this.view.definition.resolveCollection(col)) {
726
+ throw CeroError.UNKNOWN('collection', name)
727
+ }
728
+ const filter = { ...query }
729
+ delete filter.limit
730
+ delete filter.reverse
731
+ delete filter.total
732
+ const matches = (row) => paginate([row], filter).length > 0
733
+ return makeChanges(this, name, col, matches)
734
+ }
735
+
637
736
  /**
638
737
  * First-run bootstrap: create the device writer, save it, and swap into it.
639
738
  *
@@ -661,9 +760,10 @@ export class Database extends ReadyResource {
661
760
  const encoded = this.spec.dispatch.encode(`@${this.ns}/claim-writer`, {
662
761
  identity: this.identity.publicKey,
663
762
  writer: writerKey,
664
- sig
763
+ sig,
764
+ ts: Date.now()
665
765
  })
666
- await this.bee.append(encoded, { optimistic: true })
766
+ await this.bee.append(wrap(this.version, encoded), { optimistic: true })
667
767
  await this.bee.update()
668
768
  if (!this.bee.writable) await this.whenWritable()
669
769
  }
@@ -826,7 +926,9 @@ export class Database extends ReadyResource {
826
926
  signers: [{ publicKey }]
827
927
  })
828
928
  const sig = this.identity.sign(addWriterPayload(this.key, writerKey, this.writerKey))
829
- await this.write([[verb, { master: this.identity.publicKey, writer: writerKey, sig }]])
929
+ await this.write([
930
+ [verb, { master: this.identity.publicKey, writer: writerKey, sig, ts: Date.now() }]
931
+ ])
830
932
  await this.runAfter(hook, ctx)
831
933
  }
832
934
  }
@@ -1,12 +1,12 @@
1
1
  // This file is autogenerated by the hyperschema compiler
2
- // Schema Version: 1
2
+ // Schema Version: 2
3
3
  /* eslint-disable camelcase */
4
4
  /* eslint-disable quotes */
5
5
  /* eslint-disable space-before-function-paren */
6
6
 
7
7
  import { c } from 'hyperschema/runtime'
8
8
 
9
- const VERSION = 1
9
+ const VERSION = 2
10
10
 
11
11
  // eslint-disable-next-line no-unused-vars
12
12
  let version = VERSION
@@ -274,6 +274,37 @@ const encoding6 = {
274
274
  }
275
275
  }
276
276
 
277
+ // @cero/changes.prev
278
+ const encoding7_0 = encoding3_0
279
+ // @cero/changes.next
280
+ const encoding7_1 = encoding3_0
281
+
282
+ // @cero/changes
283
+ const encoding7 = {
284
+ preencode(state, m) {
285
+ state.end++ // max flag is 2 so always one byte
286
+
287
+ if (version >= 2 && m.prev) encoding7_0.preencode(state, m.prev)
288
+ if (version >= 2 && m.next) encoding7_1.preencode(state, m.next)
289
+ },
290
+ encode(state, m) {
291
+ const flags = ((version >= 2 && m.prev) ? 1 : 0) | ((version >= 2 && m.next) ? 2 : 0)
292
+
293
+ c.uint.encode(state, flags)
294
+
295
+ if (version >= 2 && m.prev) encoding7_0.encode(state, m.prev)
296
+ if (version >= 2 && m.next) encoding7_1.encode(state, m.next)
297
+ },
298
+ decode(state) {
299
+ const flags = c.uint.decode(state)
300
+
301
+ return {
302
+ prev: (version >= 2 && (flags & 1) !== 0) ? encoding7_0.decode(state) : null,
303
+ next: (version >= 2 && (flags & 2) !== 0) ? encoding7_1.decode(state) : null
304
+ }
305
+ }
306
+ }
307
+
277
308
  function setVersion(v) {
278
309
  version = v
279
310
  }
@@ -311,6 +342,8 @@ function getEncoding(name) {
311
342
  return encoding5
312
343
  case '@cero/blob-id':
313
344
  return encoding6
345
+ case '@cero/changes':
346
+ return encoding7
314
347
  default:
315
348
  throw new Error('Encoder not found ' + name)
316
349
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 1,
2
+ "version": 2,
3
3
  "schema": [
4
4
  {
5
5
  "name": "invite-body",
@@ -273,6 +273,28 @@
273
273
  "version": 1
274
274
  }
275
275
  ]
276
+ },
277
+ {
278
+ "name": "changes",
279
+ "namespace": "cero",
280
+ "compact": false,
281
+ "flagsPosition": 0,
282
+ "fields": [
283
+ {
284
+ "name": "prev",
285
+ "required": false,
286
+ "array": true,
287
+ "type": "buffer",
288
+ "version": 2
289
+ },
290
+ {
291
+ "name": "next",
292
+ "required": false,
293
+ "array": true,
294
+ "type": "buffer",
295
+ "version": 2
296
+ }
297
+ ]
276
298
  }
277
299
  ]
278
300
  }
@@ -1,8 +1,10 @@
1
1
  import NoiseSecretStream from '@hyperswarm/secret-stream'
2
2
  import BlindPairing from 'blind-pairing'
3
+ import BlindPeering from 'blind-peering'
3
4
  import ProtomuxWakeup from 'protomux-wakeup'
4
5
  import ReadyResource from 'ready-resource'
5
6
  import safetyCatch from 'safety-catch'
7
+ import { decode as decodeKey } from 'hypercore-id-encoding'
6
8
  import b4a from 'b4a'
7
9
 
8
10
  import { ACTIVE, PASSIVE } from '../lib/constants.js'
@@ -19,6 +21,8 @@ export { channelTopic }
19
21
  * @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
20
22
  * @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
21
23
  * @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
24
+ * @property {any} [store] Corestore; required for mirrors (blind peers replicate its cores).
25
+ * @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys; each attached room/blob core is mirrored through them for offline sync.
22
26
  *
23
27
  * @typedef {{ replicate: (stream: any) => any }} Replicable
24
28
  */
@@ -29,13 +33,15 @@ export { channelTopic }
29
33
  */
30
34
  export class Network extends ReadyResource {
31
35
  /** @param {NetworkOpts} [opts] */
32
- constructor({ identity, bootstrap, firewall, relayThrough, channel } = {}) {
36
+ constructor({ identity, bootstrap, firewall, relayThrough, channel, store, mirrors } = {}) {
33
37
  super()
34
38
  this.identity = identity || null
35
39
  this.bootstrap = bootstrap || null
36
40
  this.firewall = firewall || null
37
41
  this.relayThrough = relayThrough || null
38
42
  this.channel = channel || null
43
+ this.store = store || null
44
+ this.mirrors = (mirrors || []).map((k) => (typeof k === 'string' ? decodeKey(k) : k))
39
45
 
40
46
  this._dht = null
41
47
  this.wakeup = new ProtomuxWakeup()
@@ -44,6 +50,7 @@ export class Network extends ReadyResource {
44
50
  this._discoveries = new Set()
45
51
  this._injected = new Set()
46
52
  this._blind = null
53
+ this._blindPeering = null
47
54
  }
48
55
 
49
56
  /** @returns {any} The underlying hyperswarm, or null before ready / after close. */
@@ -171,6 +178,14 @@ export class Network extends ReadyResource {
171
178
  })
172
179
  swarm.on('peer-add', (peer) => this.emit('peer-add', peer))
173
180
  swarm.on('peer-remove', (peer) => this.emit('peer-remove', peer))
181
+
182
+ if (this.store && this.mirrors.length) {
183
+ this._blindPeering = new BlindPeering(swarm.dht, this.store, {
184
+ keys: this.mirrors,
185
+ wakeup: this.wakeup,
186
+ pick: 2
187
+ })
188
+ }
174
189
  }
175
190
 
176
191
  /**
@@ -190,6 +205,7 @@ export class Network extends ReadyResource {
190
205
  */
191
206
  async suspend() {
192
207
  if (this.closing || this.closed) return
208
+ await this._blindPeering?.suspend()
193
209
  await this._dht?.suspend()
194
210
  }
195
211
 
@@ -201,6 +217,7 @@ export class Network extends ReadyResource {
201
217
  async resume() {
202
218
  if (this.closing || this.closed) return
203
219
  await this._dht?.resume()
220
+ await this._blindPeering?.resume()
204
221
  }
205
222
 
206
223
  async _close() {
@@ -221,6 +238,15 @@ export class Network extends ReadyResource {
221
238
  }
222
239
  }
223
240
 
241
+ if (this._blindPeering) {
242
+ try {
243
+ await this._blindPeering.close()
244
+ } catch (err) {
245
+ safetyCatch(err)
246
+ }
247
+ this._blindPeering = null
248
+ }
249
+
224
250
  if (this._blind) {
225
251
  try {
226
252
  await (await this._blind).close()
@@ -284,6 +310,12 @@ export class Network extends ReadyResource {
284
310
  if (!core) throw CeroError.REQUIRED('core')
285
311
  this._replicateables.add(core)
286
312
  for (const stream of this.connections) replicateInto(core, stream)
313
+ // mirror the core so it stays available when its writers are offline —
314
+ // autobees announce their whole writer set, plain cores (blobs) just themselves
315
+ if (this._blindPeering) {
316
+ if (core.wakeupCapability) this._blindPeering.addAutobaseBackground(core)
317
+ else this._blindPeering.addCoreBackground(core)
318
+ }
287
319
  }
288
320
 
289
321
  /**
@@ -18,7 +18,8 @@ const TYPE_HELLO = 4
18
18
  const SID_LEN = 8
19
19
  const HEADER = 1 + SID_LEN
20
20
 
21
- const DEFAULT_CAP = 4
21
+ const DEFAULT_MAX_OUTBOUND = 4
22
+ const DEFAULT_MAX_INBOUND = 8
22
23
  const CONNECT_TIMEOUT = 15000
23
24
  // per-peer dial backoff: eager while unlinked, patient once linked; the cooldown
24
25
  // grows exponentially per consecutive failure.
@@ -126,7 +127,8 @@ export class BLETransport extends ReadyResource {
126
127
  * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
127
128
  * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
128
129
  * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
129
- * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
130
+ * @param {number} [opts.maxOutbound] Max concurrent outbound dials/links; gossip covers the rest.
131
+ * @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
130
132
  * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
131
133
  * @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
132
134
  * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
@@ -137,7 +139,8 @@ export class BLETransport extends ReadyResource {
137
139
  uuid,
138
140
  nodeId,
139
141
  tag = 'cero-ble',
140
- cap = DEFAULT_CAP,
142
+ maxOutbound = DEFAULT_MAX_OUTBOUND,
143
+ maxInbound = DEFAULT_MAX_INBOUND,
141
144
  scanOptions,
142
145
  keepLinks = false,
143
146
  name
@@ -149,7 +152,8 @@ export class BLETransport extends ReadyResource {
149
152
  this.nodeId = nodeId
150
153
  this.nodeHex = b4a.toString(nodeId, 'hex')
151
154
  this.serviceUUID = toServiceUUID(uuid, tag)
152
- this.cap = cap
155
+ this.maxOutbound = maxOutbound
156
+ this.maxInbound = maxInbound
153
157
  this.scanOptions = scanOptions
154
158
  this.keepLinks = keepLinks
155
159
 
@@ -271,6 +275,12 @@ export class BLETransport extends ReadyResource {
271
275
  if (!f) return
272
276
  if (f.type === TYPE_OPEN) {
273
277
  if (this._sessions.has(f.sidHex)) return
278
+ if (this._sessions.size >= this.maxInbound) {
279
+ // established links win: refuse newcomers with a CLOSE so the dialer's
280
+ // stream ends cleanly and backs off — the mesh converges transitively.
281
+ this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
282
+ return
283
+ }
274
284
  const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
275
285
  const stream = new GattStream({
276
286
  send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
@@ -414,7 +424,7 @@ export class BLETransport extends ReadyResource {
414
424
  if (d.coolUntil > Date.now()) return // failed recently — back off
415
425
  if (d.timer) return // already connecting to this one
416
426
  }
417
- if (this.linkCount >= this.cap) return // gossip covers the rest
427
+ if (this.linkCount >= this.maxOutbound) return // gossip covers the rest
418
428
  if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
419
429
  // dial every discovery and open a session; a redundant link is dropped by
420
430
  // _track's dedup
package/src/rpc/index.js CHANGED
@@ -12,6 +12,7 @@ const EMPTY = b4a.alloc(0)
12
12
  // Default envelope encodings for the @cero namespace, used when the supplied
13
13
  // spec does not provide its own rows/query/create types in its schema.
14
14
  const DEFAULT_ROWS = getEncoding('@cero/rows')
15
+ const DEFAULT_CHANGES = getEncoding('@cero/changes')
15
16
  const DEFAULT_QUERY = getEncoding('@cero/query')
16
17
  const DEFAULT_CREATE = getEncoding('@cero/create')
17
18
 
@@ -26,6 +27,8 @@ const DEFAULT_CREATE = getEncoding('@cero/create')
26
27
  * @property {(type: string, row: any) => Uint8Array} encodeRow
27
28
  * @property {(type: string, buf: Uint8Array) => any} decodeRow
28
29
  * @property {(type: string, rows: any[]) => Uint8Array} encodeRows
30
+ * @property {(type: string, changes: Array<{ prev: any, next: any }>) => Uint8Array} encodeChanges
31
+ * @property {(type: string, buf: Uint8Array) => Array<{ prev: any, next: any }>} decodeChanges
29
32
  * @property {(type: string, buf: Uint8Array) => any[]} decodeRows
30
33
  * @property {(q: any) => Uint8Array} encodeQuery
31
34
  * @property {(buf: Uint8Array) => any} decodeQuery
@@ -51,6 +54,7 @@ export function bindCodec(spec) {
51
54
 
52
55
  const ns = spec.meta?.ns
53
56
  const ROWS = ns ? `@${ns}/rows` : null
57
+ const CHANGES = ns ? `@${ns}/changes` : null
54
58
  const QUERY = ns ? `@${ns}/query` : null
55
59
  const CREATE = ns ? `@${ns}/create` : null
56
60
 
@@ -73,6 +77,25 @@ export function bindCodec(spec) {
73
77
  const data = env?.data || []
74
78
  return data.map((b) => schema.decode(type, b))
75
79
  },
80
+ encodeChanges(type, changes) {
81
+ const prev = changes.map((x) => (x.prev ? schema.encode(type, x.prev) : EMPTY))
82
+ const next = changes.map((x) => (x.next ? schema.encode(type, x.next) : EMPTY))
83
+ return encodeEnvelope(schema, CHANGES, DEFAULT_CHANGES, { prev, next })
84
+ },
85
+ decodeChanges(type, buf) {
86
+ if (!buf || buf.length === 0) return []
87
+ const env = decodeEnvelope(schema, CHANGES, DEFAULT_CHANGES, buf)
88
+ const prev = env?.prev || []
89
+ const next = env?.next || []
90
+ const out = []
91
+ for (let i = 0; i < Math.max(prev.length, next.length); i++) {
92
+ out.push({
93
+ prev: prev[i]?.length ? schema.decode(type, prev[i]) : null,
94
+ next: next[i]?.length ? schema.decode(type, next[i]) : null
95
+ })
96
+ }
97
+ return out
98
+ },
76
99
  encodeQuery(q) {
77
100
  if (q == null) return encodeEnvelope(schema, QUERY, DEFAULT_QUERY, {})
78
101
  const { gt, gte, lt, lte, limit, reverse, ...rest } = q
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Pull-driven delta stream over a collection: batches of `{ prev, next }`
3
+ * pairs diffed between per-stream snapshot cursors. Update ticks only mark
4
+ * the stream dirty — the diff runs when the reader demands, so backpressure
5
+ * folds bursts into one bigger batch and nothing is ever dropped. The first
6
+ * batch, and any batch after the view is swapped or fast-forwarded to a new
7
+ * core, carries the full matching state as inserts with `reset: true`.
8
+ *
9
+ * @param {import('./index.js').Database} db
10
+ * @param {string} name Ref name (scopes the update ticks).
11
+ * @param {string} col Collection path (`@ns/name`).
12
+ * @param {(row: any) => boolean} matches
13
+ * @returns {import('streamx').Readable}
14
+ */
15
+ export function makeChanges(db: import("./index.js").Database, name: string, col: string, matches: (row: any) => boolean): import("streamx").Readable;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Prefix an encoded op with the app's contract version.
3
+ *
4
+ * @param {number} version
5
+ * @param {Uint8Array} body
6
+ * @returns {Uint8Array}
7
+ */
8
+ export function wrap(version: number, body: Uint8Array): Uint8Array;
9
+ /**
10
+ * Split an op into contract version and payload. Ops written before the
11
+ * envelope existed carry no sentinel and read as version 0.
12
+ *
13
+ * @param {Uint8Array} buf
14
+ * @returns {{ version: number, body: Uint8Array }}
15
+ */
16
+ export function unwrap(buf: Uint8Array): {
17
+ version: number;
18
+ body: Uint8Array;
19
+ };
@@ -54,6 +54,8 @@ export class Database extends ReadyResource {
54
54
  kind?: string;
55
55
  verb?: string;
56
56
  }>;
57
+ version: any;
58
+ behind: any;
57
59
  routes: Record<string, Function>;
58
60
  namespace: string;
59
61
  encryptionKey: Uint8Array<ArrayBufferLike>;
@@ -216,6 +218,24 @@ export class Database extends ReadyResource {
216
218
  * @returns {Promise<void>}
217
219
  */
218
220
  write(ops: Array<[string, any]>): Promise<void>;
221
+ /**
222
+ * Record that the log contains ops from a newer app version than this
223
+ * peer understands. Fires `'behind'` once per version so apps can prompt
224
+ * an upgrade; the marker survives restarts via local core userData.
225
+ *
226
+ * @param {number} version
227
+ */
228
+ _onFuture(version: number): void;
229
+ /**
230
+ * Validate ops against the committed view before appending: each op runs in
231
+ * a throwaway transaction with host effects stubbed, so a throwing handler
232
+ * rejects the write and nothing enters the permanent log. Permission gates
233
+ * that `return` are not rejections — apply stays the authority at
234
+ * linearization time.
235
+ *
236
+ * @param {Uint8Array[]} encoded
237
+ */
238
+ _dryRun(encoded: Uint8Array[]): Promise<void>;
219
239
  /**
220
240
  * Read a row. With no `query`: list all (collection) or fetch the one
221
241
  * record (single). With a string id: fetch that specific row.
@@ -247,6 +267,18 @@ export class Database extends ReadyResource {
247
267
  * @returns {import('streamx').Readable}
248
268
  */
249
269
  watch(name: string, query?: Query): import("streamx").Readable;
270
+ /**
271
+ * Delta subscription: batches of `{ prev, next }` row pairs instead of
272
+ * full snapshots. The first batch (and any batch after a view swap) carries
273
+ * the current matching rows as inserts with `reset: true` — replaying every
274
+ * batch into a Map keyed by row id always reconstructs current state.
275
+ * `limit`/`reverse` are not applied; deltas are unwindowed by design.
276
+ *
277
+ * @param {string} name
278
+ * @param {Query} [query]
279
+ * @returns {import('streamx').Readable}
280
+ */
281
+ changes(name: string, query?: Query): import("streamx").Readable;
250
282
  /**
251
283
  * First-run bootstrap: create the device writer, save it, and swap into it.
252
284
  *
@@ -2,12 +2,8 @@ export function resolveStruct(name: any, v?: number): {
2
2
  preencode(state: any, m: any): void;
3
3
  encode(state: any, m: any): void;
4
4
  decode(state: any): {
5
- coreKey: any;
6
- blockOffset: any;
7
- blockLength: any;
8
- byteOffset: any;
9
- byteLength: any;
10
- type: any;
5
+ prev: any;
6
+ next: any;
11
7
  } | {
12
8
  status: any;
13
9
  reason: any;
@@ -21,18 +17,21 @@ export function resolveStruct(name: any, v?: number): {
21
17
  name: any;
22
18
  role: any;
23
19
  noAccept: boolean;
24
- };
25
- };
26
- export function getStruct(name: any, v?: number): {
27
- preencode(state: any, m: any): void;
28
- encode(state: any, m: any): void;
29
- decode(state: any): {
20
+ } | {
30
21
  coreKey: any;
31
22
  blockOffset: any;
32
23
  blockLength: any;
33
24
  byteOffset: any;
34
25
  byteLength: any;
35
26
  type: any;
27
+ };
28
+ };
29
+ export function getStruct(name: any, v?: number): {
30
+ preencode(state: any, m: any): void;
31
+ encode(state: any, m: any): void;
32
+ decode(state: any): {
33
+ prev: any;
34
+ next: any;
36
35
  } | {
37
36
  status: any;
38
37
  reason: any;
@@ -46,6 +45,13 @@ export function getStruct(name: any, v?: number): {
46
45
  name: any;
47
46
  role: any;
48
47
  noAccept: boolean;
48
+ } | {
49
+ coreKey: any;
50
+ blockOffset: any;
51
+ blockLength: any;
52
+ byteOffset: any;
53
+ byteLength: any;
54
+ type: any;
49
55
  };
50
56
  };
51
57
  export function getEnum(name: any): void;
@@ -85,6 +91,13 @@ export function getEncoding(name: any): {
85
91
  byteLength: any;
86
92
  type: any;
87
93
  };
94
+ } | {
95
+ preencode(state: any, m: any): void;
96
+ encode(state: any, m: any): void;
97
+ decode(state: any): {
98
+ prev: any;
99
+ next: any;
100
+ };
88
101
  };
89
102
  export function encode(name: any, value: any, v?: number): any;
90
103
  export function decode(name: any, buffer: any, v?: number): any;
@@ -6,6 +6,8 @@ export { channelTopic };
6
6
  * @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
7
7
  * @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
8
8
  * @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
9
+ * @property {any} [store] Corestore; required for mirrors (blind peers replicate its cores).
10
+ * @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys; each attached room/blob core is mirrored through them for offline sync.
9
11
  *
10
12
  * @typedef {{ replicate: (stream: any) => any }} Replicable
11
13
  */
@@ -15,7 +17,7 @@ export { channelTopic };
15
17
  */
16
18
  export class Network extends ReadyResource {
17
19
  /** @param {NetworkOpts} [opts] */
18
- constructor({ identity, bootstrap, firewall, relayThrough, channel }?: NetworkOpts);
20
+ constructor({ identity, bootstrap, firewall, relayThrough, channel, store, mirrors }?: NetworkOpts);
19
21
  identity: import("../index.js").Identity;
20
22
  bootstrap: {
21
23
  host: string;
@@ -24,12 +26,15 @@ export class Network extends ReadyResource {
24
26
  firewall: (remotePublicKey: Uint8Array, payload: any) => boolean;
25
27
  relayThrough: Uint8Array<ArrayBufferLike>[];
26
28
  channel: string;
29
+ store: any;
30
+ mirrors: any[];
27
31
  _dht: DHTTransport;
28
32
  wakeup: any;
29
33
  _replicateables: Set<any>;
30
34
  _discoveries: Set<any>;
31
35
  _injected: Set<any>;
32
36
  _blind: any;
37
+ _blindPeering: any;
33
38
  /** @returns {any} The underlying hyperswarm, or null before ready / after close. */
34
39
  get swarm(): any;
35
40
  /**
@@ -151,6 +156,14 @@ export type NetworkOpts = {
151
156
  * Optional network-isolation label; only same-channel peers meet.
152
157
  */
153
158
  channel?: string;
159
+ /**
160
+ * Corestore; required for mirrors (blind peers replicate its cores).
161
+ */
162
+ store?: any;
163
+ /**
164
+ * Blind-peer public keys; each attached room/blob core is mirrored through them for offline sync.
165
+ */
166
+ mirrors?: Array<string | Uint8Array>;
154
167
  };
155
168
  export type Replicable = {
156
169
  replicate: (stream: any) => any;
@@ -28,18 +28,20 @@ export class BLETransport extends ReadyResource {
28
28
  * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
29
29
  * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
30
30
  * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
31
- * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
31
+ * @param {number} [opts.maxOutbound] Max concurrent outbound dials/links; gossip covers the rest.
32
+ * @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
32
33
  * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
33
34
  * @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
34
35
  * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
35
36
  */
36
- constructor({ backend, network, uuid, nodeId, tag, cap, scanOptions, keepLinks, name }: {
37
+ constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name }: {
37
38
  backend: any;
38
39
  network: import("../index.js").Network;
39
40
  uuid: Uint8Array;
40
41
  nodeId: Uint8Array;
41
42
  tag?: string;
42
- cap?: number;
43
+ maxOutbound?: number;
44
+ maxInbound?: number;
43
45
  scanOptions?: {
44
46
  scanMode?: any;
45
47
  };
@@ -52,7 +54,8 @@ export class BLETransport extends ReadyResource {
52
54
  nodeId: Uint8Array<ArrayBufferLike>;
53
55
  nodeHex: any;
54
56
  serviceUUID: string;
55
- cap: number;
57
+ maxOutbound: number;
58
+ maxInbound: number;
56
59
  scanOptions: {
57
60
  scanMode?: any;
58
61
  };
@@ -9,6 +9,8 @@
9
9
  * @property {(type: string, row: any) => Uint8Array} encodeRow
10
10
  * @property {(type: string, buf: Uint8Array) => any} decodeRow
11
11
  * @property {(type: string, rows: any[]) => Uint8Array} encodeRows
12
+ * @property {(type: string, changes: Array<{ prev: any, next: any }>) => Uint8Array} encodeChanges
13
+ * @property {(type: string, buf: Uint8Array) => Array<{ prev: any, next: any }>} decodeChanges
12
14
  * @property {(type: string, buf: Uint8Array) => any[]} decodeRows
13
15
  * @property {(q: any) => Uint8Array} encodeQuery
14
16
  * @property {(buf: Uint8Array) => any} decodeQuery
@@ -57,6 +59,14 @@ export type Codec = {
57
59
  encodeRow: (type: string, row: any) => Uint8Array;
58
60
  decodeRow: (type: string, buf: Uint8Array) => any;
59
61
  encodeRows: (type: string, rows: any[]) => Uint8Array;
62
+ encodeChanges: (type: string, changes: Array<{
63
+ prev: any;
64
+ next: any;
65
+ }>) => Uint8Array;
66
+ decodeChanges: (type: string, buf: Uint8Array) => Array<{
67
+ prev: any;
68
+ next: any;
69
+ }>;
60
70
  decodeRows: (type: string, buf: Uint8Array) => any[];
61
71
  encodeQuery: (q: any) => Uint8Array;
62
72
  decodeQuery: (buf: Uint8Array) => any;