@cero-base/cero 1.1.1 → 1.3.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 +53 -24
- package/package.json +18 -7
- package/src/bluetooth.js +190 -0
- package/src/build/index.js +17 -4
- package/src/build/schemas.js +3 -1
- package/src/extensions/handle-sync.js +14 -3
- package/src/extensions/profile-sync.js +10 -2
- package/src/handle/index.js +39 -11
- package/src/index.js +57 -11
- package/src/lib/internal.js +7 -1
- package/src/lib/operators.js +38 -18
- package/src/local/index.js +4 -2
- package/src/rpc/client.js +18 -5
- package/src/rpc/server.js +30 -7
- package/types/bluetooth.d.ts +66 -0
- package/types/build/index.d.ts +2 -1
- package/types/build/schemas.d.ts +2 -0
- package/types/extensions/handle-sync.d.ts +2 -0
- package/types/extensions/profile-sync.d.ts +2 -0
- package/types/handle/index.d.ts +14 -0
- package/types/index.d.ts +19 -1
- package/types/lib/internal.d.ts +22 -1
- package/types/lib/operators.d.ts +1 -7
- package/types/local/index.d.ts +6 -1
- package/src/CLAUDE.md +0 -3
- package/src/build/CLAUDE.md +0 -3
- package/src/extensions/CLAUDE.md +0 -3
- package/src/handle/CLAUDE.md +0 -3
- package/src/lib/CLAUDE.md +0 -3
- package/src/rpc/CLAUDE.md +0 -3
package/README.md
CHANGED
|
@@ -46,18 +46,19 @@ Ships with TypeScript declarations (`.d.ts`) generated from JSDoc.
|
|
|
46
46
|
|
|
47
47
|
## `cero(dir, spec, opts?)`
|
|
48
48
|
|
|
49
|
-
| Opt | Meaning
|
|
50
|
-
| ------------------- |
|
|
51
|
-
| `bootstrap` | Hyperswarm bootstrap nodes.
|
|
52
|
-
| `channel` | Optional network-isolation label. Peers connect only to peers on the **same** channel (it salts every swarm topic); omit it for the global network. A storage remembers its channel and refuses to reopen under a different one. Any string works.
|
|
53
|
-
| `name` / `isMobile` | Stamped on the device's `add-writer` event.
|
|
54
|
-
| `seed` / `phrase` | Restore from explicit 16-/32-byte entropy or a BIP-39 mnemonic. Without either, a stored identity is loaded if present, else a fresh one is generated.
|
|
55
|
-
| `key` | Open against an existing bee key (multi-device flow).
|
|
56
|
-
| `recovery` | `true` triggers `bootstrap({ recovering: true })` — for a second device opening with `key` + the same identity. Needed under autobee 1.0.3 so the second device swaps off the identity-keyed bootstrap-writer slot and starts applying remote appends.
|
|
57
|
-
| `recoveryTimeout` | Bound on the recovery wait.
|
|
58
|
-
| `routes` | Custom action handlers keyed by route name.
|
|
59
|
-
| `encryptionKey` | Override the per-identity encryption key.
|
|
60
|
-
| `
|
|
49
|
+
| Opt | Meaning |
|
|
50
|
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
51
|
+
| `bootstrap` | Hyperswarm bootstrap nodes. |
|
|
52
|
+
| `channel` | Optional network-isolation label. Peers connect only to peers on the **same** channel (it salts every swarm topic); omit it for the global network. A storage remembers its channel and refuses to reopen under a different one. Any string works. |
|
|
53
|
+
| `name` / `isMobile` | Stamped on the device's `add-writer` event. |
|
|
54
|
+
| `seed` / `phrase` | Restore from explicit 16-/32-byte entropy or a BIP-39 mnemonic. Without either, a stored identity is loaded if present, else a fresh one is generated. |
|
|
55
|
+
| `key` | Open against an existing bee key (multi-device flow). |
|
|
56
|
+
| `recovery` | `true` triggers `bootstrap({ recovering: true })` — for a second device opening with `key` + the same identity. Needed under autobee 1.0.3 so the second device swaps off the identity-keyed bootstrap-writer slot and starts applying remote appends. |
|
|
57
|
+
| `recoveryTimeout` | Bound on the recovery wait. |
|
|
58
|
+
| `routes` | Custom action handlers keyed by route name. |
|
|
59
|
+
| `encryptionKey` | Override the per-identity encryption key. |
|
|
60
|
+
| `storageKey` | 32-byte key encrypting local key material (master seed, device keypairs) at rest. Source it from the OS keychain — cero never stores it, and the same key must be passed on every open. Without it, key material sits plaintext on disk: rely on full-disk encryption. |
|
|
61
|
+
| `onerror` | Called with background/async failures that would otherwise be swallowed (failed after-hooks, `onApply` callbacks, pairing candidate errors); the app decides how to log or report them. |
|
|
61
62
|
|
|
62
63
|
Reads `me.id`, `me.device`, `me.identity` for canonical metadata. `me.identity.toPhrase()` renders the seed phrase.
|
|
63
64
|
|
|
@@ -240,11 +241,11 @@ cero.watch(me.files).on('data', ({ data }) => render(data)) // live
|
|
|
240
241
|
|
|
241
242
|
### The `file()` column type
|
|
242
243
|
|
|
243
|
-
For a file referenced from a row — an avatar, a room icon, a message attachment — declare the field `cero.t.file()
|
|
244
|
+
For a file referenced from a row — an avatar, a room icon, a message attachment — declare the field `cero.t.file` (a bare marker, not a call). Store the file's `id`; read it back already resolved to `{ id, name, type, size, url }`, with **no extra lookup**:
|
|
244
245
|
|
|
245
246
|
```js
|
|
246
247
|
// schema.js
|
|
247
|
-
profile: cero.t.single({ name: cero.t.string, avatar: cero.t.file
|
|
248
|
+
profile: cero.t.single({ name: cero.t.string, avatar: cero.t.file })
|
|
248
249
|
|
|
249
250
|
// save: upload, then store the id on the row
|
|
250
251
|
const { data: pic } = await cero.put(me.files, { data: bytes, type: 'image/png' })
|
|
@@ -255,8 +256,6 @@ const { data: profile } = await cero.get(me.profile)
|
|
|
255
256
|
img.src = profile.avatar.url
|
|
256
257
|
```
|
|
257
258
|
|
|
258
|
-
`cero.t.file({ embed: true })` also stores the file's `name` inline — handy for a list of named attachments, so rendering needs no per-item lookup.
|
|
259
|
-
|
|
260
259
|
### URLs are local and ephemeral
|
|
261
260
|
|
|
262
261
|
A `.url` points at a localhost server this device runs, with a per-session token — it changes across restarts and is **not** shareable to other peers. Never persist a `.url`: store the **id** (cero does), and re-read to get a current one. Each member derives their own url from the same id, and the bytes download on demand when the url is first fetched.
|
|
@@ -309,12 +308,14 @@ Non-functions in the map are skipped; the handle is returned.
|
|
|
309
308
|
|
|
310
309
|
## Extensions
|
|
311
310
|
|
|
312
|
-
An extension bundles **schema** (build-time) with **behavior** (runtime). Register it with `cero.use()` — `build()` folds in its schema and `cero()` runs its `setup` once the handle is ready.
|
|
311
|
+
An extension bundles **schema** (build-time) with **behavior** (runtime). Register it with `cero.use()` — `build()` folds in its schema and `cero()` runs its `setup` once the handle is ready.
|
|
312
|
+
|
|
313
|
+
The two bundled extensions (`profileSync`, `handleSync`) are **on by default** — no `cero.use()` needed. To reconfigure one, `cero.use(profileSync({ fields }))` replaces the default (named extensions replace by name, so double registration is harmless). To leave them out entirely, pass `{ extensions: false }` to **both** `build()` and `cero()` — the built spec and the runtime must agree.
|
|
313
314
|
|
|
314
315
|
```js
|
|
315
316
|
import { profileSync } from '@cero-base/cero/extensions'
|
|
316
317
|
|
|
317
|
-
cero.use(profileSync(
|
|
318
|
+
cero.use(profileSync({ fields: { status: cero.t.string } })) // reconfigure the default
|
|
318
319
|
await build('./spec', schema)
|
|
319
320
|
```
|
|
320
321
|
|
|
@@ -373,10 +374,9 @@ setup(me) {
|
|
|
373
374
|
|
|
374
375
|
### `profileSync` (bundled)
|
|
375
376
|
|
|
376
|
-
Mirrors your `profile` onto your `member` row in every room, so others see your name and avatar. It **declares its own `profile` single** (`name` + the synced fields) and extends `member` with them — no app schema required. Defaults to syncing `avatar`:
|
|
377
|
+
On by default. Mirrors your `profile` onto your `member` row in every room, so others see your name and avatar. It **declares its own `profile` single** (`name` + the synced fields) and extends `member` with them — no app schema required. Defaults to syncing `avatar`:
|
|
377
378
|
|
|
378
379
|
```js
|
|
379
|
-
cero.use(profileSync())
|
|
380
380
|
cero.use(profileSync({ fields: { status: cero.t.string } })) // sync extra fields
|
|
381
381
|
```
|
|
382
382
|
|
|
@@ -384,11 +384,9 @@ Want a richer profile (e.g. a `bio` that doesn't sync)? Declare your own `profil
|
|
|
384
384
|
|
|
385
385
|
### `handleSync` (bundled)
|
|
386
386
|
|
|
387
|
-
Mirrors a child handle's `profile` (name + avatar) onto its row in the parent's `handles` list — so a handle/room list renders names and photos without opening each handle. Adds `avatar` (or your `fields`) to the `handle` builtin and reflects the handle's own `profile` (which your app owns) onto the row.
|
|
387
|
+
On by default. Mirrors a child handle's `profile` (name + avatar) onto its row in the parent's `handles` list — so a handle/room list renders names and photos without opening each handle. Adds `avatar` (or your `fields`) to the `handle` builtin and reflects the handle's own `profile` (which your app owns) onto the row.
|
|
388
388
|
|
|
389
389
|
```js
|
|
390
|
-
cero.use(handleSync())
|
|
391
|
-
|
|
392
390
|
// your handle type declares a `profile`; your app sets it:
|
|
393
391
|
const room = await cero.open(me.room)
|
|
394
392
|
await cero.set(room.profile, { name: 'general', avatar: 'pic.png' })
|
|
@@ -412,7 +410,7 @@ import { spec } from './spec/index.js'
|
|
|
412
410
|
|
|
413
411
|
// serve builds and owns the root cero — pass storage + the built spec, not a handle.
|
|
414
412
|
// Returns a Server instance (call server.close() to shut down).
|
|
415
|
-
const server = await serve(Bare.IPC, { storage: './data', spec,
|
|
413
|
+
const server = await serve(Bare.IPC, { storage: './data', spec, phrase: '…' })
|
|
416
414
|
```
|
|
417
415
|
|
|
418
416
|
### Client (the UI process)
|
|
@@ -486,6 +484,37 @@ await serve(s, { storage: './data', spec })
|
|
|
486
484
|
const remote = await connect(c, spec)
|
|
487
485
|
```
|
|
488
486
|
|
|
487
|
+
## Nearby sync (Bluetooth)
|
|
488
|
+
|
|
489
|
+
Sync with no internet at all. Bluetooth is a transport, not a parallel API — it only changes _how peers meet and carry bytes_; the same refs, guards, roles, and reactivity ride over it unchanged. Volunteers in range converge; hop-by-hop gossip heals partitions as people move.
|
|
490
|
+
|
|
491
|
+
```js
|
|
492
|
+
const me = await cero(dir, spec, { channel, bluetooth: true })
|
|
493
|
+
|
|
494
|
+
me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
|
|
495
|
+
await me.bluetooth.start() // runtime toggle
|
|
496
|
+
await me.bluetooth.stop()
|
|
497
|
+
me.bluetooth.peers // Map of live BLE links
|
|
498
|
+
me.bluetooth.on('update', () => {}) // state / peer changes
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Inherited for free from the stack: `channel` isolation (the BLE service UUID is derived from the channel, so only same-channel devices discover each other), capability-gated replication (a connected stranger syncs nothing), and offline invite→join. Requires `bare-bluetooth` (an optional peer dep, bundled into the mobile/desktop worklet); absent it, `state` is `'unsupported'` — loud, never silent. macOS + iOS + Android; Linux desktop has no BLE backend today.
|
|
502
|
+
|
|
503
|
+
**Offline join** — a late volunteer incorporates on the spot, zero internet. Both sides derive a rendezvous UUID from the invite itself; the join side is automatic:
|
|
504
|
+
|
|
505
|
+
```js
|
|
506
|
+
// organizer, while the invite QR is on screen:
|
|
507
|
+
const invite = await room.invite({ role: 'member', expiresIn: 3600_000 })
|
|
508
|
+
const stop = me.bluetooth.announce(invite)
|
|
509
|
+
// … QR closed:
|
|
510
|
+
stop() // stops the rendezvous; an established link stays and carries the initial replication
|
|
511
|
+
|
|
512
|
+
// late volunteer — the exact same code as online:
|
|
513
|
+
const room = await cero.open(me.room, invite) // rendezvouses over BLE automatically
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
The advertisement auto-stops at the invite's `expiresIn` — a photographed QR must not stay an ambient admission ticket.
|
|
517
|
+
|
|
489
518
|
## Exports
|
|
490
519
|
|
|
491
520
|
| 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.3.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,
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"src",
|
|
19
19
|
"types",
|
|
20
20
|
"README.md",
|
|
21
|
-
"LICENSE"
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"!**/CLAUDE.md"
|
|
22
23
|
],
|
|
23
24
|
"publishConfig": {
|
|
24
25
|
"access": "public"
|
|
@@ -80,16 +81,17 @@
|
|
|
80
81
|
"build:types": "rm -rf types && tsc -p .",
|
|
81
82
|
"pretest": "npm run build:test",
|
|
82
83
|
"prepublishOnly": "npm run build:types",
|
|
83
|
-
"test": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
|
|
84
|
+
"test": "ls test/*.test.js | xargs -P1 -n1 brittle-node",
|
|
85
|
+
"pretest:bare": "npm run build:test",
|
|
86
|
+
"test:bare": "npx bare test/bare-smoke.js"
|
|
84
87
|
},
|
|
85
88
|
"dependencies": {
|
|
86
|
-
"@cero-base/core": "^1.
|
|
89
|
+
"@cero-base/core": "^1.3.0",
|
|
87
90
|
"b4a": "^1.8.1",
|
|
88
91
|
"bare-abort-controller": "^1.1.2",
|
|
89
92
|
"bare-crypto": "^1.15.3",
|
|
90
93
|
"bare-fs": "^4.7.2",
|
|
91
94
|
"bare-path": "^3.0.1",
|
|
92
|
-
"blind-pairing": "^2.3.1",
|
|
93
95
|
"compact-encoding": "^3.2.0",
|
|
94
96
|
"corestore": "^7.10.1",
|
|
95
97
|
"hrpc": "^4.3.0",
|
|
@@ -107,7 +109,16 @@
|
|
|
107
109
|
"devDependencies": {
|
|
108
110
|
"@hyperswarm/testnet": "^3.1.4",
|
|
109
111
|
"brittle": "^4.0.2",
|
|
110
|
-
"typescript": "^
|
|
112
|
+
"typescript": "^5.9.3"
|
|
111
113
|
},
|
|
112
|
-
"license": "Apache-2.0"
|
|
114
|
+
"license": "Apache-2.0",
|
|
115
|
+
"x": 1,
|
|
116
|
+
"peerDependencies": {
|
|
117
|
+
"bare-bluetooth": ">=0.2.0"
|
|
118
|
+
},
|
|
119
|
+
"peerDependenciesMeta": {
|
|
120
|
+
"bare-bluetooth": {
|
|
121
|
+
"optional": true
|
|
122
|
+
}
|
|
123
|
+
}
|
|
113
124
|
}
|
package/src/bluetooth.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import ReadyResource from 'ready-resource'
|
|
2
|
+
import safetyCatch from 'safety-catch'
|
|
3
|
+
|
|
4
|
+
import { BluetoothTransport } from '@cero-base/core/network/bluetooth'
|
|
5
|
+
import { Pairing } from '@cero-base/core/pairing'
|
|
6
|
+
import { Invite } from '@cero-base/core/invite'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lazily resolve the bare-bluetooth backend. Absent (Linux, or dep not
|
|
10
|
+
* bundled) → null, which the facade reports as `state: 'unsupported'`.
|
|
11
|
+
* Never throws — a missing optional dep is a state, not a crash.
|
|
12
|
+
*
|
|
13
|
+
* @returns {Promise<any | null>}
|
|
14
|
+
*/
|
|
15
|
+
async function loadBackend() {
|
|
16
|
+
try {
|
|
17
|
+
return await import('bare-bluetooth')
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `me.bluetooth` — the whole app-facing surface for nearby (Bluetooth) sync.
|
|
25
|
+
* Bluetooth is a cero feature, not a parallel API: it only changes how peers
|
|
26
|
+
* meet and carry bytes; capability-gated replication still decides what syncs.
|
|
27
|
+
*
|
|
28
|
+
* ```js
|
|
29
|
+
* const me = await cero(dir, spec, { channel, bluetooth: true })
|
|
30
|
+
* me.bluetooth.state // 'unsupported' | 'unauthorized' | 'off' | 'waiting' | 'on'
|
|
31
|
+
* await me.bluetooth.start()
|
|
32
|
+
* me.bluetooth.peers // Map of live BLE links
|
|
33
|
+
* me.bluetooth.on('update', () => {})
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* @extends ReadyResource
|
|
37
|
+
*/
|
|
38
|
+
export class Bluetooth extends ReadyResource {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} handle Root cero Handle (network + identity + channel).
|
|
41
|
+
* @param {object} [opts]
|
|
42
|
+
* @param {any} [opts.backend] Injected bare-bluetooth-shaped backend (tests); lazy-loaded when absent.
|
|
43
|
+
* @param {boolean} [opts.autoStart] Start on handle open (from `cero({ bluetooth: true })`).
|
|
44
|
+
*/
|
|
45
|
+
constructor(handle, { backend, autoStart } = {}) {
|
|
46
|
+
super()
|
|
47
|
+
this._handle = handle
|
|
48
|
+
this._backend = backend || null
|
|
49
|
+
this._autoStart = autoStart === true
|
|
50
|
+
this._transport = null
|
|
51
|
+
this._name = null
|
|
52
|
+
this._announces = new Set()
|
|
53
|
+
/** @type {'unsupported'|'unauthorized'|'off'|'waiting'|'starting'|'on'} */
|
|
54
|
+
this.state = 'off'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Offline join rendezvous. Both sides derive the same BLE service UUID from
|
|
59
|
+
* the invite, so they find each other with zero DHT: the host calls this
|
|
60
|
+
* while the invite QR is on screen; the joiner's `open(me.room, invite)`
|
|
61
|
+
* calls it automatically for the duration of the join. Returns a stop
|
|
62
|
+
* function — closing the QR must stop the advertisement so a photographed
|
|
63
|
+
* invite doesn't stay an ambient admission ticket. Auto-stops at the
|
|
64
|
+
* invite's expiry, on `bluetooth.stop()`, and on close.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} invite Z32 invite string.
|
|
67
|
+
* @returns {() => void}
|
|
68
|
+
*/
|
|
69
|
+
announce(invite) {
|
|
70
|
+
if (!this._backend || this.state === 'unsupported') return () => {}
|
|
71
|
+
const topic = Pairing.inviteTopic(invite)
|
|
72
|
+
if (!topic) return () => {}
|
|
73
|
+
|
|
74
|
+
const transport = new BluetoothTransport({
|
|
75
|
+
backend: this._backend,
|
|
76
|
+
network: this._handle.network,
|
|
77
|
+
uuid: topic,
|
|
78
|
+
nodeId: this._handle.identity.publicKey,
|
|
79
|
+
tag: 'cero-ble-invite',
|
|
80
|
+
// the QR closing stops the rendezvous, not the just-established link —
|
|
81
|
+
// that link carries the joiner's initial replication
|
|
82
|
+
keepLinks: true
|
|
83
|
+
})
|
|
84
|
+
transport.ready().catch(safetyCatch)
|
|
85
|
+
this._announces.add(transport)
|
|
86
|
+
|
|
87
|
+
const { expires } = Invite.parse(invite)
|
|
88
|
+
const timer = expires > 0 ? setTimeout(() => stop(), Math.max(0, expires - Date.now())) : null
|
|
89
|
+
|
|
90
|
+
const stop = () => {
|
|
91
|
+
if (timer) clearTimeout(timer)
|
|
92
|
+
if (!this._announces.delete(transport)) return
|
|
93
|
+
transport.close().catch(safetyCatch)
|
|
94
|
+
}
|
|
95
|
+
return stop
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @returns {Map<string, any>} Live BLE links, keyed by peer node id. */
|
|
99
|
+
get peers() {
|
|
100
|
+
return this._transport ? this._transport.peers : new Map()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async _open() {
|
|
104
|
+
if (this._backend === null) this._backend = await loadBackend()
|
|
105
|
+
if (!this._backend) {
|
|
106
|
+
this.state = 'unsupported'
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (this._autoStart) await this.start()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Begin advertising + scanning on the channel-derived UUID. Idempotent.
|
|
114
|
+
* No-op (stays `unsupported`) when no backend is present.
|
|
115
|
+
*
|
|
116
|
+
* @returns {Promise<void>}
|
|
117
|
+
*/
|
|
118
|
+
async start({ name } = {}) {
|
|
119
|
+
if (name !== undefined) this._name = name
|
|
120
|
+
if (this.state === 'unsupported') return
|
|
121
|
+
if (this._transport) {
|
|
122
|
+
// reuse one transport across toggles — recreating leaks iOS CoreBluetooth
|
|
123
|
+
// managers (can't destroy()) and their stale GATT service
|
|
124
|
+
this._transport.name = this._name || ''
|
|
125
|
+
this._transport.resume()
|
|
126
|
+
this.state = this._transport.state
|
|
127
|
+
this.emit('update')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const handle = this._handle
|
|
131
|
+
|
|
132
|
+
this._transport = new BluetoothTransport({
|
|
133
|
+
backend: this._backend,
|
|
134
|
+
network: handle.network,
|
|
135
|
+
// channel isolation for free: same channel → same UUID, like the swarm.
|
|
136
|
+
// No channel → the identity topic (unchanged global-mesh semantics).
|
|
137
|
+
uuid: handle.network.channel
|
|
138
|
+
? Buffer.from(handle.network.channel)
|
|
139
|
+
: handle.identity.publicKey,
|
|
140
|
+
nodeId: handle.identity.publicKey,
|
|
141
|
+
name: this._name || ''
|
|
142
|
+
})
|
|
143
|
+
this._transport.on('update', () => {
|
|
144
|
+
this.state = this._transport.state
|
|
145
|
+
this.emit('update')
|
|
146
|
+
})
|
|
147
|
+
try {
|
|
148
|
+
await this._transport.ready()
|
|
149
|
+
this.state = this._transport.state
|
|
150
|
+
} catch (err) {
|
|
151
|
+
this._transport = null
|
|
152
|
+
this.state = 'off'
|
|
153
|
+
throw err
|
|
154
|
+
}
|
|
155
|
+
this.emit('update')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Stop advertising/scanning and drop links. Idempotent. Sync stops; local
|
|
160
|
+
* data and the rest of the network (DHT) are untouched.
|
|
161
|
+
*
|
|
162
|
+
* @returns {Promise<void>}
|
|
163
|
+
*/
|
|
164
|
+
async stop() {
|
|
165
|
+
for (const t of [...this._announces]) {
|
|
166
|
+
this._announces.delete(t)
|
|
167
|
+
await t.close().catch(safetyCatch)
|
|
168
|
+
}
|
|
169
|
+
if (!this._transport) return
|
|
170
|
+
// suspend, don't close: keep the transport (and its GATT service) so a later
|
|
171
|
+
// start() resumes the same instance instead of leaking a new one. suspend()
|
|
172
|
+
// is async (it says goodbye + drains before hanging up); await it.
|
|
173
|
+
await this._transport.suspend()
|
|
174
|
+
this.state = 'off'
|
|
175
|
+
this.emit('update')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async _close() {
|
|
179
|
+
for (const t of [...this._announces]) {
|
|
180
|
+
this._announces.delete(t)
|
|
181
|
+
await t.close().catch(safetyCatch)
|
|
182
|
+
}
|
|
183
|
+
if (this._transport) {
|
|
184
|
+
await this._transport.close().catch(safetyCatch)
|
|
185
|
+
this._transport = null
|
|
186
|
+
}
|
|
187
|
+
this.state = 'off'
|
|
188
|
+
this.emit('update')
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/build/index.js
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
*/
|
|
45
45
|
export { getHyperdbType } from './builtins.js'
|
|
46
46
|
|
|
47
|
-
export async function build(specDir, schema, { ns = NS } = {}) {
|
|
47
|
+
export async function build(specDir, schema, { ns = NS, extensions = true } = {}) {
|
|
48
48
|
const raw = /** @type {SchemaDefs & { local?: SchemaDefs }} */ (schema?.defs || schema)
|
|
49
49
|
if (!raw || typeof raw !== 'object') throw CeroError.REQUIRED('schema')
|
|
50
50
|
|
|
@@ -57,14 +57,18 @@ export async function build(specDir, schema, { ns = NS } = {}) {
|
|
|
57
57
|
if (v && v.kind === 'extend') {
|
|
58
58
|
const type = refs.main[k]?.type
|
|
59
59
|
if (!type) throw CeroError.INVALID(`'${k}' is not an extendable builtin`)
|
|
60
|
-
|
|
60
|
+
// app schema wins on field conflicts, extensions only add new fields
|
|
61
|
+
extend[type] = fromExt ? { ...v.fields, ...extend[type] } : { ...extend[type], ...v.fields }
|
|
61
62
|
} else if (!fromExt || !(k in defs)) {
|
|
62
63
|
defs[k] = v // app schema wins over an extension's default
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
collect(raw)
|
|
67
|
-
for (const ext of internal.extensions)
|
|
68
|
+
for (const ext of internal.extensions) {
|
|
69
|
+
if (ext.bundled && extensions === false) continue
|
|
70
|
+
if (ext.schema) collect(ext.schema, true)
|
|
71
|
+
}
|
|
68
72
|
|
|
69
73
|
const main = compile(splitMain(defs), ns, 'main')
|
|
70
74
|
const local = compile(defs.local || {}, ns, 'local')
|
|
@@ -128,7 +132,8 @@ function compile(root, ns, scope = 'main') {
|
|
|
128
132
|
dispatches: [],
|
|
129
133
|
indexes: [],
|
|
130
134
|
meta: { ns, refs: {} },
|
|
131
|
-
ns
|
|
135
|
+
ns,
|
|
136
|
+
scope
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
Object.assign(ctx.meta.refs, builtinRefs(ns, scope))
|
|
@@ -207,6 +212,14 @@ function register(name, node, ctx) {
|
|
|
207
212
|
}
|
|
208
213
|
ctx.meta.refs[name].indexes = node.indexes
|
|
209
214
|
}
|
|
215
|
+
// implicit secondary index on the auto-increment `index` — lets reads push
|
|
216
|
+
// reverse/limit down to hyperdb instead of scanning the whole collection.
|
|
217
|
+
// main scope only: local (Storage) rows never get `index` stamped, and an
|
|
218
|
+
// undefined index key crashes the encoder on insert.
|
|
219
|
+
if (ctx.scope === 'main' && !node.indexes?.index) {
|
|
220
|
+
ctx.indexes.push({ name: `${name}-index`, collection: fqn, key: ['index'] })
|
|
221
|
+
refEntry.orderIndex = true
|
|
222
|
+
}
|
|
210
223
|
}
|
|
211
224
|
}
|
|
212
225
|
|
package/src/build/schemas.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t } from '../lib/spec.js'
|
|
2
|
-
import { set, watch } from '../lib/operators.js'
|
|
2
|
+
import { get, set, watch } from '../lib/operators.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Mirror a child handle's `profile` (name + avatar) onto its row in the parent's
|
|
@@ -14,15 +14,26 @@ import { set, watch } from '../lib/operators.js'
|
|
|
14
14
|
*/
|
|
15
15
|
export function handleSync({ fields = { avatar: t.string } } = {}) {
|
|
16
16
|
return {
|
|
17
|
+
name: 'handle-sync',
|
|
18
|
+
bundled: true,
|
|
17
19
|
schema: { handles: t.extend(fields) },
|
|
18
20
|
setup(me) {
|
|
21
|
+
const keys = ['name', ...Object.keys(fields)]
|
|
22
|
+
// skip the write when the row already mirrors the profile — every open
|
|
23
|
+
// re-emits the watch snapshot, and an unconditional set is a new op in
|
|
24
|
+
// the replicated log forever
|
|
25
|
+
const reflect = async (child, data) => {
|
|
26
|
+
child.name = data.name
|
|
27
|
+
const { data: row } = await get(me.handles, child.id)
|
|
28
|
+
if (row && keys.every((k) => row[k] === data[k])) return
|
|
29
|
+
await set(me.handles, { id: child.id, ...data }, { upsert: false })
|
|
30
|
+
}
|
|
19
31
|
const onHandle = (child, opts) => {
|
|
20
32
|
if (!child.profile) return
|
|
21
33
|
if (opts.name) set(child.profile, { name: opts.name }).catch(me._onerror)
|
|
22
34
|
watch(child.profile).on('data', ({ data }) => {
|
|
23
35
|
if (!data?.name) return
|
|
24
|
-
child
|
|
25
|
-
set(me.handles, { id: child.id, ...data }, { upsert: false }).catch(me._onerror)
|
|
36
|
+
reflect(child, data).catch(me._onerror)
|
|
26
37
|
})
|
|
27
38
|
}
|
|
28
39
|
me.on('handle', onHandle, { signal: me.signal })
|
|
@@ -12,24 +12,32 @@ import { get, set, after } from '../lib/operators.js'
|
|
|
12
12
|
*/
|
|
13
13
|
export function profileSync({ fields = { avatar: t.string } } = {}) {
|
|
14
14
|
return {
|
|
15
|
+
name: 'profile-sync',
|
|
16
|
+
bundled: true,
|
|
15
17
|
schema: {
|
|
16
18
|
profile: t.single({ name: t.string, ...fields }),
|
|
17
19
|
members: t.extend(fields)
|
|
18
20
|
},
|
|
19
21
|
setup(me) {
|
|
22
|
+
const keys = ['name', ...Object.keys(fields)]
|
|
20
23
|
const publish = (child, profile) =>
|
|
21
24
|
set(child.members, { id: me.identity.id, ...profile }, { upsert: false }).catch(me._onerror)
|
|
22
25
|
|
|
26
|
+
// skip the broadcast when the member row already mirrors the profile —
|
|
27
|
+
// an unconditional set on every open is a room-wide op forever
|
|
23
28
|
const onHandle = async (child) => {
|
|
24
29
|
const { data: profile } = await get(me.profile)
|
|
25
|
-
if (profile)
|
|
30
|
+
if (!profile) return
|
|
31
|
+
const { data: member } = await get(child.members, me.identity.id)
|
|
32
|
+
if (member && keys.every((k) => member[k] === profile[k])) return
|
|
33
|
+
publish(child, profile)
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
const onProfile = (ctx) => {
|
|
29
37
|
if (ctx.row) me.children.forEach((child) => publish(child, ctx.row))
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
me.on('handle', onHandle, { signal: me.signal })
|
|
40
|
+
me.on('handle', (child) => onHandle(child).catch(me._onerror), { signal: me.signal })
|
|
33
41
|
after(me.profile, onProfile, { signal: me.signal })
|
|
34
42
|
}
|
|
35
43
|
}
|