@le-space/orbitdb-storage-bridge 0.14.0 → 0.15.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 +78 -300
- package/lib/courier-sync.js +251 -9
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,331 +1,109 @@
|
|
|
1
1
|
# OrbitDB Storage Bridge
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
|
|
3
|
+
> Back up, restore and replicate OrbitDB databases through pluggable storage backends, with hash and identity preservation.
|
|
5
4
|
|
|
6
5
|
[](https://opensource.org/licenses/MIT)
|
|
7
6
|
[](https://nodejs.org/)
|
|
8
7
|
[](https://github.com/NiKrause/orbitdb-storage-bridge/actions/workflows/ci.yml)
|
|
9
|
-
[](https://github.com/NiKrause/orbitdb-storage-bridge/actions/workflows/ci.yml)
|
|
10
8
|
[](https://www.npmjs.com/package/@le-space/orbitdb-storage-bridge)
|
|
11
9
|
|
|
12
10
|
> [!NOTE]
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
- [Architectural Considerations for Local-First Applications](#architectural-considerations-for-local-first-applications)
|
|
46
|
-
- [Use Cases](#use-cases)
|
|
47
|
-
- [Architecture Notes](#architecture-notes)
|
|
48
|
-
- [What This Does](#what-this-does)
|
|
49
|
-
- [Roadmap](#roadmap)
|
|
50
|
-
- [Installation](#installation)
|
|
51
|
-
- [Environment Setup](#environment-setup)
|
|
52
|
-
- [Demo](#demo)
|
|
53
|
-
- [NodeJS Demo Scripts (full backup with Manifest, Identity and AccessController and entries blocks)](#nodejs-demo-scripts-full-backup-with-manifest-identity-and-accesscontroller-and-entries-blocks)
|
|
54
|
-
- [Svelte Components](#svelte-components)
|
|
55
|
-
- [How It Works](#how-it-works)
|
|
56
|
-
- [Restore Mechanism](#restore-mechanism)
|
|
57
|
-
- [Logging](#logging)
|
|
58
|
-
- [Testing](#testing)
|
|
59
|
-
- [Contributing](#contributing)
|
|
60
|
-
- [License](#license)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
## Does this work from a browser? Measure it
|
|
64
|
-
|
|
65
|
-
`examples/browser/storage-probe/` is a static page that asks the services this package talks to,
|
|
66
|
-
from a real browser, with no server: do Aleph, Pinata and Lighthouse answer a page at all, does
|
|
67
|
-
their CORS survive a refusal as well as a success, and does anybody on IPFS hold a given CID at an
|
|
68
|
-
address a browser can dial. With your own key it does a real upload and reads it back. The key is
|
|
69
|
-
kept in that browser and sent only to the service it belongs to.
|
|
70
|
-
|
|
71
|
-
It is published from this repository's Pages, and it opens from a checkout just as well — there is
|
|
72
|
-
no build step, which is the point: what it measures is a browser talking to a service, with
|
|
73
|
-
nothing in between.
|
|
74
|
-
|
|
75
|
-
## Status: Storacha sunset (May 2026)
|
|
76
|
-
|
|
77
|
-
Storacha switched off user writes in **May 2026** and has since decommissioned the service.
|
|
78
|
-
Verified on 2026-09-05:
|
|
79
|
-
|
|
80
|
-
| Check | Result |
|
|
81
|
-
| --- | --- |
|
|
82
|
-
| `up.storacha.network`, `console.storacha.network`, `indexer.storacha.network`, `forge.storacha.network` | no DNS record — upload, console and indexing endpoints are gone |
|
|
83
|
-
| `storacha.network`, `docs.storacha.network` | `301` → `fil.one`, the team's new S3-compatible product |
|
|
84
|
-
| `storacha.link`, `w3s.link` | `301` → `dweb.link`; the gateways only forward to the public IPFS gateway now |
|
|
85
|
-
| the widget demo CID linked in the roadmap below | `504` on `w3s.link`, `dweb.link`, `ipfs.io` and `trustless-gateway.link` |
|
|
86
|
-
| `@storacha/client` on npm | last release `2.1.4`, 2026-05-15, not marked deprecated |
|
|
87
|
-
|
|
88
|
-
The gateway those redirects pointed at is gone too. On **2026-09-21** Protocol Labs retired
|
|
89
|
-
`ipfs.io` and `dweb.link`: both answer `429` with an RFC 8594 `Sunset` header and a link to
|
|
90
|
-
[gatewaychanges.ipfs.io](https://gatewaychanges.ipfs.io/), so `storacha.link` and `w3s.link`
|
|
91
|
-
now redirect to a closed door. Retrieval defaults here are `ipfs.aleph.cloud` — the one free
|
|
92
|
-
path gateway measured still serving arbitrary CIDs on 2026-09-23 — with
|
|
93
|
-
`trustless-gateway.link` available for verifiable single-block requests
|
|
94
|
-
(`Accept: application/vnd.ipld.raw`). One host is not a fallback chain, which is why `peer-fetch.js` fetches from the providers that
|
|
95
|
-
hold the blocks instead — bitswap over libp2p, measured from a real page at 0.73 s to dial and
|
|
96
|
-
0.26 s for the block, with no credential in the path. See
|
|
97
|
-
[docs/RECOVERY-ON-A-SECOND-DEVICE.md](docs/RECOVERY-ON-A-SECOND-DEVICE.md).
|
|
98
|
-
|
|
99
|
-
The shutdown is traceable in the open:
|
|
100
|
-
[`upload-service#708`](https://github.com/storacha/upload-service/pull/708) added a `writesDisabled`
|
|
101
|
-
kill switch that makes the eight user-initiated write capabilities
|
|
102
|
-
(`space/blob/{add,remove,replicate}`, `space/index/add`, `upload/{add,remove}`, `store/{add,remove}`)
|
|
103
|
-
return `ServiceUnavailable`, and [`w3infra#636`](https://github.com/storacha/w3infra/pull/636) wired
|
|
104
|
-
`WRITES_DISABLED=true` into the production stack — both merged 2026-05-15. Five days later Storacha
|
|
105
|
-
shipped `storacha space migrate`
|
|
106
|
-
([`@storacha/filecoin-pin-migration`](https://www.npmjs.com/package/@storacha/filecoin-pin-migration)),
|
|
107
|
-
a migration path from Storacha spaces to Filecoin Onchain Cloud. That tool reads spaces through
|
|
108
|
-
endpoints that no longer resolve, so the official migration window has closed.
|
|
109
|
-
|
|
110
|
-
We found no announcement page: the Storacha blog now redirects to `fil.one/blog`, which carries a
|
|
111
|
-
single post ("Introducing Fil One", 2026-08-12). The dates above come from the code and the DNS,
|
|
112
|
-
not from a press release.
|
|
113
|
-
|
|
114
|
-
**What this means for this library**
|
|
115
|
-
|
|
116
|
-
- **Backup does not work.** Every write path ends in `client.uploadFile()` against `up.storacha.network`.
|
|
117
|
-
- **Restore only reaches what someone else still holds.** The p2p-first path still finds blocks that a
|
|
118
|
-
peer or another pinning service pins; the gateway fallback and the `capability.upload.list`
|
|
119
|
-
discovery step cannot reach Storacha any more.
|
|
120
|
-
- **The OrbitDB half is unaffected.** Block extraction, CID bridging, CAR packing, identity
|
|
121
|
-
preservation, UCAN signing and courier-sync are backend-agnostic — only the handful of Storacha
|
|
122
|
-
client methods mapped in [docs/STORAGE-BACKENDS.md](docs/STORAGE-BACKENDS.md) need a new home.
|
|
123
|
-
|
|
124
|
-
If you still have an OrbitDB instance with the blocks in it, re-pin them somewhere else now: the data
|
|
125
|
-
is only as alive as the peers that hold it.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
## What we want to accomplish
|
|
129
|
-
|
|
130
|
-
### The Challenge of Distributed Data Persistence
|
|
131
|
-
|
|
132
|
-
In local-first, peer-to-peer applications built on OrbitDB, data naturally replicates across participating peers through libp2p network connections. Under ideal conditions, this distributed architecture provides inherent redundancy—if one peer loses data, they can resynchronize from other active peers in the network. This peer-to-peer replication model represents the current state of OrbitDB technology.
|
|
133
|
-
|
|
134
|
-
While relay nodes and pinning services (running Helia and OrbitDB instances) can provide additional decentralized persistence for database entries and IPFS-referenced content, the ecosystem still lacks comprehensive infrastructure for long-term archival of large-scale OrbitDB deployments.
|
|
135
|
-
|
|
136
|
-
### Architectural Considerations for Local-First Applications
|
|
137
|
-
|
|
138
|
-
OrbitDB's data model differs fundamentally from traditional centralized databases. In local-first architectures, users typically host their own data locally and selectively replicate with specific peers based on collaboration requirements—not with the entire network. This selective replication is essential for scalability and user experience.
|
|
139
|
-
|
|
140
|
-
However, as OrbitDB instances grow (consider a blog database accumulating years of posts), replication times increase proportionally. At scale, databases require archival strategies and potential sharding to maintain performant synchronization and optimal user experience.
|
|
141
|
-
|
|
142
|
-
### Use Cases
|
|
143
|
-
|
|
144
|
-
This bridge addresses several critical scenarios:
|
|
145
|
-
|
|
146
|
-
**1. Long-term Archival**
|
|
147
|
-
Archive large OrbitDB instances to Storacha/Filecoin storage, enabling efficient cold storage for historical data while maintaining fast replication of active datasets.
|
|
148
|
-
|
|
149
|
-
**2. Disaster Recovery**
|
|
150
|
-
Provides recovery options when all active peers lose data simultaneously. Users can restore databases from Storacha using either their original identity or a new identity, ensuring business continuity beyond the peer-to-peer network's availability.
|
|
151
|
-
|
|
152
|
-
**3. Network Resilience**
|
|
153
|
-
Historically, various network environments (corporate networks, ISPs, regional restrictions) have blocked critical protocols including WebRTC and WebSocket/WebTransport. While libp2p's multi-transport architecture provides numerous fallback options, having an additional restoration pathway through IPFS/Storacha offers defense-in-depth for network-hostile environments. Users can restore databases directly from IPFS and maintain incremental backups after each database mutation.
|
|
154
|
-
|
|
155
|
-
**4. Access Control & Delegation**
|
|
156
|
-
The bridge supports UCAN (User Controlled Authorization Networks) authentication with planned delegation capabilities between OrbitDB instances. This enables fine-grained, time-bound access control for Storacha backup spaces, allowing users to securely share access with collaborators or recovery agents.
|
|
157
|
-
|
|
158
|
-
### Architecture Notes
|
|
159
|
-
|
|
160
|
-
Currently, Storacha backup and restore operations utilize Storacha's gateway infrastructure to interface with Filecoin's decentralized storage network. This hybrid approach balances accessibility with decentralization during the current phase of the Filecoin ecosystem's evolution.
|
|
161
|
-
|
|
162
|
-
That description is now historical: the gateway infrastructure it relies on was decommissioned in 2026 (see [Status: Storacha sunset](#status-storacha-sunset-may-2026)). The hybrid shape of the design still holds — a backend that stores bytes, an IPFS network that serves them — but the backend slot is open. [docs/STORAGE-BACKENDS.md](docs/STORAGE-BACKENDS.md) compares the candidates.
|
|
163
|
-
|
|
164
|
-
## What This Does
|
|
165
|
-
|
|
166
|
-
Backup and restore between **OrbitDB databases** and **Storacha/Filecoin** with full hash and identity preservation. Works in both Node.js and browser environments.
|
|
167
|
-
|
|
168
|
-
The project includes **Svelte components** for browser-based demos and integration (see [SVELTE-COMPONENTS.md](SVELTE-COMPONENTS.md) for detailed documentation).
|
|
169
|
-
|
|
170
|
-
**Features:**
|
|
171
|
-
|
|
172
|
-
- backup/restore between OrbitDB and Storacha in browsers and NodeJS via Storacha key and proof credential
|
|
173
|
-
- full backup per space
|
|
174
|
-
- timestamped backups (multiple backups per space - restore last backup by default)
|
|
175
|
-
- Storacha Svelte components for integration into Svelte projects
|
|
176
|
-
- UCAN authentication
|
|
177
|
-
- Backup/restore functionality with hash and identity preservation
|
|
178
|
-
- OrbitDB CAR file storage [OrbitDB CustomStorage](https://github.com/orbitdb/orbitdb/blob/main/docs/STORAGE.md)
|
|
179
|
-
|
|
180
|
-
## Roadmap
|
|
181
|
-
|
|
182
|
-
> Being re-based on a backend interface instead of a single vendor — the plan is
|
|
183
|
-
> [issue 54](https://github.com/NiKrause/orbitdb-storage-bridge/issues/54), not here. The WebAuthn/varsig items below survive
|
|
184
|
-
> unchanged; the Storacha-named ones become backend-agnostic.
|
|
185
|
-
|
|
186
|
-
- [ ] Live parallel persistence: hand an open database a backend-backed OrbitDB `ComposedStorage`, so every block is written to a backend **as it is created** — during sync and after each update — rather than only when a backup runs.
|
|
187
|
-
- [ ] Today a block becomes durable somewhere else only if bitswap reaches a peer that keeps it, gossipsub reaches a subscriber, or somebody runs a backup. All three depend on another party being reachable at that moment; a write-through storage does not.
|
|
188
|
-
- [ ] The seam already exists: [`lib/car-storage.js`](lib/car-storage.js) implements OrbitDB's storage shape (`put`/`get`/`del`/`iterator`/`merge`) against a CAR file, and `ComposedStorage` is designed to fan a write out to more than one place. This points the same seam at a network backend instead.
|
|
189
|
-
- [ ] Aleph Cloud is the first candidate, because it is the only evaluated backend a browser can write to with no key at all — `POST /api/v0/add` answers unauthenticated with an open CORS header. See [docs/STORAGE-BACKENDS.md](docs/STORAGE-BACKENDS.md); persistence there needs the wallet-signed STORE message, not the ingest alone.
|
|
190
|
-
- [ ] Open question worth settling first: what a write-through storage does when the backend is unreachable. Blocking a local write on a network round trip would make the database only as available as the backend, which is the opposite of the point — so it queues, and the queue is the design.
|
|
191
|
-
|
|
192
|
-
- [ ] v0.4.4 (Feb 2026): Latest-backup pointer (single CID) to avoid listing via the Storacha SDK and restore from the IPFS network for initial OrbitDB syncs.
|
|
193
|
-
- [ ] After each backup, write a small pointer record (JSON) that stores the latest metadata CID, CAR CID, and last heads (block CID).
|
|
194
|
-
- [ ] Store that pointer in a user-controlled place (local storage, QR/share link, WebAuthN largetBlog extension or file download).
|
|
195
|
-
- [ ] v0.5.0 (Feb 2026): OrbitDB CustomStorage (StorachaStorage) ([issue 23](https://github.com/NiKrause/orbitdb-storage-bridge/issues/23)).
|
|
196
|
-
- [ ] v0.6.0 (Mar 2026): WebAuthN + varsig signing/verification (Ed25519 and P-256) for OrbitDB oplog. https://github.com/ChainAgnostic/varsig/blob/main/README.md
|
|
197
|
-
- [ ] v0.6.1 (Mar 2026): WebAuthN + SimpleEncryption example that uses WebAuthN+PRF key material for encrypted backups and restore.
|
|
198
|
-
- [ ] v0.7.0 (Apr 2026): WebAuthN + OrbitDB AccessController (store a UCAN instead of only a DID for admin/write access).
|
|
199
|
-
- [ ] Alice (authenticated via UCAN or Storacha credentials) can delegate/revoke access for Bob with custom/default capabilities ([issue 16](https://github.com/NiKrause/orbitdb-storage-bridge/issues/16)). See [WebAuthN Upload Wall](https://github.com/NiKrause/ucan-upload-wall/tree/browser-only/web) and the [live demo](https://bafybeibdcnp7pr26okzr6kbygcounsz3klyg3vydxwwovmz2ljyzfmprre.ipfs.w3s.link/).
|
|
200
|
-
- [ ] v0.7.1 (May 2026): Storacha Backup & Restore Svelte widget with WebAuthN-varsig UCAN signing/verification (Ed25519/P-256).
|
|
201
|
-
- [ ] v0.7.2 (May 2026): Storacha Backup & Restore React widget with WebAuthN-varsig UCAN signing/verification (Ed25519/P-256).
|
|
202
|
-
- [ ] v0.7.3 (May 2026): Storacha Backup & Restore React widget with WebAuthN-varsig UCAN delegation (Ed25519/P-256).
|
|
203
|
-
- [ ] v0.7.4 (May 2026): UI enhancement for the Storacha Backup & Restore widget (timestamped backup restore and management).
|
|
204
|
-
- [ ] v0.8.0 (Jun 2026): Upgrade to UCAN 1.0 support.
|
|
205
|
-
- [ ] v0.9.0 (Jul 2026): Social backup between devices with DKG (decentralized key generation).
|
|
206
|
-
- [ ] v0.10.0 (Aug 2027): WebAuthN + Roaming Credentials: Have a browser and a mobile with one Yubikey creating one and the same DID and replicating the same OrbitDB
|
|
207
|
-
|
|
208
|
-
Read more on Medium: [Bridging OrbitDB with Storacha: Decentralized Database Backups](https://medium.com/@akashjana663/bridging-orbitdb-with-storacha-decentralized-database-backups-44c7bee5c395)
|
|
209
|
-
|
|
210
|
-
## Installation
|
|
211
|
-
|
|
212
|
-
Install the package via npm:
|
|
11
|
+
> Published as `orbitdb-storacha-bridge` up to 0.6.0 and as `@le-space/orbitdb-storage-bridge`
|
|
12
|
+
> from 0.7.0, because Storacha is no longer the only backend it bridges to. The rename changed no
|
|
13
|
+
> API — only the dependency and the import specifiers. Names that refer to Storacha itself stay as
|
|
14
|
+
> they are: `OrbitDBStorachaBridge`, `backends/storacha`, and the `libp2p:orbitdb-storacha:*` debug
|
|
15
|
+
> namespace.
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
An OrbitDB database is a set of content-addressed blocks and a log whose heads tie them together.
|
|
20
|
+
This package moves those blocks somewhere else and brings them back with their CIDs and the
|
|
21
|
+
writer's identity intact — so a restored database is the same database, and its original author can
|
|
22
|
+
still write to it.
|
|
23
|
+
|
|
24
|
+
Where "somewhere else" is, is a choice:
|
|
25
|
+
|
|
26
|
+
| Backend | Needs | |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `aleph` | nothing | the only backend a browser can write to with no key; ingest only |
|
|
29
|
+
| `aleph-pin` | a wallet | the signed STORE message that makes Aleph keep it |
|
|
30
|
+
| `pinata` | a scoped JWT | pin-by-CID as well as CAR upload |
|
|
31
|
+
| `lighthouse` | an API key | pay once, stored in perpetuity |
|
|
32
|
+
| `memory` | nothing | in-process, for tests and demos |
|
|
33
|
+
| `mirror` | — | wraps others: one backup, several services |
|
|
34
|
+
| `encryption` | — | wraps one: the service holds ciphertext |
|
|
35
|
+
|
|
36
|
+
Reading back needs no account, because a CID is a name anyone can resolve: `peer-fetch` asks the
|
|
37
|
+
peers that hold the blocks over libp2p, `gateway-fetch` asks an HTTP gateway. Two more entry
|
|
38
|
+
points do not involve a storage service at all — `courier-sync` replicates over any byte courier
|
|
39
|
+
(a LoRa mesh, a QR relay, a file), and `dehydrate`/`hydrate` put a database where a second device
|
|
40
|
+
can find it from a passkey alone.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
213
43
|
|
|
214
44
|
```bash
|
|
215
45
|
npm install @le-space/orbitdb-storage-bridge
|
|
216
46
|
```
|
|
217
47
|
|
|
218
|
-
##
|
|
219
|
-
|
|
220
|
-
`STORACHA_KEY` and `STORACHA_PROOF` in `.env` are still what the code reads, and existing credentials still parse — but there is no longer a service to present them to, and no way to mint new ones: the console and the quickstart docs are gone (see [Status: Storacha sunset](#status-storacha-sunset-may-2026)). The test suite's in-memory modes run without credentials; see `test/README.md`.
|
|
221
|
-
|
|
222
|
-
## Demo
|
|
48
|
+
## Quick start
|
|
223
49
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
They back up to Aleph by default, which takes an upload without an account, so
|
|
227
|
-
they run straight after cloning. `STORAGE=pinata` and `STORAGE=lighthouse`
|
|
228
|
-
switch to the paid services and read their keys from the environment — see
|
|
229
|
-
[`examples/storage.js`](examples/storage.js).
|
|
50
|
+
The demos back up to Aleph, which accepts an upload without an account, so they run straight after
|
|
51
|
+
cloning:
|
|
230
52
|
|
|
231
53
|
```sh
|
|
232
|
-
node examples/backup-demo.js
|
|
233
|
-
BACKUP_CID=<cid> node examples/restore-demo.js
|
|
54
|
+
node examples/backup-demo.js # prints the backup's CID
|
|
55
|
+
BACKUP_CID=<cid> node examples/restore-demo.js # restores it on a node that has never seen it
|
|
234
56
|
```
|
|
235
57
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
- `node` [`examples/demo-different-identity.js`](examples/demo-different-identity.js) - Different identities with access control enforcement
|
|
240
|
-
- `node` [`examples/demo-shared-identities.js`](examples/demo-shared-identities.js) - Shared identity backup/restore scenarios
|
|
241
|
-
|
|
242
|
-
An Aleph upload is not kept: staying stored takes a wallet-signed STORE message
|
|
243
|
-
this library does not send, so restore what you back up while the demo is still
|
|
244
|
-
running.
|
|
245
|
-
|
|
246
|
-
The scripts written against Storacha's space and UCAN model are in
|
|
247
|
-
[`examples/storacha/`](examples/storacha/README.md). None of them runs end to
|
|
248
|
-
end since the uploads stopped, and the README there says what replaced each.
|
|
249
|
-
|
|
250
|
-
### In a browser: a database back on a device that has nothing
|
|
58
|
+
`STORAGE=pinata` and `STORAGE=lighthouse` switch backends and read their keys from the environment
|
|
59
|
+
— see [`examples/storage.js`](examples/storage.js). An Aleph upload is not retained without the
|
|
60
|
+
signed STORE message, so restore what you back up while the demo is still running.
|
|
251
61
|
|
|
252
|
-
|
|
253
|
-
[
|
|
254
|
-
|
|
62
|
+
Further scripts: identity and access-control scenarios in [`examples/`](examples/), and the
|
|
63
|
+
Storacha-era scripts in [`examples/storacha/`](examples/storacha/README.md), none of which runs end
|
|
64
|
+
to end any more.
|
|
255
65
|
|
|
256
|
-
|
|
257
|
-
the same on every device;
|
|
258
|
-
2. **a list** is made and written to;
|
|
259
|
-
3. **`dehydrate`** backs it up to Aleph as a CAR, and publishes an IPNS pointer under a name
|
|
260
|
-
the key derives;
|
|
261
|
-
4. **on another device** — or the same one, wiped — the same key finds the pointer, **`hydrate`**
|
|
262
|
-
brings the list back, and the list takes new entries, because the writer is the same.
|
|
66
|
+
## Recovery on a second device
|
|
263
67
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
68
|
+
[The recovery page](https://nikrause.github.io/orbitdb-storage-bridge/recovery/) runs the whole
|
|
69
|
+
procedure in a phone's browser with a security key and nothing else: the passkey gives the
|
|
70
|
+
identity, `dehydrate` backs the database up and publishes an IPNS pointer under a name derived from
|
|
71
|
+
the key, and on another device the same key finds that pointer — so `hydrate` needs no CID, no
|
|
72
|
+
address and no file. The restored database takes new entries, because the writer is the same.
|
|
267
73
|
|
|
268
|
-
|
|
74
|
+
On 21 September 2026 it ran that way on two phones: a Galaxy Fold 5 backed up and was reset, and a
|
|
75
|
+
Galaxy A57 with the same key brought the database back and wrote to it. Source:
|
|
76
|
+
[`examples/svelte/recovery/`](examples/svelte/recovery).
|
|
269
77
|
|
|
270
|
-
|
|
78
|
+
## Documentation
|
|
271
79
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
The library uses **@libp2p/logger** for consistent logging across the libp2p ecosystem. Control logging with the `DEBUG` environment variable:
|
|
289
|
-
|
|
290
|
-
**Node.js:**
|
|
291
|
-
```bash
|
|
292
|
-
# Enable all logs from this library (the namespace kept its old name)
|
|
293
|
-
DEBUG=libp2p:orbitdb-storacha:* node your-script.js
|
|
294
|
-
|
|
295
|
-
# Enable specific components
|
|
296
|
-
DEBUG=libp2p:orbitdb-storacha:bridge node your-script.js
|
|
297
|
-
|
|
298
|
-
# Enable all libp2p logs (includes this library + libp2p internals)
|
|
299
|
-
DEBUG=libp2p:* node your-script.js
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
**Browser:**
|
|
303
|
-
```javascript
|
|
304
|
-
// In browser console or before loading the application
|
|
305
|
-
localStorage.setItem('debug', 'libp2p:orbitdb-storacha:*')
|
|
306
|
-
// Then refresh the page
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
The logger supports printf-style formatting:
|
|
310
|
-
- `%s` - string
|
|
311
|
-
- `%d` - number
|
|
312
|
-
- `%o` - object
|
|
313
|
-
- `%p` - peer ID
|
|
314
|
-
- `%b` - base58btc encoded data
|
|
315
|
-
- `%t` - base32 encoded data
|
|
316
|
-
|
|
317
|
-
## Testing
|
|
318
|
-
|
|
319
|
-
See `test/README.md` for detailed test documentation, modes (in-memory vs production),
|
|
320
|
-
and how to run each suite.
|
|
80
|
+
| | |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| [docs/DESIGN.md](docs/DESIGN.md) | the problem this solves, and how a backup and a restore actually work |
|
|
83
|
+
| [docs/STORAGE-BACKENDS.md](docs/STORAGE-BACKENDS.md) | every backend evaluated — prices, limits, and what has been verified against a live account |
|
|
84
|
+
| [docs/RECOVERY-ON-A-SECOND-DEVICE.md](docs/RECOVERY-ON-A-SECOND-DEVICE.md) | the passkey recovery procedure, step by step |
|
|
85
|
+
| [docs/CAR-BACKUP.md](docs/CAR-BACKUP.md) | CAR-based timestamped backups |
|
|
86
|
+
| [SVELTE-COMPONENTS.md](SVELTE-COMPONENTS.md) | the browser components — older than this repository's copy of them; [docs/STORACHA-UI-HISTORY.md](docs/STORACHA-UI-HISTORY.md) records what each piece could do and where it went |
|
|
87
|
+
| [docs/LOGGING.md](docs/LOGGING.md) | debug namespaces, in Node and in a browser |
|
|
88
|
+
| [ROADMAP.md](ROADMAP.md) | what is planned |
|
|
89
|
+
| [docs/STORACHA-SUNSET.md](docs/STORACHA-SUNSET.md) | why Storacha stopped being the default, with dates and evidence |
|
|
90
|
+
| [test/README.md](test/README.md) | the suites, and which of them need credentials |
|
|
91
|
+
|
|
92
|
+
`examples/browser/storage-probe/` is a static page that asks these services from a real browser
|
|
93
|
+
with no build step: whether they answer a page at all, whether their CORS survives a refusal as
|
|
94
|
+
well as a success, and whether anyone on IPFS holds a given CID at an address a browser can dial.
|
|
321
95
|
|
|
322
96
|
## Contributing
|
|
323
97
|
|
|
324
|
-
|
|
325
|
-
2. Create a feature branch
|
|
326
|
-
3. Add tests for new functionality
|
|
327
|
-
4. Submit a pull request
|
|
98
|
+
Fork, branch, add tests for new behaviour, open a pull request.
|
|
328
99
|
|
|
329
100
|
## License
|
|
330
101
|
|
|
331
|
-
MIT
|
|
102
|
+
MIT.
|
|
103
|
+
|
|
104
|
+
LoRa® is a trademark of Semtech Corporation and Meshtastic® a registered trademark of Meshtastic
|
|
105
|
+
LLC; OrbitDB, IPFS, Filecoin, Storacha, Pinata, Lighthouse and Aleph belong to their respective
|
|
106
|
+
owners and appear here only to say what this package interoperates with. This project is
|
|
107
|
+
affiliated with none of them and contains no code from the Meshtastic libraries, which are
|
|
108
|
+
GPL-3.0-only — see [docs/TRADEMARKS.md](docs/TRADEMARKS.md) for why that line matters and which
|
|
109
|
+
side of it this package is on.
|
package/lib/courier-sync.js
CHANGED
|
@@ -17,15 +17,84 @@
|
|
|
17
17
|
* all three. Returns an unsubscribe function.
|
|
18
18
|
*
|
|
19
19
|
* Wire messages (dag-cbor encoded, one per courier payload):
|
|
20
|
-
* { v, tag, p, t: "announce", heads: [hash] }
|
|
20
|
+
* { v, tag, p, t: "announce", heads: [hash], r? } r: please reconcile
|
|
21
21
|
* { v, tag, p, t: "want", cids: [hash], have: [hash] }
|
|
22
22
|
* { v, tag, p, t: "blocks", heads: [hash], blocks: [{ hash, bytes }] }
|
|
23
|
+
* { v, tag, p, t: "op", id: bytes8, o: { op, key, value } } one change
|
|
23
24
|
* { v, tag, p, t: "hello" } is anybody keeping this database out there?
|
|
24
25
|
* { v, tag, p, t: "here" } the answer
|
|
25
26
|
* `tag` is a short hash of the database address, so couriers can be shared
|
|
26
27
|
* between databases without cross-talk while the address itself stays off
|
|
27
28
|
* the air (the mesh reads everything).
|
|
28
29
|
*
|
|
30
|
+
* Two ways to carry a change, and the cheap one is not replication.
|
|
31
|
+
*
|
|
32
|
+
* `announce`/`want`/`blocks` move the log itself: signed entries, their
|
|
33
|
+
* ancestry, the identity that vouches for them. That is real OrbitDB
|
|
34
|
+
* replication — every peer ends up with the same log and the same hashes — and
|
|
35
|
+
* it is what a cold join needs, because a peer that has never seen the database
|
|
36
|
+
* has to be given the manifest and the whole history.
|
|
37
|
+
*
|
|
38
|
+
* It is also expensive out of all proportion to a todo. The operation inside
|
|
39
|
+
* that entry — `{op: "PUT", key, value}`, which OrbitDB hands to every
|
|
40
|
+
* `update` listener already — is the whole of what anyone meant to send.
|
|
41
|
+
*
|
|
42
|
+
* Measured over funkpost's Meshtastic courier, framing, ARQ and duty-cycle
|
|
43
|
+
* pacing included, for one further change to a list both sides already hold:
|
|
44
|
+
*
|
|
45
|
+
* | plane | bytes on the air |
|
|
46
|
+
* | ------------ | ---------------- |
|
|
47
|
+
* | `delta` | 1774 B |
|
|
48
|
+
* | `operations` | 86 B |
|
|
49
|
+
*
|
|
50
|
+
* Twenty times. On a carrier moving half a kilobyte a minute that is three and
|
|
51
|
+
* a half minutes against ten seconds, for one ticked box.
|
|
52
|
+
*
|
|
53
|
+
* So `op` ships the operation and lets the far side perform it locally, as its
|
|
54
|
+
* own write, with its own identity. What that buys and what it costs:
|
|
55
|
+
*
|
|
56
|
+
* - The two logs **diverge**: each device holds its own entry for the same
|
|
57
|
+
* change, with a different hash. For a keyvalue database the materialised
|
|
58
|
+
* view still agrees, and once an IP path returns OrbitDB's own sync merges
|
|
59
|
+
* both logs and the divergence becomes history rather than disagreement.
|
|
60
|
+
* - Two devices editing the **same key** while both are offline can show
|
|
61
|
+
* different values until that merge, because each applies the other's
|
|
62
|
+
* operation after its own. A CRDT would not have this; an oplog with
|
|
63
|
+
* last-write-wins does. For a shared todo list it is an acceptable trade
|
|
64
|
+
* and it must be a deliberate one.
|
|
65
|
+
* - **The receiver must be allowed to write.** This is the requirement that
|
|
66
|
+
* surprises: on the delta plane the far side only *stores* an entry someone
|
|
67
|
+
* else signed, so a database whose access controller names one writer
|
|
68
|
+
* replicates fine. Here the far side performs the change as its own write,
|
|
69
|
+
* so an access controller that excludes it refuses the operation outright —
|
|
70
|
+
* *"Key … is not allowed to write to the log"*. `write: ["*"]` makes it
|
|
71
|
+
* work, which is what funkpost's mesh-todo already does.
|
|
72
|
+
*
|
|
73
|
+
* That is also where a pairing between two devices belongs, rather than in
|
|
74
|
+
* a new protocol of its own: two devices that have met put each other's
|
|
75
|
+
* OrbitDB identity in the write set, and "anyone holding the channel key"
|
|
76
|
+
* becomes "these two". The access controller is the authorisation
|
|
77
|
+
* mechanism this plane needs, and it already exists.
|
|
78
|
+
* - Nothing is signed end to end. The receiver vouches for the change with
|
|
79
|
+
* its own identity, so trust rests entirely on the carrier — which on a
|
|
80
|
+
* Meshtastic channel means everyone holding the channel key. The sender id
|
|
81
|
+
* in `p` is not authentication and must never be read as such.
|
|
82
|
+
* - Reconciliation is asked for, not inferred: `announce()` sets `r` on the
|
|
83
|
+
* wire and the far side answers with a delta. An announce without it is
|
|
84
|
+
* noted and left alone, because divergent logs are this plane's ordinary
|
|
85
|
+
* state rather than a gap.
|
|
86
|
+
* - A lost `op` is simply lost; there is no ancestry to notice the hole.
|
|
87
|
+
* Note that `send`'s own comment — that a dropped message is re-derivable
|
|
88
|
+
* because a peer still wanting it asks again — holds for `announce`, `want`
|
|
89
|
+
* and `blocks` and **not** for `op`: an operation the outbox sheds under
|
|
90
|
+
* `maxOutbox` takes its change with it.
|
|
91
|
+
* `announce()` is the repair: it runs the delta plane, which is complete by
|
|
92
|
+
* construction. Cheap path for the ordinary change, expensive path when
|
|
93
|
+
* something has to be made right.
|
|
94
|
+
*
|
|
95
|
+
* `liveUpdates: "operations"` opts in. The default stays `"delta"`, so nothing
|
|
96
|
+
* changes for a consumer that does not ask.
|
|
97
|
+
*
|
|
29
98
|
* `p` is a four-byte sender id, and it is what makes *presence* possible: a
|
|
30
99
|
* carrier can tell you a radio is in range, which is not the question. The
|
|
31
100
|
* question is whether another program is keeping the same database, and only
|
|
@@ -52,6 +121,13 @@ export const COURIER_SYNC_VERSION = 1;
|
|
|
52
121
|
|
|
53
122
|
const TAG_LENGTH = 8;
|
|
54
123
|
|
|
124
|
+
// Eight bytes naming a change, derived from the entry hash so that every
|
|
125
|
+
// retransmission of the same change carries the same id and applies once.
|
|
126
|
+
const CHANGE_ID_LENGTH = 8;
|
|
127
|
+
// How many change ids to remember. A duplicate arriving after this many others
|
|
128
|
+
// is applied twice: one extra entry in the log, not a wrong value.
|
|
129
|
+
const SEEN_OPS = 512;
|
|
130
|
+
|
|
55
131
|
// Four bytes of sender id: enough that two peers in one conversation collide
|
|
56
132
|
// with probability ~1 in 4 billion, small enough to ride on every message.
|
|
57
133
|
const PEER_ID_LENGTH = 4;
|
|
@@ -185,6 +261,19 @@ function isOplogEntry(value) {
|
|
|
185
261
|
* Reading the whole ancestry locally to avoid transmitting it is a good trade
|
|
186
262
|
* on any carrier: the reads are a blockstore away, the bytes are airtime.
|
|
187
263
|
*/
|
|
264
|
+
/**
|
|
265
|
+
* Eight bytes naming one change, the same on every retransmission of it.
|
|
266
|
+
*
|
|
267
|
+
* Derived from the entry hash rather than from the operation, so that two
|
|
268
|
+
* genuinely separate writes of the same value — ticking a box off and on and
|
|
269
|
+
* off again — stay separate changes, while a duplicate delivery of one write
|
|
270
|
+
* is recognised and applied once.
|
|
271
|
+
*/
|
|
272
|
+
async function changeId(entryHash) {
|
|
273
|
+
const digest = await sha256.digest(new TextEncoder().encode(entryHash));
|
|
274
|
+
return digest.digest.slice(0, CHANGE_ID_LENGTH);
|
|
275
|
+
}
|
|
276
|
+
|
|
188
277
|
async function reachableFrom(db, roots) {
|
|
189
278
|
const held = new Set();
|
|
190
279
|
const queue = [...roots];
|
|
@@ -192,6 +281,18 @@ async function reachableFrom(db, roots) {
|
|
|
192
281
|
const hash = queue.shift();
|
|
193
282
|
if (held.has(hash)) continue;
|
|
194
283
|
held.add(hash);
|
|
284
|
+
// The index first, and only then the blocks. `IPFSBlockStorage.get` on a
|
|
285
|
+
// miss waits out a network timeout — measured at 20 s against a Helia node
|
|
286
|
+
// with no peers, which is every phone in the field — and the peer's heads
|
|
287
|
+
// are precisely where misses live: their newest entry is the one we have
|
|
288
|
+
// not got. `has` reads the log's index and answers at once.
|
|
289
|
+
//
|
|
290
|
+
// The rule is already written down twenty lines below, in the closure
|
|
291
|
+
// check, and this walk broke it: 0.14.0 built correct deltas and took
|
|
292
|
+
// twenty seconds to do it, which stalled the exchange past every timeout
|
|
293
|
+
// around it (funkpost#170). The suite could not see it, because its nodes
|
|
294
|
+
// are built offline and a miss there fails instantly.
|
|
295
|
+
if (!(await db.log.has(hash))) continue; // theirs, not ours: stop here
|
|
195
296
|
const bytes = await db.log.storage.get(hash).catch(() => null);
|
|
196
297
|
if (!bytes) continue; // not ours to follow; their ancestry ends here for us
|
|
197
298
|
let value;
|
|
@@ -466,6 +567,11 @@ async function applyDeltaToStores({ blockstore, log, events, delta }) {
|
|
|
466
567
|
* @param {string} [params.address] Database address, required when `db` is not given
|
|
467
568
|
* @param {Object} params.courier The byte courier (see module docs)
|
|
468
569
|
* @param {Object} [params.dbOptions] Extra options for the lazy `orbitdb.open`
|
|
570
|
+
* @param {"delta"|"operations"} [params.liveUpdates="delta"] How a local write
|
|
571
|
+
* travels. `"delta"` announces and lets the peer ask, which moves signed
|
|
572
|
+
* entries and keeps both logs identical. `"operations"` ships the operation
|
|
573
|
+
* itself — thirteen times smaller, at the cost of divergent logs and no
|
|
574
|
+
* end-to-end signature. See the module docs before choosing it.
|
|
469
575
|
* @param {boolean} [params.announceOnLocalUpdate=true] Announce as soon as a
|
|
470
576
|
* local write lands. Default keeps the eager behaviour. Set false where the
|
|
471
577
|
* courier is expensive — a duty-cycled radio, say — and the application would
|
|
@@ -500,6 +606,7 @@ export async function createCourierSync({
|
|
|
500
606
|
dbOptions = {},
|
|
501
607
|
rejoinIntervalMs = 15000,
|
|
502
608
|
announceOnLocalUpdate = true,
|
|
609
|
+
liveUpdates = "delta",
|
|
503
610
|
peerId = randomPeerId(),
|
|
504
611
|
peerTimeoutMs = PEER_TIMEOUT_MS,
|
|
505
612
|
sendTimeoutMs = SEND_TIMEOUT_MS,
|
|
@@ -515,6 +622,9 @@ export async function createCourierSync({
|
|
|
515
622
|
if (!(peerId instanceof Uint8Array) || peerId.length !== PEER_ID_LENGTH) {
|
|
516
623
|
throw new Error(`peerId must be ${PEER_ID_LENGTH} bytes`);
|
|
517
624
|
}
|
|
625
|
+
if (liveUpdates !== "delta" && liveUpdates !== "operations") {
|
|
626
|
+
throw new Error('liveUpdates must be "delta" or "operations"');
|
|
627
|
+
}
|
|
518
628
|
const databaseAddress = address || (db && db.address);
|
|
519
629
|
if (!databaseAddress) {
|
|
520
630
|
throw new Error("Either an open db or a database address is required");
|
|
@@ -530,6 +640,9 @@ export async function createCourierSync({
|
|
|
530
640
|
const peers = new Map(); // sender id (hex) -> when we last heard it
|
|
531
641
|
let lastHeardAt = null; // any traffic for this database, identified or not
|
|
532
642
|
const listeners = { synced: [], applied: [], message: [], error: [] };
|
|
643
|
+
// Change ids already applied, oldest first. Insertion-ordered, so the oldest
|
|
644
|
+
// key is the first one Map iteration yields.
|
|
645
|
+
const seenOps = new Map();
|
|
533
646
|
let database = db;
|
|
534
647
|
// Opened on first contact but not handed out: the bootstrap is not in it yet.
|
|
535
648
|
// The protocol works on it all the same, so repair stays incremental.
|
|
@@ -714,7 +827,13 @@ export async function createCourierSync({
|
|
|
714
827
|
const ourHeadHashes = async (target = local()) =>
|
|
715
828
|
target ? (await target.log.heads()).map((entry) => entry.hash) : [];
|
|
716
829
|
|
|
717
|
-
|
|
830
|
+
/**
|
|
831
|
+
* @param {boolean} [reconcile] Ask the peer to close the gap, not merely to
|
|
832
|
+
* note where we stand. Only the operation plane distinguishes the two:
|
|
833
|
+
* there, divergent logs are the ordinary state rather than a gap, so a
|
|
834
|
+
* reconciliation has to be asked for or it would run after every change.
|
|
835
|
+
*/
|
|
836
|
+
const announce = async (reconcile = false) => {
|
|
718
837
|
if (!local()) {
|
|
719
838
|
// Nothing local yet — not even the manifest. An announce of empty heads
|
|
720
839
|
// cannot get one from a peer whose log is also empty, so first contact
|
|
@@ -723,7 +842,9 @@ export async function createCourierSync({
|
|
|
723
842
|
await send({ t: "want", cids: [], have: [] });
|
|
724
843
|
return;
|
|
725
844
|
}
|
|
726
|
-
|
|
845
|
+
const message = { t: "announce", heads: await ourHeadHashes() };
|
|
846
|
+
if (reconcile) message.r = true;
|
|
847
|
+
await send(message);
|
|
727
848
|
};
|
|
728
849
|
|
|
729
850
|
/**
|
|
@@ -762,10 +883,12 @@ export async function createCourierSync({
|
|
|
762
883
|
// blocks is served — going quiet must not mean going deaf.
|
|
763
884
|
if (!announceOnLocalUpdate) return;
|
|
764
885
|
if (!database || offUpdate) return;
|
|
765
|
-
const onUpdate = () => {
|
|
886
|
+
const onUpdate = (entry) => {
|
|
766
887
|
if (applying) return; // courier-applied entries already end in an announce
|
|
767
888
|
queue = queue
|
|
768
|
-
.then(() =>
|
|
889
|
+
.then(() =>
|
|
890
|
+
liveUpdates === "operations" ? shipOperation(entry) : announceSoon(),
|
|
891
|
+
)
|
|
769
892
|
.catch((error) => emit("error", error));
|
|
770
893
|
};
|
|
771
894
|
database.events.on("update", onUpdate);
|
|
@@ -784,6 +907,16 @@ export async function createCourierSync({
|
|
|
784
907
|
);
|
|
785
908
|
return;
|
|
786
909
|
}
|
|
910
|
+
// On the operation plane both sides write their own entry for the same
|
|
911
|
+
// change, so the peer's heads being strangers to us is the ordinary state
|
|
912
|
+
// and not a gap to close. Without this, the acknowledgement that ends a
|
|
913
|
+
// blocks delivery reads as a reconcile request, and on logs that are
|
|
914
|
+
// permanently divergent each answer earns another: measured over the memory
|
|
915
|
+
// courier at nine messages for one reconcile against six with it. Both
|
|
916
|
+
// terminate — this is chatter, not a loop — and it does not change what an
|
|
917
|
+
// operation costs, which is one message either way.
|
|
918
|
+
if (liveUpdates === "operations" && !message.r) return;
|
|
919
|
+
|
|
787
920
|
const ours = await ourHeadHashes();
|
|
788
921
|
const theirSet = new Set(theirHeads);
|
|
789
922
|
const theyLack = ours.filter((hash) => !theirSet.has(hash));
|
|
@@ -875,8 +1008,114 @@ export async function createCourierSync({
|
|
|
875
1008
|
emit("synced", { joined: result.joined, entries: result.entries });
|
|
876
1009
|
}
|
|
877
1010
|
// Tells the peer where we now stand — their diff turns empty and the
|
|
878
|
-
// exchange goes quiet; doubles as an end-to-end acknowledgement.
|
|
879
|
-
|
|
1011
|
+
// exchange goes quiet; doubles as an end-to-end acknowledgement. Not a
|
|
1012
|
+
// reconcile request: two peers answering each other's would never stop.
|
|
1013
|
+
await announce(false);
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Remember a change id. Answers whether it was new.
|
|
1018
|
+
*
|
|
1019
|
+
* Our own changes are remembered as they go out, so a mesh repeating us —
|
|
1020
|
+
* or a peer echoing the operation onward — cannot make us perform our own
|
|
1021
|
+
* write a second time.
|
|
1022
|
+
*/
|
|
1023
|
+
const rememberOp = (id) => {
|
|
1024
|
+
const key = hex(id);
|
|
1025
|
+
if (seenOps.has(key)) return false;
|
|
1026
|
+
seenOps.set(key, Date.now());
|
|
1027
|
+
while (seenOps.size > SEEN_OPS) seenOps.delete(seenOps.keys().next().value);
|
|
1028
|
+
return true;
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Perform someone else's change as our own write.
|
|
1033
|
+
*
|
|
1034
|
+
* Only the operations a keyvalue, documents or events database emits. A type
|
|
1035
|
+
* whose operations are named differently is not carried this way, and saying
|
|
1036
|
+
* so out loud is better than writing something that silently does nothing.
|
|
1037
|
+
*/
|
|
1038
|
+
const performOperation = async (target, op) => {
|
|
1039
|
+
const run = () => {
|
|
1040
|
+
if (op.op === "PUT") return target.put(op.key, op.value);
|
|
1041
|
+
if (op.op === "DEL") return target.del(op.key);
|
|
1042
|
+
if (op.op === "ADD") return target.add(op.value);
|
|
1043
|
+
throw new Error(`cannot perform operation ${op.op} over the courier`);
|
|
1044
|
+
};
|
|
1045
|
+
try {
|
|
1046
|
+
return await run();
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
// The refusal every consumer of this plane meets first, and OrbitDB's own
|
|
1049
|
+
// message does not say why it is happening here rather than on the delta
|
|
1050
|
+
// plane. Name the cause where it is read.
|
|
1051
|
+
if (/not allowed to write/i.test(error?.message || "")) {
|
|
1052
|
+
throw new Error(
|
|
1053
|
+
"the operation plane performs the change as a local write, so this " +
|
|
1054
|
+
"device must be in the database's write set — open it with an " +
|
|
1055
|
+
'access controller that admits it (mesh-todo uses write: ["*"]), ' +
|
|
1056
|
+
'or stay on liveUpdates: "delta"',
|
|
1057
|
+
{ cause: error },
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
/** One local change, on its way as an operation rather than as a log entry. */
|
|
1065
|
+
const shipOperation = async (entry) => {
|
|
1066
|
+
const op = entry && entry.payload;
|
|
1067
|
+
if (!op || typeof op.op !== "string" || !entry.hash) return;
|
|
1068
|
+
const id = await changeId(entry.hash);
|
|
1069
|
+
rememberOp(id); // ours: never perform it on ourselves
|
|
1070
|
+
post({ t: "op", id, o: op });
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
const handleOperation = async (message) => {
|
|
1074
|
+
const id = message.id;
|
|
1075
|
+
const op = message.o;
|
|
1076
|
+
if (!(id instanceof Uint8Array) || id.length !== CHANGE_ID_LENGTH) return;
|
|
1077
|
+
if (!op || typeof op.op !== "string") return;
|
|
1078
|
+
|
|
1079
|
+
const target = local();
|
|
1080
|
+
// An operation carries no manifest, so it cannot bootstrap a database. Ask
|
|
1081
|
+
// for the whole thing instead and let the delta plane answer.
|
|
1082
|
+
if (!target) {
|
|
1083
|
+
post({ t: "want", cids: [], have: [] }, { to: senderOf(message) });
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const report = (applied) =>
|
|
1088
|
+
emit("applied", {
|
|
1089
|
+
complete: true,
|
|
1090
|
+
heads: 1,
|
|
1091
|
+
missing: 0,
|
|
1092
|
+
joined: applied ? 1 : 0,
|
|
1093
|
+
held: applied ? 0 : 1,
|
|
1094
|
+
absent: 0,
|
|
1095
|
+
malformed: 0,
|
|
1096
|
+
refused: 0,
|
|
1097
|
+
via: "operation",
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
// Duplicates are ordinary on a broadcast carrier with retransmission, and
|
|
1101
|
+
// performing one twice would put a second entry in the log for a change
|
|
1102
|
+
// that already happened.
|
|
1103
|
+
if (!rememberOp(id)) return report(false);
|
|
1104
|
+
|
|
1105
|
+
// The write we are about to make fires the database's own "update", which
|
|
1106
|
+
// is what an application listens to — so the far side's list moves without
|
|
1107
|
+
// anything else being wired. The guard is what stops that same event from
|
|
1108
|
+
// sending the change straight back out.
|
|
1109
|
+
applying = true;
|
|
1110
|
+
try {
|
|
1111
|
+
await performOperation(target, op);
|
|
1112
|
+
} finally {
|
|
1113
|
+
applying = false;
|
|
1114
|
+
}
|
|
1115
|
+
// No "synced": that event carries the entries that were joined, and nothing
|
|
1116
|
+
// was joined here — the database performed a write of its own. "applied"
|
|
1117
|
+
// is where a courier delivery reports what it came to.
|
|
1118
|
+
report(true);
|
|
880
1119
|
};
|
|
881
1120
|
|
|
882
1121
|
const handlePayload = (bytes) => {
|
|
@@ -905,6 +1144,7 @@ export async function createCourierSync({
|
|
|
905
1144
|
if (message.t === "announce") return handleAnnounce(message);
|
|
906
1145
|
if (message.t === "want") return handleWant(message);
|
|
907
1146
|
if (message.t === "blocks") return handleBlocks(message);
|
|
1147
|
+
if (message.t === "op") return handleOperation(message);
|
|
908
1148
|
})
|
|
909
1149
|
.catch((error) => emit("error", error));
|
|
910
1150
|
};
|
|
@@ -923,7 +1163,9 @@ export async function createCourierSync({
|
|
|
923
1163
|
started = true;
|
|
924
1164
|
unsubscribe = courier.onPayload(handlePayload);
|
|
925
1165
|
watchLocalUpdates();
|
|
926
|
-
|
|
1166
|
+
// A start reconciles: whatever happened while this program was not
|
|
1167
|
+
// running is exactly what the delta plane is for. Once per session.
|
|
1168
|
+
await announce(true);
|
|
927
1169
|
// A joiner that has not bootstrapped keeps re-asking on its own until
|
|
928
1170
|
// the database opens — so a bootstrap the lossy channel dropped heals
|
|
929
1171
|
// without the user pressing "join" again. Cleared the moment the
|
|
@@ -941,7 +1183,7 @@ export async function createCourierSync({
|
|
|
941
1183
|
}
|
|
942
1184
|
},
|
|
943
1185
|
/** Re-announce — recovery poke after suspected loss. */
|
|
944
|
-
announce: () => announce(),
|
|
1186
|
+
announce: () => announce(true),
|
|
945
1187
|
/** This instance's sender id, as it appears on the wire. */
|
|
946
1188
|
peerId: hex(peerId),
|
|
947
1189
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@le-space/orbitdb-storage-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Back up, restore and replicate OrbitDB databases through pluggable storage backends, with hash and identity preservation",
|
|
5
5
|
"main": "lib/orbitdb-storacha-bridge.js",
|
|
6
6
|
"svelte": "dist/components/",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"clear-space": "node examples/storacha/clear-space.js",
|
|
47
47
|
"car-demo": "node examples/storacha/car-backup-demo.js",
|
|
48
48
|
"ucan-demo": "node examples/storacha/ucan-demo.js",
|
|
49
|
+
"check:links": "node scripts/check-links.mjs",
|
|
49
50
|
"lint": "eslint --config eslint.config.js '{lib,examples,test}/**/*.js' --ignore-pattern 'examples/svelte/*/build/**' --ignore-pattern 'examples/svelte/*/dist/**' --ignore-pattern 'examples/svelte/**/.svelte-kit/**' --ignore-pattern 'node_modules/**'",
|
|
50
51
|
"format": "prettier --write lib/ examples/ test/ --ignore-path '.prettierignore'",
|
|
51
52
|
"clean": "rm -rf ./cid-bridge-test* ./demo-test*",
|