@cero-base/cero 1.3.0 → 1.5.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 +71 -0
- package/package.json +24 -11
- package/src/bluetooth.js +24 -8
- package/src/build/builtins.js +6 -1
- package/src/build/index.js +17 -0
- package/src/build/schemas.js +8 -2
- package/src/handle/index.js +6 -2
- package/src/index.js +16 -3
- package/src/lib/operators.js +52 -0
- package/src/rpc/client.js +43 -0
- package/src/rpc/server.js +36 -1
- package/types/bluetooth.d.ts +10 -3
- package/types/build/schemas.d.ts +6 -0
- package/types/index.d.ts +12 -3
- package/types/lib/operators.d.ts +1 -0
package/README.md
CHANGED
|
@@ -178,6 +178,23 @@ const ac = new AbortController()
|
|
|
178
178
|
cero.watch(room.messages, null, { signal: ac.signal }) // ac.abort() ⇒ destroyed
|
|
179
179
|
```
|
|
180
180
|
|
|
181
|
+
### `cero.changes(ref, q?, opts?)`
|
|
182
|
+
|
|
183
|
+
Delta subscription: instead of full snapshots, batches of `{ prev, next }` row pairs — insert (`prev: null`), update (both), delete (`next: null`). Self-contained: the first batch (and any batch after recovery or an upgrade rebuild) replays the current matching rows as inserts with `reset: true`, so folding every batch into a Map always reconstructs current state — no separate `get`, no attach race:
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
const rows = new Map()
|
|
187
|
+
for await (const { changes, reset } of cero.changes(room.messages)) {
|
|
188
|
+
if (reset) rows.clear()
|
|
189
|
+
for (const { prev, next } of changes) {
|
|
190
|
+
if (next) rows.set(next.id, next)
|
|
191
|
+
else rows.delete(prev.id)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Lossless under backpressure — a slow consumer gets fewer, bigger batches, never dropped ones. Query `gt/gte/lt/lte`, equality fields, and `search` scope the deltas (a row updated out of the filter arrives as `prev`-only); `limit`/`reverse` are not applied — deltas are unwindowed, windowing is `watch`'s job. Over RPC the wire carries only the changed rows, not the result set. Prefer `changes` for accumulating UIs (chat logs, ever-growing lists); prefer `watch` for windowed views. File-typed fields resolve on both sides. Handle refs are not supported.
|
|
197
|
+
|
|
181
198
|
### `cero.call(actionRef, data)`
|
|
182
199
|
|
|
183
200
|
Invoke an action defined in your schema with `t.action({ … })`. Custom write paths; the dispatcher handles encoding/decoding.
|
|
@@ -515,6 +532,60 @@ const room = await cero.open(me.room, invite) // rendezvouses over BLE automatic
|
|
|
515
532
|
|
|
516
533
|
The advertisement auto-stops at the invite's `expiresIn` — a photographed QR must not stay an ambient admission ticket.
|
|
517
534
|
|
|
535
|
+
## App versions & rollouts
|
|
536
|
+
|
|
537
|
+
Rooms are permanent replicated logs, and after release your users will run
|
|
538
|
+
different app versions side by side. Every op cero writes carries the app's
|
|
539
|
+
contract version (stamped into the generated spec by `build` — it advances
|
|
540
|
+
automatically whenever the schema changes). Peers handle version skew
|
|
541
|
+
deterministically:
|
|
542
|
+
|
|
543
|
+
- Ops from a **newer** version are skipped — they stay in the log, they are
|
|
544
|
+
not errors, and nothing diverges. The store reports it:
|
|
545
|
+
|
|
546
|
+
```js
|
|
547
|
+
me.store.behind // highest future version seen, or null
|
|
548
|
+
me.store.on('behind', (version) => showUpdatePrompt())
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
- After the app **upgrades** past everything it skipped, the next open
|
|
552
|
+
rebuilds the local view from the log through the new handlers — the
|
|
553
|
+
previously-skipped ops apply, nothing is lost, and the store emits
|
|
554
|
+
`'rebuild'` once.
|
|
555
|
+
- Apps built before versioned ops existed read as version 0 and interoperate
|
|
556
|
+
unchanged.
|
|
557
|
+
|
|
558
|
+
Ship gradual rollouts freely; old peers keep working on everything they
|
|
559
|
+
understand and know when to prompt for an update.
|
|
560
|
+
|
|
561
|
+
## Mirrors (offline sync)
|
|
562
|
+
|
|
563
|
+
Two peers can only sync while both are online. A **blind peer** is an always-on relay that holds your rooms' encrypted blocks and serves them to other members — so a peer can pick up messages that were sent while it was offline. It's _blind_: rooms are end-to-end encrypted, so the mirror stores ciphertext and never sees your data.
|
|
564
|
+
|
|
565
|
+
Pass mirror public keys and every room and file is mirrored automatically:
|
|
566
|
+
|
|
567
|
+
```js
|
|
568
|
+
const me = await cero(dir, spec, {
|
|
569
|
+
mirrors: ['<blind-peer-public-key>'] // hex or z32
|
|
570
|
+
})
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Nothing else changes — the operators, invites, and roles work exactly as before; mirrors only add availability. Keys carry through account recovery (`cero.restore`).
|
|
574
|
+
|
|
575
|
+
**Running a mirror.** The `blind-peer` package is the server (a library — see `holepunchto/blind-peer-cli` for a turnkey host). A minimal self-hosted mirror:
|
|
576
|
+
|
|
577
|
+
```js
|
|
578
|
+
import Hyperswarm from 'hyperswarm'
|
|
579
|
+
import BlindPeer from 'blind-peer'
|
|
580
|
+
|
|
581
|
+
const swarm = new Hyperswarm()
|
|
582
|
+
const mirror = new BlindPeer('./mirror-store', { swarm })
|
|
583
|
+
await mirror.listen()
|
|
584
|
+
console.log('mirror key:', mirror.publicKey.toString('hex')) // give this to cero({ mirrors })
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Point your app's `mirrors` at that key. Storage grows with the rooms it holds; `blind-peer` GCs least-recently-used data past its `maxBytes` limit.
|
|
588
|
+
|
|
518
589
|
## Exports
|
|
519
590
|
|
|
520
591
|
| Path | What you get |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/cero",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -74,6 +74,14 @@
|
|
|
74
74
|
"crypto": {
|
|
75
75
|
"bare": "bare-crypto",
|
|
76
76
|
"default": "crypto"
|
|
77
|
+
},
|
|
78
|
+
"url": {
|
|
79
|
+
"bare": "bare-url",
|
|
80
|
+
"default": "url"
|
|
81
|
+
},
|
|
82
|
+
"events": {
|
|
83
|
+
"bare": "bare-events",
|
|
84
|
+
"default": "events"
|
|
77
85
|
}
|
|
78
86
|
},
|
|
79
87
|
"scripts": {
|
|
@@ -81,23 +89,24 @@
|
|
|
81
89
|
"build:types": "rm -rf types && tsc -p .",
|
|
82
90
|
"pretest": "npm run build:test",
|
|
83
91
|
"prepublishOnly": "npm run build:types",
|
|
84
|
-
"test": "
|
|
92
|
+
"test": "npm run test:node",
|
|
85
93
|
"pretest:bare": "npm run build:test",
|
|
86
|
-
"test:bare": "
|
|
94
|
+
"test:bare": "ls test/*.test.js | xargs -P1 -n1 brittle-bare test/bare.js",
|
|
95
|
+
"test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
|
|
87
96
|
},
|
|
88
97
|
"dependencies": {
|
|
89
|
-
"@cero-base/core": "^1.
|
|
98
|
+
"@cero-base/core": "^1.5.0",
|
|
90
99
|
"b4a": "^1.8.1",
|
|
91
100
|
"bare-abort-controller": "^1.1.2",
|
|
92
101
|
"bare-crypto": "^1.15.3",
|
|
93
|
-
"bare-fs": "^4.7.
|
|
94
|
-
"bare-path": "^3.
|
|
95
|
-
"compact-encoding": "^3.
|
|
96
|
-
"corestore": "^7.
|
|
102
|
+
"bare-fs": "^4.7.4",
|
|
103
|
+
"bare-path": "^3.1.1",
|
|
104
|
+
"compact-encoding": "^3.3.0",
|
|
105
|
+
"corestore": "^7.11.1",
|
|
97
106
|
"hrpc": "^4.3.0",
|
|
98
|
-
"hypercore": "^11.
|
|
107
|
+
"hypercore": "^11.34.1",
|
|
99
108
|
"hypercore-crypto": "^3.7.0",
|
|
100
|
-
"hypercore-storage": "^3.
|
|
109
|
+
"hypercore-storage": "^3.2.0",
|
|
101
110
|
"hyperdb": "^6.7.0",
|
|
102
111
|
"hyperdispatch": "^1.6.0",
|
|
103
112
|
"hyperschema": "^1.21.0",
|
|
@@ -108,7 +117,11 @@
|
|
|
108
117
|
},
|
|
109
118
|
"devDependencies": {
|
|
110
119
|
"@hyperswarm/testnet": "^3.1.4",
|
|
111
|
-
"
|
|
120
|
+
"bare-events": "^2.9.1",
|
|
121
|
+
"bare-fetch": "^3.0.3",
|
|
122
|
+
"bare-process": "^4.4.1",
|
|
123
|
+
"bare-url": "^2.2.1",
|
|
124
|
+
"brittle": "^4.1.0",
|
|
112
125
|
"typescript": "^5.9.3"
|
|
113
126
|
},
|
|
114
127
|
"license": "Apache-2.0",
|
package/src/bluetooth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import ReadyResource from 'ready-resource'
|
|
2
2
|
import safetyCatch from 'safety-catch'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { BLETransport } from '@cero-base/core/network/transports/ble'
|
|
5
5
|
import { Pairing } from '@cero-base/core/pairing'
|
|
6
6
|
import { Invite } from '@cero-base/core/invite'
|
|
7
7
|
|
|
@@ -41,12 +41,18 @@ export class Bluetooth extends ReadyResource {
|
|
|
41
41
|
* @param {object} [opts]
|
|
42
42
|
* @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
|
|
43
43
|
* @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
|
|
44
|
+
* @param {number} [opts.maxOutbound] Max concurrent outbound links; gossip covers the rest.
|
|
45
|
+
* @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
|
|
44
46
|
*/
|
|
45
|
-
constructor(handle, { backend, autoStart } = {}) {
|
|
47
|
+
constructor(handle, { backend, autoStart, maxOutbound, maxInbound } = {}) {
|
|
46
48
|
super()
|
|
47
49
|
this._handle = handle
|
|
50
|
+
// explicit null/false disables bluetooth; only an omitted backend lazy-loads
|
|
48
51
|
this._backend = backend || null
|
|
52
|
+
this._lazy = backend === undefined
|
|
49
53
|
this._autoStart = autoStart === true
|
|
54
|
+
this._maxOutbound = maxOutbound
|
|
55
|
+
this._maxInbound = maxInbound
|
|
50
56
|
this._transport = null
|
|
51
57
|
this._name = null
|
|
52
58
|
this._announces = new Set()
|
|
@@ -71,7 +77,7 @@ export class Bluetooth extends ReadyResource {
|
|
|
71
77
|
const topic = Pairing.inviteTopic(invite)
|
|
72
78
|
if (!topic) return () => {}
|
|
73
79
|
|
|
74
|
-
const transport = new
|
|
80
|
+
const transport = new BLETransport({
|
|
75
81
|
backend: this._backend,
|
|
76
82
|
network: this._handle.network,
|
|
77
83
|
uuid: topic,
|
|
@@ -101,7 +107,7 @@ export class Bluetooth extends ReadyResource {
|
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
async _open() {
|
|
104
|
-
if (this._backend === null) this._backend = await loadBackend()
|
|
110
|
+
if (this._backend === null && this._lazy) this._backend = await loadBackend()
|
|
105
111
|
if (!this._backend) {
|
|
106
112
|
this.state = 'unsupported'
|
|
107
113
|
return
|
|
@@ -129,7 +135,7 @@ export class Bluetooth extends ReadyResource {
|
|
|
129
135
|
}
|
|
130
136
|
const handle = this._handle
|
|
131
137
|
|
|
132
|
-
this._transport = new
|
|
138
|
+
this._transport = new BLETransport({
|
|
133
139
|
backend: this._backend,
|
|
134
140
|
network: handle.network,
|
|
135
141
|
// channel isolation for free: same channel → same UUID, like the swarm.
|
|
@@ -138,10 +144,20 @@ export class Bluetooth extends ReadyResource {
|
|
|
138
144
|
? Buffer.from(handle.network.channel)
|
|
139
145
|
: handle.identity.publicKey,
|
|
140
146
|
nodeId: handle.identity.publicKey,
|
|
141
|
-
name: this._name || ''
|
|
147
|
+
name: this._name || '',
|
|
148
|
+
maxOutbound: this._maxOutbound,
|
|
149
|
+
maxInbound: this._maxInbound,
|
|
150
|
+
// Android scans in a battery-saver mode by default — too slow for a mesh
|
|
151
|
+
scanOptions:
|
|
152
|
+
typeof Bare !== 'undefined' && Bare.platform === 'android'
|
|
153
|
+
? { scanMode: this._backend.Central.SCAN_MODE_LOW_LATENCY }
|
|
154
|
+
: undefined
|
|
142
155
|
})
|
|
143
|
-
this._transport
|
|
144
|
-
|
|
156
|
+
const transport = this._transport
|
|
157
|
+
transport.on('update', () => {
|
|
158
|
+
// a torn-down or replaced transport can still emit late adapter events
|
|
159
|
+
if (this._transport !== transport) return
|
|
160
|
+
this.state = transport.state
|
|
145
161
|
this.emit('update')
|
|
146
162
|
})
|
|
147
163
|
try {
|
package/src/build/builtins.js
CHANGED
|
@@ -140,6 +140,11 @@ export const rpcCommands = (ns) => {
|
|
|
140
140
|
request: { name: ref('req-handle') },
|
|
141
141
|
response: { name: ref('res-ok') }
|
|
142
142
|
},
|
|
143
|
-
{ name: 'leave', request: { name: ref('req-handle') }, response: { name: ref('res-ok') } }
|
|
143
|
+
{ name: 'leave', request: { name: ref('req-handle') }, response: { name: ref('res-ok') } },
|
|
144
|
+
{
|
|
145
|
+
name: 'changes',
|
|
146
|
+
request: { name: ref('req-query') },
|
|
147
|
+
response: { name: ref('res-changes'), stream: true }
|
|
148
|
+
}
|
|
144
149
|
]
|
|
145
150
|
}
|
package/src/build/index.js
CHANGED
|
@@ -85,6 +85,7 @@ export async function build(specDir, schema, { ns = NS, extensions = true } = {}
|
|
|
85
85
|
|
|
86
86
|
const meta = {
|
|
87
87
|
...main.meta,
|
|
88
|
+
version: await contractVersion(join(specDir, 'main')),
|
|
88
89
|
local: local.meta,
|
|
89
90
|
handles: Object.fromEntries(
|
|
90
91
|
Object.entries(handles).map(([n, h]) => [n, { ...h.meta, type: n }])
|
|
@@ -231,6 +232,22 @@ function fieldsFor(fields) {
|
|
|
231
232
|
}))
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
// The contract version is the highest of the three auto-bumped artifact
|
|
236
|
+
// versions — any schema/db/dispatch change advances it, so a peer can tell
|
|
237
|
+
// whether an op predates or postdates its own build.
|
|
238
|
+
async function contractVersion(mainDir) {
|
|
239
|
+
const read = async (rel) => {
|
|
240
|
+
const j = JSON.parse(await fs.readFile(join(mainDir, rel), 'utf-8'))
|
|
241
|
+
return j.version || 1
|
|
242
|
+
}
|
|
243
|
+
const versions = await Promise.all([
|
|
244
|
+
read('schema/schema.json'),
|
|
245
|
+
read('db/db.json'),
|
|
246
|
+
read('dispatch/dispatch.json')
|
|
247
|
+
])
|
|
248
|
+
return Math.max(...versions)
|
|
249
|
+
}
|
|
250
|
+
|
|
234
251
|
function emitMain(dir, ns, { types, collections, dispatches, indexes = [] }, { rpc, extend = {} }) {
|
|
235
252
|
const schemaDir = join(dir, 'schema')
|
|
236
253
|
const dbDir = join(dir, 'db')
|
package/src/build/schemas.js
CHANGED
|
@@ -13,7 +13,8 @@ export const main = {
|
|
|
13
13
|
master: required(bytes),
|
|
14
14
|
writer: required(bytes),
|
|
15
15
|
sig: required(bytes),
|
|
16
|
-
isIndexer: bool
|
|
16
|
+
isIndexer: bool,
|
|
17
|
+
ts: int
|
|
17
18
|
},
|
|
18
19
|
counter: {
|
|
19
20
|
name: required(string),
|
|
@@ -70,7 +71,8 @@ export const main = {
|
|
|
70
71
|
claim: {
|
|
71
72
|
identity: required(bytes),
|
|
72
73
|
writer: required(bytes),
|
|
73
|
-
sig: required(bytes)
|
|
74
|
+
sig: required(bytes),
|
|
75
|
+
ts: int
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -154,6 +156,10 @@ export const rpc = {
|
|
|
154
156
|
total: required(int),
|
|
155
157
|
size: required(int)
|
|
156
158
|
},
|
|
159
|
+
'res-changes': {
|
|
160
|
+
changes: required(bytes),
|
|
161
|
+
reset: bool
|
|
162
|
+
},
|
|
157
163
|
'res-count': {
|
|
158
164
|
count: required(int)
|
|
159
165
|
},
|
package/src/handle/index.js
CHANGED
|
@@ -451,7 +451,8 @@ export class Handle extends ReadyResource {
|
|
|
451
451
|
await this.store.call('add-writer', {
|
|
452
452
|
sig,
|
|
453
453
|
master: this.identity.publicKey,
|
|
454
|
-
writer: writerKey
|
|
454
|
+
writer: writerKey,
|
|
455
|
+
ts: member.updatedAt || Date.now()
|
|
455
456
|
})
|
|
456
457
|
await this.store.call('add-member', member)
|
|
457
458
|
})
|
|
@@ -503,7 +504,10 @@ export class Handle extends ReadyResource {
|
|
|
503
504
|
await child.store.call('add-writer', {
|
|
504
505
|
master: this.identity.publicKey,
|
|
505
506
|
writer: writerKey,
|
|
506
|
-
sig: this.identity.sign(
|
|
507
|
+
sig: this.identity.sign(
|
|
508
|
+
addWriterPayload(child.store.key, writerKey, child.store.writerKey)
|
|
509
|
+
),
|
|
510
|
+
ts
|
|
507
511
|
})
|
|
508
512
|
await child.store.call('add-member', {
|
|
509
513
|
id: this.identity.id,
|
package/src/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
del,
|
|
18
18
|
count,
|
|
19
19
|
watch,
|
|
20
|
+
changes,
|
|
20
21
|
call,
|
|
21
22
|
open,
|
|
22
23
|
before,
|
|
@@ -37,6 +38,7 @@ export {
|
|
|
37
38
|
del,
|
|
38
39
|
count,
|
|
39
40
|
watch,
|
|
41
|
+
changes,
|
|
40
42
|
call,
|
|
41
43
|
open,
|
|
42
44
|
before,
|
|
@@ -61,6 +63,7 @@ export { t, schema } from './lib/spec.js'
|
|
|
61
63
|
* @property {boolean} [isMobile] Marks this device as mobile.
|
|
62
64
|
* @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
|
|
63
65
|
* @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
|
|
66
|
+
* @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
|
|
64
67
|
* @property {Uint8Array} [key] Pre-existing database key (skip bootstrap).
|
|
65
68
|
* @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
|
|
66
69
|
* @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
|
|
@@ -69,7 +72,7 @@ export { t, schema } from './lib/spec.js'
|
|
|
69
72
|
* @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
|
|
70
73
|
* @property {Uint8Array} [storageKey] 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it.
|
|
71
74
|
* @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
|
|
72
|
-
* @property {boolean | { autoStart?: boolean, backend?: any }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests)
|
|
75
|
+
* @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
|
|
73
76
|
*/
|
|
74
77
|
|
|
75
78
|
/**
|
|
@@ -120,7 +123,12 @@ export async function cero(dir, spec, opts = {}) {
|
|
|
120
123
|
else if (stored != null && stored !== wanted) throw CeroError.CHANNEL_MISMATCH()
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
network = new Network({
|
|
126
|
+
network = new Network({
|
|
127
|
+
bootstrap: opts.bootstrap,
|
|
128
|
+
channel: opts.channel,
|
|
129
|
+
store,
|
|
130
|
+
mirrors: opts.mirrors
|
|
131
|
+
})
|
|
124
132
|
await network.ready()
|
|
125
133
|
discovery = network.join(identity.topic)
|
|
126
134
|
await Promise.race([discovery.flush(), new Promise((r) => setTimeout(r, FLUSH))])
|
|
@@ -187,7 +195,9 @@ export async function cero(dir, spec, opts = {}) {
|
|
|
187
195
|
: opts.bluetooth
|
|
188
196
|
me.bluetooth = new Bluetooth(me, {
|
|
189
197
|
backend: bt.backend || null,
|
|
190
|
-
autoStart: bt.autoStart !== false
|
|
198
|
+
autoStart: bt.autoStart !== false,
|
|
199
|
+
maxOutbound: bt.maxOutbound,
|
|
200
|
+
maxInbound: bt.maxInbound
|
|
191
201
|
})
|
|
192
202
|
me.once('close', () => me.bluetooth.close().catch(safetyCatch))
|
|
193
203
|
await me.bluetooth.ready()
|
|
@@ -238,6 +248,7 @@ export async function restore(me, phrase) {
|
|
|
238
248
|
routes,
|
|
239
249
|
recoveryTimeout,
|
|
240
250
|
channel,
|
|
251
|
+
mirrors,
|
|
241
252
|
storageKey,
|
|
242
253
|
extensions
|
|
243
254
|
} = opts
|
|
@@ -253,6 +264,7 @@ export async function restore(me, phrase) {
|
|
|
253
264
|
routes,
|
|
254
265
|
recoveryTimeout,
|
|
255
266
|
channel,
|
|
267
|
+
mirrors,
|
|
256
268
|
storageKey,
|
|
257
269
|
extensions,
|
|
258
270
|
phrase,
|
|
@@ -270,6 +282,7 @@ cero.get = get
|
|
|
270
282
|
cero.del = del
|
|
271
283
|
cero.count = count
|
|
272
284
|
cero.watch = watch
|
|
285
|
+
cero.changes = changes
|
|
273
286
|
cero.call = call
|
|
274
287
|
cero.open = open
|
|
275
288
|
cero.before = before
|
package/src/lib/operators.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Readable } from 'streamx'
|
|
|
2
2
|
import b4a from 'b4a'
|
|
3
3
|
|
|
4
4
|
import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
|
|
5
|
+
import { CeroError } from '@cero-base/core/errors'
|
|
5
6
|
import { onAbort } from './utils.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -251,6 +252,57 @@ export const watch = (ref, q, opts) => {
|
|
|
251
252
|
return bindStream(owner, out, opts)
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Delta subscription: batches of `{ prev, next }` row pairs instead of
|
|
257
|
+
* full snapshots — lossless under backpressure, self-contained (the first
|
|
258
|
+
* batch, and any batch after a view swap, replays current state as inserts
|
|
259
|
+
* with `reset: true`). File-typed fields resolve on both sides.
|
|
260
|
+
*/
|
|
261
|
+
export const changes = (ref, q, opts) => {
|
|
262
|
+
const owner = ref.handle
|
|
263
|
+
if (ref.kind === 'handle') throw CeroError.INVALID('changes does not support handle refs')
|
|
264
|
+
const src = owner.store.changes(ref.name, q)
|
|
265
|
+
let resume = null
|
|
266
|
+
const wake = () => {
|
|
267
|
+
const r = resume
|
|
268
|
+
resume = null
|
|
269
|
+
if (r) r()
|
|
270
|
+
}
|
|
271
|
+
const out = new Readable({
|
|
272
|
+
read(cb) {
|
|
273
|
+
wake()
|
|
274
|
+
cb(null)
|
|
275
|
+
},
|
|
276
|
+
destroy(cb) {
|
|
277
|
+
wake()
|
|
278
|
+
src.destroy()
|
|
279
|
+
cb(null)
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
const pump = async () => {
|
|
283
|
+
for await (const batch of src) {
|
|
284
|
+
const changes = []
|
|
285
|
+
for (const { prev, next } of batch.changes) {
|
|
286
|
+
changes.push({
|
|
287
|
+
prev: prev && resolveRow(ref, prev),
|
|
288
|
+
next: next && resolveRow(ref, next)
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
if (out.push({ ...batch, changes }) === false) {
|
|
292
|
+
await new Promise((r) => {
|
|
293
|
+
resume = r
|
|
294
|
+
})
|
|
295
|
+
if (out.destroyed) return
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
out.push(null)
|
|
299
|
+
}
|
|
300
|
+
pump().catch((err) => {
|
|
301
|
+
if (!out.destroyed) out.destroy(err)
|
|
302
|
+
})
|
|
303
|
+
return bindStream(owner, out, opts)
|
|
304
|
+
}
|
|
305
|
+
|
|
254
306
|
// Snapshots are idempotent — under a slow consumer hold only the NEWEST one
|
|
255
307
|
// instead of queueing every intermediate (a busy room + un-drained reader
|
|
256
308
|
// used to buffer full result sets without bound).
|
package/src/rpc/client.js
CHANGED
|
@@ -308,6 +308,49 @@ const operators = {
|
|
|
308
308
|
return out
|
|
309
309
|
},
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Delta subscription over the wire — same contract as the local operator:
|
|
313
|
+
* batches of `{ prev, next }` with file fields resolved, `reset` marks
|
|
314
|
+
* a full replay. Lossless: server-side the cursor folds under backpressure.
|
|
315
|
+
*/
|
|
316
|
+
changes(name, query) {
|
|
317
|
+
const refInfo = this._refInfo(name)
|
|
318
|
+
const codec = this._codec()
|
|
319
|
+
const schema = refInfo?.schema
|
|
320
|
+
const wire = this.rpc.changes({
|
|
321
|
+
handle: this.id,
|
|
322
|
+
ref: name,
|
|
323
|
+
query: codec.encodeQuery(query),
|
|
324
|
+
local: this._local
|
|
325
|
+
})
|
|
326
|
+
const out = new Readable({
|
|
327
|
+
predestroy() {
|
|
328
|
+
wire.destroy()
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
const pump = async () => {
|
|
332
|
+
for await (const frame of wire) {
|
|
333
|
+
const changes = []
|
|
334
|
+
for (const { prev, next } of codec.decodeChanges(schema, frame.changes)) {
|
|
335
|
+
changes.push({
|
|
336
|
+
prev: prev && this._resolveFiles(name, prev),
|
|
337
|
+
next: next && this._resolveFiles(name, next)
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
out.push({ changes, reset: frame.reset === true })
|
|
341
|
+
}
|
|
342
|
+
if (!out.destroyed) out.push(null)
|
|
343
|
+
}
|
|
344
|
+
pump().catch((err) => {
|
|
345
|
+
if (out.destroyed) return
|
|
346
|
+
// the server tears the stream down on handle close — that is an end,
|
|
347
|
+
// not a failure (same contract as watch)
|
|
348
|
+
if (err.code === 'PREMATURE_CLOSE') out.push(null)
|
|
349
|
+
else out.destroy(err)
|
|
350
|
+
})
|
|
351
|
+
return out
|
|
352
|
+
},
|
|
353
|
+
|
|
311
354
|
/**
|
|
312
355
|
* Invoke a named action ref over the wire.
|
|
313
356
|
*
|
package/src/rpc/server.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import safetyCatch from 'safety-catch'
|
|
2
|
+
|
|
1
3
|
import { RPCServer, bindCodec } from '@cero-base/core/rpc'
|
|
2
4
|
import { CeroError } from '@cero-base/core/errors'
|
|
3
5
|
import { encodeId } from '@cero-base/core/blobs/codec'
|
|
4
6
|
|
|
5
7
|
import { cero, restore } from '../index.js'
|
|
6
|
-
import { put, set, get, del, count, watch, call } from '../lib/operators.js'
|
|
8
|
+
import { put, set, get, del, count, watch, changes, call } from '../lib/operators.js'
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* @typedef {import('@cero-base/core/rpc').RPCServer} BaseRPCServer
|
|
@@ -216,6 +218,39 @@ export class Server extends RPCServer {
|
|
|
216
218
|
})
|
|
217
219
|
})
|
|
218
220
|
|
|
221
|
+
this.rpc.onChanges((stream) => {
|
|
222
|
+
const { handle, ref, query, local } = stream.data
|
|
223
|
+
let r, codec, live
|
|
224
|
+
try {
|
|
225
|
+
;({ ref: r, codec } = this._refOf(handle, ref, local))
|
|
226
|
+
live = changes(r, codec.decodeQuery(query))
|
|
227
|
+
} catch (err) {
|
|
228
|
+
stream.once('error', () => {})
|
|
229
|
+
stream.writeStream.destroy(err)
|
|
230
|
+
stream.destroy()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
let set = this._watchStreams.get(handle)
|
|
234
|
+
if (!set) this._watchStreams.set(handle, (set = new Set()))
|
|
235
|
+
set.add(stream)
|
|
236
|
+
// no keep-latest: delta batches are not idempotent — hold the iteration
|
|
237
|
+
// on wire backpressure instead; the cursor folds everything missed
|
|
238
|
+
const pump = async () => {
|
|
239
|
+
for await (const batch of live) {
|
|
240
|
+
const ok = stream.write({
|
|
241
|
+
changes: codec.encodeChanges(r.schema, batch.changes),
|
|
242
|
+
reset: batch.reset === true
|
|
243
|
+
})
|
|
244
|
+
if (ok === false) await new Promise((resolve) => stream.once('drain', resolve))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
pump().catch(safetyCatch)
|
|
248
|
+
stream.on('close', () => {
|
|
249
|
+
live.destroy()
|
|
250
|
+
this._watchStreams.get(handle)?.delete(stream)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
219
254
|
this.rpc.onCall(async ({ handle, op, data }) => {
|
|
220
255
|
const h = this._resolve(handle)
|
|
221
256
|
const r = h[op]
|
package/types/bluetooth.d.ts
CHANGED
|
@@ -19,15 +19,22 @@ export class Bluetooth extends ReadyResource {
|
|
|
19
19
|
* @param {object} [opts]
|
|
20
20
|
* @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
|
|
21
21
|
* @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
|
|
22
|
+
* @param {number} [opts.maxOutbound] Max concurrent outbound links; gossip covers the rest.
|
|
23
|
+
* @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
|
|
22
24
|
*/
|
|
23
|
-
constructor(handle: object, { backend, autoStart }?: {
|
|
25
|
+
constructor(handle: object, { backend, autoStart, maxOutbound, maxInbound }?: {
|
|
24
26
|
backend?: any;
|
|
25
27
|
autoStart?: boolean;
|
|
28
|
+
maxOutbound?: number;
|
|
29
|
+
maxInbound?: number;
|
|
26
30
|
});
|
|
27
31
|
_handle: any;
|
|
28
32
|
_backend: any;
|
|
33
|
+
_lazy: boolean;
|
|
29
34
|
_autoStart: boolean;
|
|
30
|
-
|
|
35
|
+
_maxOutbound: number;
|
|
36
|
+
_maxInbound: number;
|
|
37
|
+
_transport: BLETransport;
|
|
31
38
|
_name: any;
|
|
32
39
|
_announces: Set<any>;
|
|
33
40
|
/** @type {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
|
|
@@ -63,4 +70,4 @@ export class Bluetooth extends ReadyResource {
|
|
|
63
70
|
stop(): Promise<void>;
|
|
64
71
|
}
|
|
65
72
|
import ReadyResource from 'ready-resource';
|
|
66
|
-
import {
|
|
73
|
+
import { BLETransport } from '@cero-base/core/network/transports/ble';
|
package/types/build/schemas.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const main: {
|
|
|
7
7
|
writer: import("@cero-base/core").Prim;
|
|
8
8
|
sig: import("@cero-base/core").Prim;
|
|
9
9
|
isIndexer: import("@cero-base/core").Prim;
|
|
10
|
+
ts: import("@cero-base/core").Prim;
|
|
10
11
|
};
|
|
11
12
|
counter: {
|
|
12
13
|
name: import("@cero-base/core").Prim;
|
|
@@ -64,6 +65,7 @@ export const main: {
|
|
|
64
65
|
identity: import("@cero-base/core").Prim;
|
|
65
66
|
writer: import("@cero-base/core").Prim;
|
|
66
67
|
sig: import("@cero-base/core").Prim;
|
|
68
|
+
ts: import("@cero-base/core").Prim;
|
|
67
69
|
};
|
|
68
70
|
};
|
|
69
71
|
export const local: {
|
|
@@ -145,6 +147,10 @@ export const rpc: {
|
|
|
145
147
|
total: import("@cero-base/core").Prim;
|
|
146
148
|
size: import("@cero-base/core").Prim;
|
|
147
149
|
};
|
|
150
|
+
'res-changes': {
|
|
151
|
+
changes: import("@cero-base/core").Prim;
|
|
152
|
+
reset: import("@cero-base/core").Prim;
|
|
153
|
+
};
|
|
148
154
|
'res-count': {
|
|
149
155
|
count: import("@cero-base/core").Prim;
|
|
150
156
|
};
|
package/types/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* @property {boolean} [isMobile] Marks this device as mobile.
|
|
12
12
|
* @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
|
|
13
13
|
* @property {string} [channel] Optional network-isolation label; only same-channel peers connect.
|
|
14
|
+
* @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
|
|
14
15
|
* @property {Uint8Array} [key] Pre-existing database key (skip bootstrap).
|
|
15
16
|
* @property {Uint8Array} [encryptionKey] Pre-existing encryption key.
|
|
16
17
|
* @property {Record<string, Function>} [routes] Custom RPC routes for the database dispatcher.
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* @property {number} [recoveryTimeout] Max wait for peer data + writer capability during recovery.
|
|
20
21
|
* @property {Uint8Array} [storageKey] 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it.
|
|
21
22
|
* @property {boolean} [extensions] `false` disables the bundled extensions (profileSync, handleSync) for this instance. Build with `{ extensions: false }` too so the spec matches.
|
|
22
|
-
* @property {boolean | { autoStart?: boolean, backend?: any }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests)
|
|
23
|
+
* @property {boolean | { autoStart?: boolean, backend?: any, maxOutbound?: number, maxInbound?: number }} [bluetooth] `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
|
|
23
24
|
*/
|
|
24
25
|
/**
|
|
25
26
|
* Open (or create) a cero handle at `dir`. Sets up storage, network and
|
|
@@ -40,6 +41,7 @@ export namespace cero {
|
|
|
40
41
|
export { del };
|
|
41
42
|
export { count };
|
|
42
43
|
export { watch };
|
|
44
|
+
export { changes };
|
|
43
45
|
export { call };
|
|
44
46
|
export { open };
|
|
45
47
|
export { before };
|
|
@@ -100,6 +102,10 @@ export type CeroOpts = {
|
|
|
100
102
|
* Optional network-isolation label; only same-channel peers connect.
|
|
101
103
|
*/
|
|
102
104
|
channel?: string;
|
|
105
|
+
/**
|
|
106
|
+
* Blind-peer public keys. Rooms and files are mirrored through them so peers sync even when never online at the same time. Mirrors hold only encrypted blocks — they never read your data.
|
|
107
|
+
*/
|
|
108
|
+
mirrors?: Array<string | Uint8Array>;
|
|
103
109
|
/**
|
|
104
110
|
* Pre-existing database key (skip bootstrap).
|
|
105
111
|
*/
|
|
@@ -133,11 +139,13 @@ export type CeroOpts = {
|
|
|
133
139
|
*/
|
|
134
140
|
extensions?: boolean;
|
|
135
141
|
/**
|
|
136
|
-
* `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests)
|
|
142
|
+
* `true` enables nearby (Bluetooth) sync via `me.bluetooth` (auto-started). `{ autoStart: false }` creates the facade without starting the radio — the app calls `me.bluetooth.start()`/`stop()` (user toggle). `backend` injects a bare-bluetooth-shaped backend (tests). `maxOutbound`/`maxInbound` cap concurrent outbound links and inbound sessions. Absent backend on an unsupported host → `me.bluetooth.state === 'unsupported'`.
|
|
137
143
|
*/
|
|
138
144
|
bluetooth?: boolean | {
|
|
139
145
|
autoStart?: boolean;
|
|
140
146
|
backend?: any;
|
|
147
|
+
maxOutbound?: number;
|
|
148
|
+
maxInbound?: number;
|
|
141
149
|
};
|
|
142
150
|
};
|
|
143
151
|
import { t } from './lib/spec.js';
|
|
@@ -147,6 +155,7 @@ import { get } from './lib/operators.js';
|
|
|
147
155
|
import { del } from './lib/operators.js';
|
|
148
156
|
import { count } from './lib/operators.js';
|
|
149
157
|
import { watch } from './lib/operators.js';
|
|
158
|
+
import { changes } from './lib/operators.js';
|
|
150
159
|
import { call } from './lib/operators.js';
|
|
151
160
|
import { open } from './lib/operators.js';
|
|
152
161
|
import { before } from './lib/operators.js';
|
|
@@ -161,5 +170,5 @@ import { Ref } from './handle/index.js';
|
|
|
161
170
|
import { Local } from './local/index.js';
|
|
162
171
|
import { Identity } from '@cero-base/core/identity';
|
|
163
172
|
export { Handle, Ref, Local };
|
|
164
|
-
export { put, set, get, del, count, watch, call, open, before, after, bind, define } from "./lib/operators.js";
|
|
173
|
+
export { put, set, get, del, count, watch, changes, call, open, before, after, bind, define } from "./lib/operators.js";
|
|
165
174
|
export { t, schema } from "./lib/spec.js";
|
package/types/lib/operators.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export function get(ref: Ref, q?: string | Record<string, any>): Promise<SingleR
|
|
|
72
72
|
export function watch(ref: Ref, q?: Record<string, any>, opts?: {
|
|
73
73
|
signal?: AbortSignal;
|
|
74
74
|
}): import("streamx").Readable;
|
|
75
|
+
export function changes(ref: any, q: any, opts: any): any;
|
|
75
76
|
export function open(ref: Ref, arg?: string | {
|
|
76
77
|
invite?: string;
|
|
77
78
|
id?: string;
|