Skip to content

Commit 6bfca4e

Browse files
committed
updated version + doc fix + CI tests fix
1 parent be9ee9f commit 6bfca4e

17 files changed

Lines changed: 640 additions & 32 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ name = "encrypted_upload_test"
7777
path = "examples/encrypted_upload_test.rs"
7878

7979
[workspace.package]
80-
version = "0.3.7"
80+
version = "0.4.0"
8181
edition = "2021"
8282
license = "MIT OR Apache-2.0"
8383
repository = "https://github.com/functionland/fula-api"

crates/fula-client/src/health_gate.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
3131
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
3232
use std::sync::Arc;
33-
use std::time::{Duration, SystemTime, UNIX_EPOCH};
33+
use std::time::Duration;
3434

3535
/// Phase 19 transparency surface — events the SDK emits when its
3636
/// view of master-server reachability changes. Apps wire a
@@ -218,15 +218,18 @@ pub enum GateDecision {
218218
ShortCircuit { down_for_secs: u64 },
219219
}
220220

221-
/// Current unix-time in milliseconds. Wall-clock based (so SystemTime
222-
/// adjustments can shift the gate's perceived "since" — acceptable here
223-
/// since we only compare durations, and a clock jump is at worst a slight
224-
/// TTL anomaly).
221+
/// Current unix-time in milliseconds. Wall-clock based (so a system-
222+
/// clock adjustment can shift the gate's perceived "since" —
223+
/// acceptable here since we only compare durations, and a clock jump
224+
/// is at worst a slight TTL anomaly).
225+
///
226+
/// Routed through `fula_crypto::time::now_millis` so the wasm32 build
227+
/// uses `js_sys::Date::now()` instead of `SystemTime::now()` (the
228+
/// latter panics on wasm32 with "time not implemented on this
229+
/// platform" — the wasm clippy `disallowed-methods` config catches
230+
/// this at lint time).
225231
fn now_ms() -> u64 {
226-
SystemTime::now()
227-
.duration_since(UNIX_EPOCH)
228-
.map(|d| d.as_millis() as u64)
229-
.unwrap_or(0)
232+
fula_crypto::time::now_millis()
230233
}
231234

232235
#[cfg(test)]

crates/fula-crypto/src/time.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ pub fn now_timestamp() -> i64 {
2121
.as_secs() as i64
2222
}
2323

24+
/// Get current Unix timestamp in milliseconds (WASM-compatible)
25+
///
26+
/// Returns the current time as milliseconds since the Unix epoch.
27+
/// Companion to `now_timestamp` for callers that need millisecond
28+
/// resolution (e.g., the master health gate's TTL bookkeeping where
29+
/// sub-second precision matters across rapid Up↔Down transitions).
30+
/// Works in both native Rust and WASM environments.
31+
#[cfg(target_arch = "wasm32")]
32+
pub fn now_millis() -> u64 {
33+
js_sys::Date::now() as u64
34+
}
35+
36+
/// Get current Unix timestamp in milliseconds (native)
37+
#[cfg(not(target_arch = "wasm32"))]
38+
pub fn now_millis() -> u64 {
39+
std::time::SystemTime::now()
40+
.duration_since(std::time::UNIX_EPOCH)
41+
.map(|d| d.as_millis() as u64)
42+
.unwrap_or(0)
43+
}
44+
2445
#[cfg(test)]
2546
mod tests {
2647
use super::*;
@@ -33,4 +54,26 @@ mod tests {
3354
// Should be before Jan 1, 2100 (timestamp: 4102444800)
3455
assert!(ts < 4102444800, "Timestamp should be before 2100");
3556
}
57+
58+
#[test]
59+
fn test_now_millis_reasonable() {
60+
let ms = now_millis();
61+
// Should be after Jan 1, 2020 in ms (1577836800000)
62+
assert!(ms > 1_577_836_800_000, "ms timestamp should be after 2020");
63+
// Should be before Jan 1, 2100 in ms
64+
assert!(ms < 4_102_444_800_000, "ms timestamp should be before 2100");
65+
}
66+
67+
#[test]
68+
fn test_now_millis_matches_seconds_within_tolerance() {
69+
// Sanity: the millis helper agrees with the seconds helper to
70+
// the second. Catches an accidental scaling bug.
71+
let ms = now_millis();
72+
let s = now_timestamp() as u64;
73+
let derived_s = ms / 1000;
74+
assert!(
75+
derived_s.abs_diff(s) <= 1,
76+
"now_millis()/1000 ({derived_s}) and now_timestamp() ({s}) must agree to within 1s",
77+
);
78+
}
3679
}

crates/fula-flutter/tests/flutter_bridge_tests.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,20 @@ fn test_fula_config_default() {
2525

2626
#[test]
2727
fn test_fula_config_with_values() {
28+
// Construct via `..Default::default()` so adding new fields to
29+
// `FulaConfig` (e.g., Phase 2.x / 3.3 / 19) doesn't require
30+
// updating this test. The pre-Phase-2.x fields below are the
31+
// ones this test specifically exercises; everything else inherits
32+
// from `Default::default()` which is the documented backward-
33+
// compat shape (all new flags off / empty).
2834
let config = FulaConfig {
2935
endpoint: "https://api.example.com".to_string(),
3036
access_token: Some("test-token".to_string()),
3137
timeout_seconds: 60,
3238
max_retries: 5,
3339
per_chunk_download_timeout_seconds: 120,
3440
buffered_download_max_bytes: 64 * 1024 * 1024,
41+
..Default::default()
3542
};
3643
assert_eq!(config.endpoint, "https://api.example.com");
3744
assert_eq!(config.access_token, Some("test-token".to_string()));

docs/flutter-integration.md

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,46 @@ application/wasm
104104
### Configuration Types
105105

106106
#### FulaConfig
107+
107108
```dart
108109
class FulaConfig {
109-
final String endpoint; // Gateway URL (e.g., "http://localhost:9000")
110-
final String? accessToken; // JWT authentication token
111-
final int timeoutSeconds; // Request timeout (default: 30)
112-
final int maxRetries; // Retry attempts (default: 3)
110+
// Connection
111+
final String endpoint; // Gateway URL (e.g., "http://localhost:9000")
112+
final String? accessToken; // JWT authentication token
113+
final int timeoutSeconds; // Request timeout (default: 30)
114+
final int maxRetries; // Retry attempts (default: 3)
115+
final int perChunkDownloadTimeoutSeconds; // F10: per-chunk timeout (default: 300)
116+
final int bufferedDownloadMaxBytes; // F8: buffered download cap (default: 256 MiB)
117+
118+
// Phase 2.1 — master-down detection (functional on every target)
119+
final bool healthGateEnabled; // default: false
120+
final int healthGateTtlSeconds; // default: 30
121+
122+
// Phase 2.2 — persistent block cache (native-only at runtime; flags
123+
// accepted on web for config symmetry, silently inert in browsers)
124+
final bool blockCacheEnabled; // default: false
125+
final String blockCachePath; // default: "" → platform default
126+
final int blockCacheMaxBytes; // default: 256 MiB
127+
128+
// Phase 2.3 / 2.4 — IPFS gateway race + warm-device offline GET
129+
final bool gatewayFallbackEnabled; // default: false (requires blockCacheEnabled)
130+
final List<String> gatewayFallbackUrls; // default: [] → ships 6 public gateways
131+
final int gatewayRaceConcurrency; // default: 3
132+
133+
// Phase 3.3 — cold-start hybrid resolver (native-only at runtime).
134+
// The resolver activates iff ALL four required fields are populated;
135+
// empty values disable cold-start (the warm-device path still works).
136+
final String usersIndexChainRpcUrl; // operator-supplied (Base/SKALE)
137+
final String usersIndexAnchorAddress; // operator-supplied
138+
final String usersIndexIpnsName; // operator-supplied (k51qzi5...)
139+
final String usersIndexUserKey; // app-derived via deriveUserKeyFromEmail
140+
final List<String> usersIndexIpnsGatewayUrls; // default: [] → SDK defaults
141+
final List<String> usersIndexIpfsGatewayUrls; // default: [] → SDK defaults
113142
}
114143
```
115144

145+
All flags default OFF — apps that don't opt in see byte-identical behavior to pre-Phase-2.x builds.
146+
116147
#### EncryptionConfig
117148
```dart
118149
class EncryptionConfig {
@@ -372,6 +403,111 @@ final restoredClient = await createEncryptedClient(
372403
);
373404
```
374405

406+
## Offline Reads (Phase 2 + 3)
407+
408+
When the master gateway is unreachable, the SDK can transparently fall back to public IPFS gateways AND, on a fresh device install, cold-start by resolving a globally-published users-index from IPNS or the chain anchor — no client wallet, no fresh master required.
409+
410+
### Two-tier offline read
411+
412+
| Scenario | Path |
413+
|---|---|
414+
| **Warm device** (signed in before, has block cache) | Phase 2.x — gateway race using cached `(bucket, key) → cid` |
415+
| **Fresh install** (no cache) | Phase 3.3 — cold-start resolver fetches global users-index via IPNS or chain, then walks per-user manifest |
416+
| **Master up** | Direct master read (fast path, byte-identical to today) |
417+
418+
### Step 1 — Enable warm-device offline reads
419+
420+
```dart
421+
final config = FulaConfig(
422+
endpoint: 'https://your-fula-gateway.com:9000',
423+
accessToken: jwt,
424+
// Phase 2.1 — detect master-down without per-read timeout tax
425+
healthGateEnabled: true,
426+
// Phase 2.2 — persistent block cache (gateway hits land here)
427+
blockCacheEnabled: true,
428+
// Phase 2.4 — fall back to public gateways when master is down
429+
gatewayFallbackEnabled: true,
430+
);
431+
```
432+
433+
### Step 2 — (Optional) Enable cold-start for fresh installs
434+
435+
In addition to the Phase 2.x flags, pass the four operator-supplied resolver fields and the per-user `userKey` derived from the user's email:
436+
437+
```dart
438+
import 'package:fula_client/fula_client.dart';
439+
440+
// Compute userKey once at sign-in. Email is hashed locally;
441+
// the SDK never sees the plaintext on the wire.
442+
final userKey = deriveUserKeyFromEmail(userEmail);
443+
444+
final config = FulaConfig(
445+
endpoint: 'https://your-fula-gateway.com:9000',
446+
accessToken: jwt,
447+
healthGateEnabled: true,
448+
blockCacheEnabled: true,
449+
gatewayFallbackEnabled: true,
450+
// Phase 3.3 — cold-start hybrid resolver
451+
usersIndexChainRpcUrl: 'https://mainnet.base.org', // or SKALE
452+
usersIndexAnchorAddress: '0x...FulaUsersIndexAnchor...',
453+
usersIndexIpnsName: 'k51qzi5uqu5dh...', // operator's published IPNS NAME
454+
usersIndexUserKey: userKey,
455+
);
456+
```
457+
458+
### Step 3 — Read with transparency fields
459+
460+
```dart
461+
final result = await getObjectWithOfflineFallback(client, 'my-bucket', 'photos/cat.jpg');
462+
final bytes = result.inner.data;
463+
464+
// Surface "you're offline" UI
465+
switch (result.source) {
466+
case FulaReadSource.master:
467+
// fast path — master served the bytes directly
468+
break;
469+
case FulaReadSource.localCache:
470+
// BLOCKS hit — no network round-trip at all
471+
showToast('Reading from cache (offline)');
472+
break;
473+
case FulaReadSource.gateway:
474+
// gateway race served the bytes; result.source.url has the gateway URL
475+
showToast('Reading via public IPFS (master is down)');
476+
break;
477+
}
478+
```
479+
480+
### Step 4 — Subscribe to master health transitions
481+
482+
Two patterns are exposed; pick whichever fits your app:
483+
484+
```dart
485+
// Pattern A: drain events on a timer / on UI rebuild
486+
final events = pollMasterHealthEvents(client);
487+
for (final event in events) {
488+
switch (event) {
489+
case MasterHealthEvent.online:
490+
setState(() => isOffline = false);
491+
break;
492+
case MasterHealthEvent.offlineFallbackActive:
493+
setState(() => isOffline = true);
494+
break;
495+
case MasterHealthEvent.severelyDegraded:
496+
// both master AND chain unreachable — disable "create new bucket" UI
497+
setState(() => canStartFresh = false);
498+
break;
499+
}
500+
}
501+
502+
// Pattern B: read latest event on mount (no buffer drain)
503+
final last = getLastMasterHealthEvent(client);
504+
if (last is MasterHealthEvent.offlineFallbackActive) {
505+
// app started while master is down
506+
}
507+
```
508+
509+
The `EncryptedClient` has corresponding `pollMasterHealthEventsEncrypted` and `getLastMasterHealthEventEncrypted` variants.
510+
375511
## Error Handling
376512

377513
All operations can throw `FulaError` with specific error types:
@@ -388,11 +524,39 @@ try {
388524
print('Access denied: ${e.message}');
389525
break;
390526
case FulaError.network:
527+
// includes Phase 2.1 MasterUnreachable
391528
print('Network error: ${e.message}');
392529
break;
393530
case FulaError.encryption:
394531
print('Encryption error: ${e.message}');
395532
break;
533+
// Phase 2.x cache errors
534+
case FulaError.cacheBudgetExceeded:
535+
// Phase 2.2: block too large for the cache budget; not fatal —
536+
// the read still succeeded, just not cached.
537+
print('Cache budget exceeded for ${e.size} bytes (budget: ${e.budget})');
538+
break;
539+
case FulaError.cacheError:
540+
// Phase 2.2: redb open / read / write failure; offline path
541+
// disabled for this session.
542+
print('Block cache unavailable: ${e.message}');
543+
break;
544+
// Phase 3.3 cold-start errors
545+
case FulaError.usersIndexResolutionFailed:
546+
// Both IPNS and chain channels failed — cold-start unavailable.
547+
// Surface to user as "can't reach storage; please try again later".
548+
print('Cold-start resolver exhausted: ${e.reason}');
549+
break;
550+
case FulaError.sequenceRegression:
551+
// Replay-defense rejection — the resolver observed a sequence
552+
// older than what it has previously seen. Either a stale gateway
553+
// response or (rarely) a tampered payload. SDK retries the
554+
// alternate channel automatically; this surface is for logging.
555+
print(
556+
'Sequence regression on ${e.channel}: '
557+
'observed=${e.observed}, highestSeen=${e.highestSeen}',
558+
);
559+
break;
396560
default:
397561
print('Error: $e');
398562
}

0 commit comments

Comments
 (0)