beamdb 0.10.0 → 0.17.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 CHANGED
@@ -23,6 +23,7 @@
23
23
  - [Wire Protocol](#wire-protocol)
24
24
  - [Configuration](#configuration)
25
25
  - [Testing](#testing)
26
+ - [Benchmarks](#benchmarks)
26
27
  - [Features](#features)
27
28
  - [Security](#security)
28
29
  - [Contributing](#contributing)
@@ -45,7 +46,7 @@ BEAM is a maintained fork of [rod](https://github.com/mmalmi/rod) — a from-scr
45
46
  - **Real-time** — `on()` subscriptions deliver updates as they propagate through the mesh
46
47
  - **Eventually consistent** — last-write-wins conflict resolution via timestamps (matching Gun.js)
47
48
  - **Encrypted** — SEA layer provides Ed25519 signing, X25519 ECDH, and AES-256-GCM encryption
48
- - **Persistent** — `redb` embedded database for disk-backed storage, or `Persy` for high-concurrency workloads, or in-memory for ephemeral use
49
+ - **Persistent** — `redb` embedded database (default), `fjall` LSM-tree (recommended for multi-node), `Persy` for high-concurrency, or in-memory for ephemeral use
49
50
  - **Multi-transport** — WebSocket (relay), UDP multicast (LAN discovery), WebRTC (direct P2P)
50
51
  - **Browser-ready** — compiles to WebAssembly via `wasm-pack`; same engine, same wire protocol, IndexedDB persistence
51
52
 
@@ -55,7 +56,7 @@ BEAM is a maintained fork of [rod](https://github.com/mmalmi/rod) — a from-scr
55
56
 
56
57
  ```toml
57
58
  [dependencies]
58
- beamdb = "0.8"
59
+ beamdb = "0.16"
59
60
  ```
60
61
 
61
62
  Or via the CLI:
@@ -64,14 +65,17 @@ Or via the CLI:
64
65
  cargo add beamdb
65
66
  ```
66
67
 
67
- Feature flags (both off by default):
68
+ Feature flags (all off by default):
68
69
 
69
70
  ```toml
70
71
  # WebRTC direct P2P support
71
- beamdb = { version = "0.8", features = ["webrtc"] }
72
+ beamdb = { version = "0.16", features = ["webrtc"] }
73
+
74
+ # Fjall LSM-tree storage backend (recommended for multi-node deployments)
75
+ beamdb = { version = "0.16", features = ["fjall"] }
72
76
 
73
77
  # Persy storage backend (for high-concurrency workloads)
74
- beamdb = { version = "0.8", features = ["persy"] }
78
+ beamdb = { version = "0.16", features = ["persy"] }
75
79
  ```
76
80
 
77
81
  ---
@@ -111,7 +115,8 @@ await init();
111
115
 
112
116
  // Create a BEAM node
113
117
  const beam = new Beam(); // in-memory (lost on reload)
114
- // or: const beam = Beam.new_persistent(); // IndexedDB (survives reload)
118
+ // or: const beam = Beam.new_persistent(); // IndexedDB (survives reload)
119
+ // or: const beam = Beam.new_with_opfs(); // OPFS (survives reload, faster)
115
120
 
116
121
  // Connect to a relay server
117
122
  beam.connect("wss://relay.example.com/ws");
@@ -222,17 +227,26 @@ compatible WebSocket peer.
222
227
  | Backend | Persistent | Browser API | Use Case |
223
228
  |---------|-----------|-------------|----------|
224
229
  | `MemoryStorage` | No (lost on reload) | Default | Ephemeral data, testing |
225
- | `WasmIdbStorage` | Yes (IndexedDB) | Opt-in | Production browser apps |
230
+ | `WasmIdbStorage` | Yes (IndexedDB) | Opt-in via `new_persistent()` | Production browser apps |
231
+ | `WasmOpfsStorage` | Yes (OPFS) | Opt-in via `new_with_opfs()` | Modern browsers (Chrome 102+, Firefox 111+, Safari 15.2+) |
232
+ | `WasmNodeFsStorage` | Yes (Node.js fs) | Node.js only (`--features node-fs`) | Server-side WASM, Electron |
226
233
 
227
- `WasmIdbStorage` uses a write-through cache: writes go to an in-memory `HashMap`
234
+ **WasmIdbStorage** uses a write-through cache: writes go to an in-memory `HashMap`
228
235
  (fast reads) and are simultaneously written through to IndexedDB (persistence).
236
+ Data is serialized as postcard bytes (base64-encoded for IDB string storage),
237
+ with automatic JSON fallback for backward compatibility with pre-v0.17 databases.
229
238
  On page reload, data is read back from IndexedDB into the cache.
230
239
 
240
+ **WasmOpfsStorage** uses the Origin Private File System for file-based persistence.
241
+ Data is stored as postcard-serialized binary files in OPFS, offering better
242
+ performance than IndexedDB for larger datasets. Requires a secure context
243
+ (HTTPS or localhost).
244
+
231
245
  ### Browser Constraints
232
246
 
233
247
  - **Single-threaded** — all async work runs on the browser's main thread
234
248
  - **Client-only** — connects to relays, does not accept inbound connections
235
- - **No file system** — uses IndexedDB instead of redb/Persy
249
+ - **No native file system** — uses IndexedDB or OPFS instead of redb/Persy
236
250
  - **WebSocket only** — no UDP multicast or WebRTC (browser sandbox limitations)
237
251
 
238
252
  ### Interop with Gun.js
@@ -381,7 +395,8 @@ BEAM is built on an actor model with a central router. Every component — stora
381
395
  │ │ │ │ │ │
382
396
  │ MemoryStorage│ │ WsServer │ │ WebRtcPeer │
383
397
  │ RedbStorage │ │ WsClient │ │ (str0m) │
384
- PersyStorage│ │ Multicast │ │ │
398
+ FjallStorage│ │ Multicast │ │ │
399
+ │ PersyStorage│ │ │ │ │
385
400
  └─────────────┘ └─────────────┘ └────────────┘
386
401
  ```
387
402
 
@@ -411,6 +426,7 @@ BEAM is built on an actor model with a central router. Every component — stora
411
426
  | `sea/session/` | Session persistence: `MemorySessionStorage` (ephemeral) and `EncryptedFileSessionStorage` (disk, AES-GCM) |
412
427
  | `adapters/memory_storage.rs` | In-memory `HashMap` storage (ephemeral, default for `Node::new()`) |
413
428
  | `adapters/redb_storage.rs` | Persistent storage via `redb` embedded database — `BatchPut` atomic transactions, flush ack |
429
+ | `adapters/fjall_storage.rs` | Persistent storage via `fjall` LSM-tree database — WAL journalling, LZ4 compression, recommended for multi-node (feature-gated) |
414
430
  | `adapters/persy_storage.rs` | Persistent storage via `Persy` segment store — high-concurrency writes, optional `background_ops` |
415
431
  | `adapters/ws_server.rs` | WebSocket server: accepts inbound connections, spawns `WsConn` per connection, optional TLS, web UI on port+1 |
416
432
  | `adapters/ws_client.rs` | `OutgoingWebsocketManager` — connects to remote WebSocket peers with retry |
@@ -628,11 +644,11 @@ When `allow_public_space=false`, the node rejects unsigned puts to public space
628
644
 
629
645
  ## Storage Backends
630
646
 
631
- BEAM supports two persistent storage backends for the embedded database layer. Both implement the same `Storage` trait, so the rest of the codebase is unaware of which one is active. The wire protocol is backend-agnostic — nodes with different storage choices converge via the standard mesh.
647
+ BEAM supports three persistent storage backends for the embedded database layer. All implement the same `Actor` trait, so the rest of the codebase is unaware of which one is active. The wire protocol is backend-agnostic — nodes with different storage choices converge via the standard mesh.
632
648
 
633
649
  ### redb (Default)
634
650
 
635
- **What**: Embedded ACID database, single-writer, fsync on every Put.
651
+ **What**: Embedded ACID B+tree database, single-writer, fsync on every Put.
636
652
 
637
653
  **When to use**:
638
654
  - Single-node deployments
@@ -642,16 +658,45 @@ BEAM supports two persistent storage backends for the embedded database layer. B
642
658
 
643
659
  **Trade-offs**:
644
660
  - ✅ Battle-tested, single-crate, well-understood
645
- - ✅ fsync before ack = bulletproof durability
661
+ - ✅ fsync before ack = bulletproof durability — data survives power loss
662
+ - ✅ Best read performance (mmap'd B+tree = direct memory access)
646
663
  - ❌ Single-writer serialization limits concurrent write throughput
647
664
  - ❌ Not ideal for high-fanout mesh workloads
665
+ - ❌ Every Put = fsync (milliseconds, blocking)
666
+
667
+ ### fjall (Recommended for Multi-Node)
668
+
669
+ **What**: Embedded LSM-tree (RocksDB-like) database in 100% safe Rust. WAL journalling with background compaction and built-in LZ4 compression.
670
+
671
+ **When to use**:
672
+ - Multi-node P2P deployments with high write fanout
673
+ - Workloads where peers flood puts during resync
674
+ - You want maximum write throughput
675
+
676
+ **Trade-offs**:
677
+ - ✅ 3–4× faster writes than redb (journal append vs fsync per write)
678
+ - ✅ Built-in LZ4 compression (free, SSTable-level)
679
+ - ✅ WriteBatch — single journal entry for atomic multi-put
680
+ - ✅ 100% safe Rust, no unsafe blocks
681
+ - ❌ ~1.4× slower random reads than redb (multi-level LSM lookup vs B+tree)
682
+ - ❌ Not fsync'd per write — data is crash-safe (WAL) but a power loss may lose recent un-fsync'd writes
683
+ - ❌ Background compaction causes read latency variance
684
+
685
+ **Durability model**: fjall's default matches RocksDB — writes are crash-safe via WAL (survive process crash), but not fsync'd to disk until explicit `persist()`. For a P2P database where peers hold copies of the data, this is the correct trade-off: if one node loses its WAL on power failure, peers resync it. `Flush` triggers `persist(SyncAll)` for full durability.
686
+
687
+ **Benchmarks** (see [`bench/RESULTS.md`](bench/RESULTS.md)):
688
+
689
+ | Benchmark | redb | fjall |
690
+ |---|---|---|
691
+ | write_storm (sequential) | ~977 elem/s | ~3,000 elem/s |
692
+ | concurrent_write_storm (4 tasks) | ~1,195 elem/s | ~4,836 elem/s |
693
+ | read_storm (random) | ~610 elem/s | ~447 elem/s |
648
694
 
649
695
  ### Persy (Opt-In)
650
696
 
651
697
  **What**: Embedded segment-based store with per-transaction isolation and optional `background_ops` fsync offloading.
652
698
 
653
699
  **When to use**:
654
- - Multi-node meshes with high concurrent write fanout
655
700
  - Workloads where many writers hit disjoint keys simultaneously
656
701
  - You're benchmarking and Persy shows wins on your data
657
702
 
@@ -659,41 +704,82 @@ BEAM supports two persistent storage backends for the embedded database layer. B
659
704
  - ✅ Multiple writers proceed in parallel on disjoint keys
660
705
  - ✅ Optional `background_ops` for fsync offloading
661
706
  - ❌ Younger ecosystem, fewer Stack Overflow answers
662
- - ❌ Requires more careful substrate reading when debugging
707
+ - ❌ Author has acknowledged crash-safety issues; development has slowed
708
+ - ❌ No WASM path (native-only)
663
709
  - ❌ Performance characteristics need your own benchmarks
664
710
 
711
+ ### Comparison Summary
712
+
713
+ | | redb | fjall | Persy |
714
+ |---|---|---|---|
715
+ | **Structure** | B+tree | LSM-tree | Segment store |
716
+ | **Write path** | fsync per Put | WAL journal append | Per-tx isolation |
717
+ | **Durability** | Bulletproof (fsync) | Crash-safe (WAL), not power-safe | Per-tx |
718
+ | **Read speed** | Fastest (mmap) | Slower (multi-level) | Moderate |
719
+ | **Write speed** | Slowest (fsync) | Fastest (journal) | Moderate |
720
+ | **Concurrency** | Single-writer | Multi-writer | Multi-writer |
721
+ | **Compression** | None | LZ4 (free) | None |
722
+ | **WASM** | No | No | No |
723
+ | **Maturity** | Most mature | Active dev | Slowing dev |
724
+ | **Best for** | Single-node | Multi-node P2P | High-concurrency |
725
+
665
726
  ### Selection
666
727
 
667
- Persy is a **build-time** feature, not a runtime flag. Build with `--features persy` to enable Persy support in the migration subcommand:
728
+ Storage backends are **build-time** features, not runtime flags:
668
729
 
669
730
  ```bash
670
731
  # Default build — redb only
671
732
  cargo build --release --bin beam
672
733
 
673
- # With Persy support (enables migration subcommand)
734
+ # With fjall support
735
+ cargo build --release --bin beam --features fjall
736
+
737
+ # With Persy and/or fjall support (enables migration subcommand)
674
738
  cargo build --release --bin beam --features persy
739
+ cargo build --release --bin beam --features fjall
675
740
 
676
741
  # Run with redb (default)
677
- cargo run --release --bin beam -- --port 4944
742
+ cargo run --release --bin beam -- start --port 4944
678
743
 
679
744
  # In-memory only (no persistence)
680
- cargo run --release --bin beam -- --port 4944 --memory-storage true
745
+ cargo run --release --bin beam -- start --port 4944 --memory-storage true
746
+ ```
747
+
748
+ **Library usage** — use any backend programmatically (requires corresponding feature flags):
749
+
750
+ ```rust,ignore
751
+ use beam::adapters::{FjallStorage, RedbStorage, MemoryStorage};
752
+
753
+ // fjall (recommended for multi-node, requires --features fjall)
754
+ let storage = FjallStorage::new_with_config(Config::default(), "beam.fjall");
755
+
756
+ // redb (default, best for single-node)
757
+ let storage = RedbStorage::new_with_config(Config::default(), "beam.redb", None);
758
+
759
+ // in-memory (ephemeral)
760
+ let storage = MemoryStorage::new();
681
761
  ```
682
762
 
683
763
  ### Migration Between Backends
684
764
 
685
- The `beam migrate` subcommand converts between formats (requires `--features persy` for Persy support):
765
+ The `beam migrate` subcommand converts between all supported storage formats (requires `--features persy` and/or `--features fjall`):
686
766
 
687
767
  ```bash
688
768
  # Preview without writing
689
769
  beam migrate --from redb --to persy --source ./data.redb --target ./data.persy --dry-run
690
770
 
691
- # Execute migration
771
+ # redb ↔ persy
692
772
  beam migrate --from redb --to persy --source ./data.redb --target ./data.persy
693
-
694
- # Reverse direction
695
773
  beam migrate --from persy --to redb --source ./data.persy --target ./data.redb
696
774
 
775
+ # redb ↔ fjall (fjall uses a directory path, not a file)
776
+ beam migrate --from redb --to fjall --source ./data.redb --target ./data.fjall
777
+ beam migrate --from fjall --to redb --source ./data.fjall --target ./data.redb
778
+
779
+ # fjall ↔ persy
780
+ beam migrate --from fjall --to persy --source ./data.fjall --target ./data.persy
781
+ beam migrate --from persy --to fjall --source ./data.persy --target ./data.fjall
782
+
697
783
  # Overwrite existing target
698
784
  beam migrate --from redb --to persy --source ./data.redb --target ./data.persy --force
699
785
 
@@ -701,11 +787,11 @@ beam migrate --from redb --to persy --source ./data.redb --target ./data.persy -
701
787
  beam migrate --from redb --to persy --source ./data.redb --target ./data.persy --batch-size 5000
702
788
  ```
703
789
 
704
- Migration uses single-transaction-per-batch for safety and includes checksum verification. See `docs/migrations/migration-guide.md` for the full procedure including rollback.
790
+ Migration uses a reader/writer architecture with a canonical intermediate format — each backend has one reader and one writer. Adding a new storage backend requires only two functions, not N² pairwise paths. See `docs/migrations/migration-guide.md` for the full procedure including rollback.
705
791
 
706
792
  ### Mixed Meshes
707
793
 
708
- Nodes with different storage backends interoperate transparently. A redb node, a Persy node, and an in-memory node form a valid mesh. The wire protocol carries the data; storage is a local choice.
794
+ Nodes with different storage backends interoperate transparently. A redb node, a fjall node, a Persy node, and an in-memory node form a valid mesh. The wire protocol carries the data; storage is a local choice.
709
795
 
710
796
  **Cross-backend mesh verified** by `tests/cross_backend_mesh_e2e.rs`: 2 redb nodes + 1 Persy node converge correctly under the standard Put/Get protocol.
711
797
 
@@ -713,6 +799,7 @@ Nodes with different storage backends interoperate transparently. A redb node, a
713
799
 
714
800
  - The `beam_meta_v1` metadata table from redb (last-write timestamps) is not preserved when migrating redb → Persy. This metadata is not currently used by the actor framework, so the loss is cosmetic.
715
801
  - The migration tool is single-threaded per batch. For datasets larger than ~100k records, run during a maintenance window.
802
+ - fjall uses a directory path for storage (LSM-tree), while redb and Persy use single files. Migration involving fjall creates a directory at the target path.
716
803
 
717
804
  ---
718
805
 
@@ -785,10 +872,10 @@ BEAM uses Gun.js's JSON wire format. Messages are JSON objects with these fields
785
872
 
786
873
  | Flag | Required | Description |
787
874
  |------|----------|-------------|
788
- | `--from` | Yes | Source backend: `redb` or `persy` |
789
- | `--to` | Yes | Target backend: `redb` or `persy` |
790
- | `--source` | Yes | Path to source database file |
791
- | `--target` | Yes | Path to target database file (will be created) |
875
+ | `--from` | Yes | Source backend: `redb`, `persy`, or `fjall` |
876
+ | `--to` | Yes | Target backend: `redb`, `persy`, or `fjall` |
877
+ | `--source` | Yes | Path to source database (file for redb/persy, directory for fjall) |
878
+ | `--target` | Yes | Path to target database (file for redb/persy, directory for fjall) |
792
879
  | `--batch-size` | No | Records per batch (default: 1000) |
793
880
  | `--force` | No | Overwrite target if it already exists |
794
881
  | `--dry-run` | No | Preview without writing |
@@ -803,6 +890,9 @@ let config = Config {
803
890
  my_pub: Some("x.y".into()),
804
891
  broadcast_buffer_size: 4096,
805
892
  ice_servers: vec!["stun:stun.l.google.com:19302".into()],
893
+ dedup_capacity: 100_000,
894
+ mailbox_capacity: 65536,
895
+ child_mailbox_capacity: 256,
806
896
  };
807
897
  # }
808
898
  ```
@@ -870,13 +960,24 @@ cargo test
870
960
  # With WebRTC tests
871
961
  cargo test --features webrtc
872
962
 
963
+ # With fjall storage tests
964
+ cargo test --features fjall
965
+
966
+ # With Persy tests (includes redb↔persy migration tests)
967
+ cargo test --features persy
968
+
969
+ # With fjall tests (includes redb↔fjall migration tests)
970
+ cargo test --features fjall
971
+
972
+ # With both (includes all migration path tests)
973
+ cargo test --features fjall,persy
974
+
873
975
  # Lint (zero warnings required)
874
976
  cargo clippy -- -D warnings
875
977
 
876
978
  # Doctests only (verifies README code examples compile)
877
979
  cargo test --doc
878
980
 
879
- # Benchmarks
880
981
  cargo bench
881
982
 
882
983
  # Run a specific integration test
@@ -902,21 +1003,137 @@ cargo test --test wire_live -- --ignored # Layer 3: live integration (needs Nod
902
1003
  | `redb_storage_persists` | Data survives restart with redb storage |
903
1004
  | `redb_storage_flush_returns_ok` | Flush ack protocol |
904
1005
  | `cross_backend_mesh_e2e` | 2 redb + 1 Persy nodes converge correctly |
1006
+ | `fjall_e2e` | 6 fjall storage tests: put-get, sequential, nested, LWW, flush, isolation (`--features fjall`) |
905
1007
  | `wire_tests` | 36 golden JSON fixtures — wire protocol spec as tests |
906
1008
  | `wire_live` | Live BEAM ↔ Gun.js bidirectional sync (4 scenarios) |
907
1009
 
908
1010
  ---
909
1011
 
1012
+ ## Benchmarks
1013
+
1014
+ BEAM includes a comprehensive benchmarking suite covering relay throughput,
1015
+ micro-benchmarks for hot-path components, and storage performance.
1016
+
1017
+ ### Local Put Throughput
1018
+
1019
+ Local (non-relay) puts through the actor pipeline — measures the full
1020
+ `Node::handle → Router::route → MemoryStorage::apply` path with no network I/O:
1021
+
1022
+ | Scenario | Messages | Throughput |
1023
+ |----------|----------|------------|
1024
+ | 1 sender × 10k | 10,000 | ~24,000–53,000 puts/sec |
1025
+
1026
+ Throughput varies with system load. On a dedicated machine with no
1027
+ competing processes, expect 50,000+ puts/sec. The bottleneck is
1028
+ `Value` cloning for broadcast channels — further gains require
1029
+ `Arc<Value>` to make cloning a refcount bump.
1030
+
1031
+ ### Relay Throughput
1032
+
1033
+ Real WebSocket connections through a memory-only relay (no disk I/O):
1034
+
1035
+ | Scenario | Messages | Throughput |
1036
+ |----------|----------|------------|
1037
+ | 1 sender × 10k | 10,000 | ~5,300 msgs/sec |
1038
+ | 1 sender × 50k | 50,000 | ~10,600 msgs/sec |
1039
+ | 10 senders × 5k | 50,000 | ~11,400 msgs/sec |
1040
+
1041
+ The relay's internal processing (parse + dedup + route + serialize) runs in
1042
+ microseconds — the bottleneck is client-side `put().await`, not the relay.
1043
+
1044
+ ### Micro-Benchmarks (Criterion)
1045
+
1046
+ | Operation | Time |
1047
+ |-----------|------|
1048
+ | Parse small Put JSON | 1,067 ns |
1049
+ | Parse medium Put JSON | 2.00 µs |
1050
+ | Serialize small Put JSON | 69 ns |
1051
+ | Parse Get | 677 ns |
1052
+ | Dedup check (fresh) | 274 µs |
1053
+ | Dedup check (duplicate) | 41.8 µs |
1054
+ | Actor mailbox send+recv | 309 µs |
1055
+
1056
+ ### WASM Benchmarks (Node.js)
1057
+
1058
+ | Operation | WASM | Native | Ratio |
1059
+ |-----------|------|--------|-------|
1060
+ | Parse small Put | 7.9 µs | 1,067 ns | ~7.4× |
1061
+ | Serialize small Put | 8.6 µs | 69 ns | ~125× |
1062
+ | Parse Get | 4.5 µs | 677 ns | ~6.7× |
1063
+
1064
+ Run with: `wasm-pack test --node --no-default-features -- --nocapture`
1065
+
1066
+ ### WASM Relay Throughput (Browser-only)
1067
+
1068
+ `web_sys::WebSocket` callbacks don't fire in Node.js `wasm-bindgen-test-runner`
1069
+ ([known limitation](https://github.com/wasm-bindgen/wasm-bindgen/issues/4921)).
1070
+ Use the browser benchmark page to measure WASM relay throughput:
1071
+
1072
+ ```bash
1073
+ cargo run -- start --port 4944 --memory-storage true --redb-storage false
1074
+ python3 -m http.server 8080 -d examples/
1075
+ # Open http://localhost:8080/bench.html in a browser
1076
+ ```
1077
+
1078
+ Previous v0.11.0 browser results: ~115–651 msgs/sec depending on batch size.
1079
+
1080
+ ### Browser Benchmark
1081
+
1082
+ An interactive benchmark page is available at `examples/bench.html`:
1083
+
1084
+ ```bash
1085
+ # Start a relay
1086
+ cargo run -- start --port 4944 --memory-storage true --redb-storage false
1087
+
1088
+ # Serve the benchmark page
1089
+ python3 -m http.server 8080 -d examples/
1090
+
1091
+ # Open in browser
1092
+ open http://localhost:8080/bench.html
1093
+ ```
1094
+
1095
+ The browser benchmark measures:
1096
+ - **Relay TPS**: end-to-end throughput through a real relay
1097
+ - **Put throughput**: local WASM API fire-and-forget puts
1098
+ - **Get throughput**: local WASM API promise resolution
1099
+ - **Put→Get round-trip**: full local cycle
1100
+
1101
+ ### Running Benchmarks
1102
+
1103
+ ```bash
1104
+ # Relay throughput (release mode required)
1105
+ cargo test --release --test relay_throughput_bench -- --ignored --nocapture
1106
+
1107
+ # Micro-benchmarks (hot-path components)
1108
+ cargo bench --bench my_benchmark -- "wire_|dup_check|actor_mailbox"
1109
+
1110
+ # Storage benchmarks (redb only by default)
1111
+ cargo bench --bench my_benchmark -- "write_storm|read_storm|mixed"
1112
+
1113
+ # Storage benchmarks with fjall (head-to-head comparison)
1114
+ cargo bench --features fjall --bench my_benchmark -- "write_storm|read_storm|mixed"
1115
+
1116
+ # Storage benchmarks with persy
1117
+ cargo bench --features persy --bench my_benchmark -- "write_storm|read_storm|mixed"
1118
+
1119
+ # Live metrics endpoint (while relay is running)
1120
+ curl http://localhost:8080/metrics
1121
+ ```
1122
+
1123
+ See [`benches/RESULTS.md`](benches/RESULTS.md) for full results with
1124
+ methodology and analysis.
1125
+
910
1126
  ## Features
911
1127
 
912
1128
  | Feature | Default | Enables |
913
1129
  |---------|---------|---------|
914
1130
  | `webrtc` | No | `dep:str0m`, `dep:stun` — direct P2P connections via WebRTC data channels |
1131
+ | `fjall` | No | `dep:fjall` — LSM-tree storage backend (recommended for multi-node deployments) |
915
1132
  | `persy` | No | `dep:persy` — Persy storage backend for high-concurrency workloads |
916
1133
 
917
- Without `webrtc`, the `stun` module and `WebRtcPeer` adapter are stubbed out (functions return `None`). Without `persy`, the `PersyStorage` adapter is not compiled in and migration to/from Persy is unavailable.
1134
+ Without `webrtc`, the `stun` module and `WebRtcPeer` adapter are stubbed out (functions return `None`). Without `persy`, the `PersyStorage` adapter is not compiled in and migration to/from Persy is unavailable. Without `fjall`, the `FjallStorage` adapter is not compiled in and migration to/from fjall is unavailable. Migration requires at least one of `persy` or `fjall` features.
918
1135
 
919
- **WASM**: When targeting `wasm32-unknown-unknown`, native-only modules (redb, Persy, tokio-tungstenite, multicast) are cfg-gated out. Browser adapters (`wasm_ws`, `wasm_idb`) are compiled in. Timer functions (`sleep`, `timeout`, `interval`) are provided by `tokio_with_wasm` via the `tokio_time` shim module instead of tokio's `time` feature (which panics on WASM). The `wasm.rs` module provides `#[wasm_bindgen]` JavaScript bindings. Build with `wasm-pack build --target web --release`.
1136
+ **WASM**: When targeting `wasm32-unknown-unknown`, native-only modules (redb, fjall, Persy, tokio-tungstenite, multicast) are cfg-gated out. Browser adapters (`wasm_ws`, `wasm_idb`) are compiled in. Timer functions (`sleep`, `timeout`, `interval`) are provided by `tokio_with_wasm` via the `tokio_time` shim module instead of tokio's `time` feature (which panics on WASM). The `wasm.rs` module provides `#[wasm_bindgen]` JavaScript bindings. Build with `wasm-pack build --target web --release`.
920
1137
 
921
1138
  ---
922
1139
 
package/beam.d.ts CHANGED
@@ -49,6 +49,35 @@ export class Beam {
49
49
  * then flushed automatically.
50
50
  */
51
51
  static new_persistent(): Beam;
52
+ /**
53
+ * Creates a new BEAM node with OPFS (Origin Private File System) persistent storage.
54
+ *
55
+ * Data is stored as postcard-serialized files in the browser's OPFS
56
+ * and survives page reloads and browser restarts. Requires a secure
57
+ * context (HTTPS or localhost). OPFS is available in all modern browsers
58
+ * (Chrome 102+, Firefox 111+, Safari 15.2+).
59
+ *
60
+ * The OPFS directory opens asynchronously — writes are buffered until
61
+ * the directory is ready, then flushed automatically.
62
+ *
63
+ * ```js
64
+ * import init, { Beam } from "./beam.js";
65
+ * await init();
66
+ * const beam = Beam.new_with_opfs();
67
+ * beam.connect("ws://relay.example.com");
68
+ * beam.put("chat.001", "hello");
69
+ * // Reload page — data is still there.
70
+ * ```
71
+ */
72
+ static new_with_opfs(): Beam;
73
+ /**
74
+ * Creates a new BEAM node with OPFS storage at a custom directory name.
75
+ *
76
+ * ```js
77
+ * const beam = Beam.new_with_opfs_name("myapp_data");
78
+ * ```
79
+ */
80
+ static new_with_opfs_name(name: string): Beam;
52
81
  /**
53
82
  * Subscribes to child updates at the given path.
54
83
  *
@@ -108,6 +137,8 @@ export interface InitOutput {
108
137
  readonly beam_get: (a: number, b: number, c: number) => number;
109
138
  readonly beam_new: () => number;
110
139
  readonly beam_new_persistent: () => number;
140
+ readonly beam_new_with_opfs: () => number;
141
+ readonly beam_new_with_opfs_name: (a: number, b: number) => number;
111
142
  readonly beam_on: (a: number, b: number, c: number, d: number) => void;
112
143
  readonly beam_put: (a: number, b: number, c: number, d: number, e: number) => void;
113
144
  readonly beam_put_bool: (a: number, b: number, c: number, d: number) => void;
@@ -115,11 +146,11 @@ export interface InitOutput {
115
146
  readonly beam_put_num: (a: number, b: number, c: number, d: number) => void;
116
147
  readonly beam_stop: (a: number) => void;
117
148
  readonly task_worker_entry_point: (a: number, b: number) => void;
118
- readonly __wasm_bindgen_func_elem_1101: (a: number, b: number, c: number, d: number) => void;
119
- readonly __wasm_bindgen_func_elem_1115: (a: number, b: number, c: number, d: number) => void;
120
- readonly __wasm_bindgen_func_elem_234: (a: number, b: number, c: number) => void;
121
- readonly __wasm_bindgen_func_elem_234_2: (a: number, b: number, c: number) => void;
122
- readonly __wasm_bindgen_func_elem_234_3: (a: number, b: number, c: number) => void;
149
+ readonly __wasm_bindgen_func_elem_1218: (a: number, b: number, c: number, d: number) => void;
150
+ readonly __wasm_bindgen_func_elem_1232: (a: number, b: number, c: number, d: number) => void;
151
+ readonly __wasm_bindgen_func_elem_252: (a: number, b: number, c: number) => void;
152
+ readonly __wasm_bindgen_func_elem_252_2: (a: number, b: number, c: number) => void;
153
+ readonly __wasm_bindgen_func_elem_252_3: (a: number, b: number, c: number) => void;
123
154
  readonly __wbindgen_export: (a: number, b: number) => number;
124
155
  readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
125
156
  readonly __wbindgen_export3: (a: number) => void;
package/beam.js CHANGED
@@ -83,6 +83,46 @@ export class Beam {
83
83
  const ret = wasm.beam_new_persistent();
84
84
  return Beam.__wrap(ret);
85
85
  }
86
+ /**
87
+ * Creates a new BEAM node with OPFS (Origin Private File System) persistent storage.
88
+ *
89
+ * Data is stored as postcard-serialized files in the browser's OPFS
90
+ * and survives page reloads and browser restarts. Requires a secure
91
+ * context (HTTPS or localhost). OPFS is available in all modern browsers
92
+ * (Chrome 102+, Firefox 111+, Safari 15.2+).
93
+ *
94
+ * The OPFS directory opens asynchronously — writes are buffered until
95
+ * the directory is ready, then flushed automatically.
96
+ *
97
+ * ```js
98
+ * import init, { Beam } from "./beam.js";
99
+ * await init();
100
+ * const beam = Beam.new_with_opfs();
101
+ * beam.connect("ws://relay.example.com");
102
+ * beam.put("chat.001", "hello");
103
+ * // Reload page — data is still there.
104
+ * ```
105
+ * @returns {Beam}
106
+ */
107
+ static new_with_opfs() {
108
+ const ret = wasm.beam_new_with_opfs();
109
+ return Beam.__wrap(ret);
110
+ }
111
+ /**
112
+ * Creates a new BEAM node with OPFS storage at a custom directory name.
113
+ *
114
+ * ```js
115
+ * const beam = Beam.new_with_opfs_name("myapp_data");
116
+ * ```
117
+ * @param {string} name
118
+ * @returns {Beam}
119
+ */
120
+ static new_with_opfs_name(name) {
121
+ const ptr0 = passStringToWasm0(name, wasm.__wbindgen_export, wasm.__wbindgen_export2);
122
+ const len0 = WASM_VECTOR_LEN;
123
+ const ret = wasm.beam_new_with_opfs_name(ptr0, len0);
124
+ return Beam.__wrap(ret);
125
+ }
86
126
  /**
87
127
  * Subscribes to child updates at the given path.
88
128
  *
@@ -215,6 +255,10 @@ function __wbg_get_imports() {
215
255
  __wbg__wbg_cb_unref_be22cc64ae6946a0: function(arg0) {
216
256
  getObject(arg0)._wbg_cb_unref();
217
257
  },
258
+ __wbg_arrayBuffer_393639d72165e90f: function(arg0) {
259
+ const ret = getObject(arg0).arrayBuffer();
260
+ return addHeapObject(ret);
261
+ },
218
262
  __wbg_call_35dba3c747ad7521: function() { return handleError(function (arg0, arg1, arg2) {
219
263
  const ret = getObject(arg0).call(getObject(arg1), getObject(arg2));
220
264
  return addHeapObject(ret);
@@ -222,10 +266,18 @@ function __wbg_get_imports() {
222
266
  __wbg_close_b857478a8d4c1a16: function() { return handleError(function (arg0) {
223
267
  getObject(arg0).close();
224
268
  }, arguments); },
269
+ __wbg_close_bff888804a3b665e: function(arg0) {
270
+ const ret = getObject(arg0).close();
271
+ return addHeapObject(ret);
272
+ },
225
273
  __wbg_createObjectStore_d3884936b845900f: function() { return handleError(function (arg0, arg1, arg2) {
226
274
  const ret = getObject(arg0).createObjectStore(getStringFromWasm0(arg1, arg2));
227
275
  return addHeapObject(ret);
228
276
  }, arguments); },
277
+ __wbg_createWritable_376a22cc9862fab6: function(arg0) {
278
+ const ret = getObject(arg0).createWritable();
279
+ return addHeapObject(ret);
280
+ },
229
281
  __wbg_data_57d8ce4eb5f0a433: function(arg0) {
230
282
  const ret = getObject(arg0).data;
231
283
  return addHeapObject(ret);
@@ -247,6 +299,26 @@ function __wbg_get_imports() {
247
299
  __wbg_error_dd408a7b3cb542dd: function(arg0) {
248
300
  console.error(getObject(arg0));
249
301
  },
302
+ __wbg_getDirectoryHandle_3b6e6ec9cd618b74: function(arg0, arg1, arg2, arg3) {
303
+ const ret = getObject(arg0).getDirectoryHandle(getStringFromWasm0(arg1, arg2), getObject(arg3));
304
+ return addHeapObject(ret);
305
+ },
306
+ __wbg_getDirectory_2cc7169b6007ef84: function(arg0) {
307
+ const ret = getObject(arg0).getDirectory();
308
+ return addHeapObject(ret);
309
+ },
310
+ __wbg_getFileHandle_275e470e839818da: function(arg0, arg1, arg2) {
311
+ const ret = getObject(arg0).getFileHandle(getStringFromWasm0(arg1, arg2));
312
+ return addHeapObject(ret);
313
+ },
314
+ __wbg_getFileHandle_ba98be7045eab758: function(arg0, arg1, arg2, arg3) {
315
+ const ret = getObject(arg0).getFileHandle(getStringFromWasm0(arg1, arg2), getObject(arg3));
316
+ return addHeapObject(ret);
317
+ },
318
+ __wbg_getFile_f042da3b150a3230: function(arg0) {
319
+ const ret = getObject(arg0).getFile();
320
+ return addHeapObject(ret);
321
+ },
250
322
  __wbg_getRandomValues_a608c4436c19407a: function() { return handleError(function (arg0, arg1) {
251
323
  globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
252
324
  }, arguments); },
@@ -272,9 +344,17 @@ function __wbg_get_imports() {
272
344
  const ret = result;
273
345
  return ret;
274
346
  },
347
+ __wbg_length_36bd29c6848c2144: function(arg0) {
348
+ const ret = getObject(arg0).length;
349
+ return ret;
350
+ },
275
351
  __wbg_log_e6372b4fbfc9f81e: function(arg0) {
276
352
  console.log(getObject(arg0));
277
353
  },
354
+ __wbg_navigator_6cfdd5fa246d910f: function(arg0) {
355
+ const ret = getObject(arg0).navigator;
356
+ return addHeapObject(ret);
357
+ },
278
358
  __wbg_new_20a7c62e9b30cbf7: function() { return handleError(function (arg0, arg1) {
279
359
  const ret = new WebSocket(getStringFromWasm0(arg0, arg1));
280
360
  return addHeapObject(ret);
@@ -290,7 +370,7 @@ function __wbg_get_imports() {
290
370
  const a = state0.a;
291
371
  state0.a = 0;
292
372
  try {
293
- return __wasm_bindgen_func_elem_1115(a, state0.b, arg0, arg1);
373
+ return __wasm_bindgen_func_elem_1232(a, state0.b, arg0, arg1);
294
374
  } finally {
295
375
  state0.a = a;
296
376
  }
@@ -301,6 +381,14 @@ function __wbg_get_imports() {
301
381
  state0.a = 0;
302
382
  }
303
383
  },
384
+ __wbg_new_77cc4f4f472aeb81: function(arg0) {
385
+ const ret = new Uint8Array(getObject(arg0));
386
+ return addHeapObject(ret);
387
+ },
388
+ __wbg_new_ebe3e0f6837f0879: function() {
389
+ const ret = new Object();
390
+ return addHeapObject(ret);
391
+ },
304
392
  __wbg_new_typed_cceaf62d8d95e9f2: function(arg0, arg1) {
305
393
  try {
306
394
  var state0 = {a: arg0, b: arg1};
@@ -308,7 +396,7 @@ function __wbg_get_imports() {
308
396
  const a = state0.a;
309
397
  state0.a = 0;
310
398
  try {
311
- return __wasm_bindgen_func_elem_1115(a, state0.b, arg0, arg1);
399
+ return __wasm_bindgen_func_elem_1232(a, state0.b, arg0, arg1);
312
400
  } finally {
313
401
  state0.a = a;
314
402
  }
@@ -342,6 +430,9 @@ function __wbg_get_imports() {
342
430
  __wbg_postMessage_6dcc1574fef77104: function() { return handleError(function (arg0, arg1) {
343
431
  getObject(arg0).postMessage(getObject(arg1));
344
432
  }, arguments); },
433
+ __wbg_prototypesetcall_de8e0d9553586985: function(arg0, arg1, arg2) {
434
+ Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), getObject(arg2));
435
+ },
345
436
  __wbg_put_5e0ae8c80bb952a7: function() { return handleError(function (arg0, arg1, arg2) {
346
437
  const ret = getObject(arg0).put(getObject(arg1), getObject(arg2));
347
438
  return addHeapObject(ret);
@@ -353,10 +444,6 @@ function __wbg_get_imports() {
353
444
  const ret = getObject(arg0).queueMicrotask;
354
445
  return addHeapObject(ret);
355
446
  },
356
- __wbg_readyState_fe79161592fd15ce: function(arg0) {
357
- const ret = getObject(arg0).readyState;
358
- return ret;
359
- },
360
447
  __wbg_resolve_020f95d838c6ef25: function(arg0) {
361
448
  const ret = Promise.resolve(getObject(arg0));
362
449
  return addHeapObject(ret);
@@ -371,6 +458,12 @@ function __wbg_get_imports() {
371
458
  __wbg_setTimeout_593504220b42c5a5: function(arg0, arg1) {
372
459
  globalThis.setTimeout(getObject(arg0), arg1);
373
460
  },
461
+ __wbg_set_create_7df81b728041e33b: function(arg0, arg1) {
462
+ getObject(arg0).create = arg1 !== 0;
463
+ },
464
+ __wbg_set_create_c415df73c1fec0ca: function(arg0, arg1) {
465
+ getObject(arg0).create = arg1 !== 0;
466
+ },
374
467
  __wbg_set_onclose_cb71fea4ad9056fc: function(arg0, arg1) {
375
468
  getObject(arg0).onclose = getObject(arg1);
376
469
  },
@@ -415,6 +508,10 @@ function __wbg_get_imports() {
415
508
  const ret = typeof window === 'undefined' ? null : window;
416
509
  return isLikeNone(ret) ? 0 : addHeapObject(ret);
417
510
  },
511
+ __wbg_storage_9c75a59b053f787c: function(arg0) {
512
+ const ret = getObject(arg0).storage;
513
+ return addHeapObject(ret);
514
+ },
418
515
  __wbg_target_13424fe1cdc436ac: function(arg0) {
419
516
  const ret = getObject(arg0).target;
420
517
  return isLikeNone(ret) ? 0 : addHeapObject(ret);
@@ -431,24 +528,28 @@ function __wbg_get_imports() {
431
528
  const ret = getObject(arg0).transaction(getStringFromWasm0(arg1, arg2), __wbindgen_enum_IdbTransactionMode[arg3]);
432
529
  return addHeapObject(ret);
433
530
  }, arguments); },
531
+ __wbg_write_6c58d60c26aaeef5: function() { return handleError(function (arg0, arg1, arg2) {
532
+ const ret = getObject(arg0).write(getArrayU8FromWasm0(arg1, arg2));
533
+ return addHeapObject(ret);
534
+ }, arguments); },
434
535
  __wbindgen_cast_0000000000000001: function(arg0, arg1) {
435
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 12, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
436
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_234);
536
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 15, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
537
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_252);
437
538
  return addHeapObject(ret);
438
539
  },
439
540
  __wbindgen_cast_0000000000000002: function(arg0, arg1) {
440
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 269, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
441
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1101);
541
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 290, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
542
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_1218);
442
543
  return addHeapObject(ret);
443
544
  },
444
545
  __wbindgen_cast_0000000000000003: function(arg0, arg1) {
445
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 12, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
446
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_234_2);
546
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 15, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
547
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_252_2);
447
548
  return addHeapObject(ret);
448
549
  },
449
550
  __wbindgen_cast_0000000000000004: function(arg0, arg1) {
450
- // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 12, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
451
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_234_3);
551
+ // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 15, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
552
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_252_3);
452
553
  return addHeapObject(ret);
453
554
  },
454
555
  __wbindgen_cast_0000000000000005: function(arg0) {
@@ -475,22 +576,22 @@ function __wbg_get_imports() {
475
576
  };
476
577
  }
477
578
 
478
- function __wasm_bindgen_func_elem_234(arg0, arg1, arg2) {
479
- wasm.__wasm_bindgen_func_elem_234(arg0, arg1, addHeapObject(arg2));
579
+ function __wasm_bindgen_func_elem_252(arg0, arg1, arg2) {
580
+ wasm.__wasm_bindgen_func_elem_252(arg0, arg1, addHeapObject(arg2));
480
581
  }
481
582
 
482
- function __wasm_bindgen_func_elem_234_2(arg0, arg1, arg2) {
483
- wasm.__wasm_bindgen_func_elem_234_2(arg0, arg1, addHeapObject(arg2));
583
+ function __wasm_bindgen_func_elem_252_2(arg0, arg1, arg2) {
584
+ wasm.__wasm_bindgen_func_elem_252_2(arg0, arg1, addHeapObject(arg2));
484
585
  }
485
586
 
486
- function __wasm_bindgen_func_elem_234_3(arg0, arg1, arg2) {
487
- wasm.__wasm_bindgen_func_elem_234_3(arg0, arg1, addHeapObject(arg2));
587
+ function __wasm_bindgen_func_elem_252_3(arg0, arg1, arg2) {
588
+ wasm.__wasm_bindgen_func_elem_252_3(arg0, arg1, addHeapObject(arg2));
488
589
  }
489
590
 
490
- function __wasm_bindgen_func_elem_1101(arg0, arg1, arg2) {
591
+ function __wasm_bindgen_func_elem_1218(arg0, arg1, arg2) {
491
592
  try {
492
593
  const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
493
- wasm.__wasm_bindgen_func_elem_1101(retptr, arg0, arg1, addHeapObject(arg2));
594
+ wasm.__wasm_bindgen_func_elem_1218(retptr, arg0, arg1, addHeapObject(arg2));
494
595
  var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
495
596
  var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
496
597
  if (r1) {
@@ -501,8 +602,8 @@ function __wasm_bindgen_func_elem_1101(arg0, arg1, arg2) {
501
602
  }
502
603
  }
503
604
 
504
- function __wasm_bindgen_func_elem_1115(arg0, arg1, arg2, arg3) {
505
- wasm.__wasm_bindgen_func_elem_1115(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
605
+ function __wasm_bindgen_func_elem_1232(arg0, arg1, arg2, arg3) {
606
+ wasm.__wasm_bindgen_func_elem_1232(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
506
607
  }
507
608
 
508
609
 
package/beam_bg.wasm CHANGED
Binary file
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "David Newman <david.r.newman@proton.me>"
7
7
  ],
8
8
  "description": "BEAM — distributed graph database syncing over WebSocket, WebRTC, and multicast. Successor to rod.",
9
- "version": "0.10.0",
9
+ "version": "0.17.0",
10
10
  "license": "MIT",
11
11
  "repository": {
12
12
  "type": "git",