this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

SSE event streaming POC + @opake/react hooks + shared lint config

Three interlocking changes landing as one coherent unit:

1. WASM-owned SSE consumer + TreeKeeper + watchDirectory
- Move SSE event consumption from TypeScript into Rust/WASM.
New `crates/opake-core/src/sse/` (parser, consumer, transport,
reconnect, mock) + `tree_keeper/` with per-context directory
trees that apply events incrementally and fire watcher
callbacks on change.
- `FileManager::watchDirectory(uri, handler)` subscription API.
Handler receives `DirectoryTreeSnapshot | null` (null = directory
deleted, watcher auto-closes).
- `Opake::startSseConsumer(url?)` / `stopSseConsumer()` with
cancellation via `sse_started` flag checked in the consumer
loop, plus `TreeKeeper::uninstall_all()` on stop so account
switches don't leave a previous user's ContentKeys resident.
- Proposal events dispatched via `schedule_proposal_sync` with a
per-keyring 2s debounce (bounded `thread_local!` HashMap, cleared
on fire or stop). Detached via `spawn_local` so the consumer
loop never blocks across an /api/keyrings round-trip.
- Delete old JS SSE path (`event-stream.ts`, `sse-consumer.ts`,
`SSEConfig`, `onWorkspaceUpdated`, `markWrite`). Tree idempotency
replaces write-correlation for self-echo handling.
- `directory-sync` TaskDef is now CLI-only. Web daemon has no
handler; core registry comment is explicit about this.

2. @opake/react subscription hooks
- New `FileManagerCache` (refcounted) + `OpakeProvider` with
automatic SSE start/stop tied to provider lifecycle (triggers
TreeKeeper wipe on unmount for account-switch safety).
- `useFileManager(keyringUri)`, `useDirectory(keyringUri, dirUri)`,
`useSseConsumer(appviewUrl?)` — subscription-based reads backed
by `watchDirectory`. State is commit-keyed so stale-deps don't
flash into view mid-transition.
- 32 unit tests via vitest + @testing-library/react + jsdom with a
hand-rolled MockOpake / MockFileManager.
- Existing react-query hooks (useTree, useUpload, etc.) stay; add
@deprecated JSDoc pointing at useDirectory.

3. Unified ESLint + Prettier config across workspaces
- Root `eslint.config.base.ts` exports `makeBaseConfig` with the
shared strict-TS, react-hooks, functional, security, sonarjs
rule set. Each package's own `eslint.config.ts` imports it and
layers file-level overrides for legitimate exceptions.
- Root `.prettierrc` / `.prettierignore` (apps/web keeps its own
prettierrc for the Tailwind plugin).
- Add `lint` / `format` scripts to @opake/sdk, @opake/daemon,
@opake/react matching apps/web.
- Hoist eslint deps into root package.json; remove duplicates
from apps/web.
- Fix real findings: React 19 `react-hooks/refs` anti-pattern in
OpakeProvider (useRef-during-render replaced with useMemo),
prefer-optional-chain in file-manager.ts, use-unknown-in-catch
on rejection callbacks.

Review fixes folded in from pre-commit Opake Review pass:
- 60s-fallback comments rewritten to tell the truth about web vs CLI.
- `watcherInitialFireConsumed` eager-fire swallow removed; closes
the drift window between syncAndLoadTree and watcher installation.
- `currentDirectoryUri` cleared synchronously on deletion before
dispatching loadDirectory(null).
- `SseEvent::keyring_uri()` method with exhaustive match so a new
proposal variant becomes a compile error.

Deferred (tracked in memory cleanup sweep):
- `stop_sse_consumer` reaching directly into TreeKeeper — ideally
moves to `WasmOpakeHandle::wipeState()` so a future logout-without-
SSE path can share the wipe.
- Provider inlines `useSseConsumer` start/stop logic — different
semantics, unifying costs more than it saves.
- document_update proposals can't route to workspace owners via
SSE (lexicon has no keyring field); fix pending document editing
landing in the web client.

+6550 -1134
+7
.prettierignore
··· 1 + dist/ 2 + node_modules/ 3 + .output/ 4 + target/ 5 + apps/web/src/routeTree.gen.ts 6 + apps/web/src/wasm/ 7 + packages/opake-sdk/wasm/
+6
.prettierrc
··· 1 + { 2 + "semi": true, 3 + "singleQuote": false, 4 + "printWidth": 100, 5 + "trailingComma": "all" 6 + }
+54
Cargo.lock
··· 656 656 checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 657 657 dependencies = [ 658 658 "futures-core", 659 + "futures-sink", 659 660 ] 660 661 661 662 [[package]] ··· 671 672 checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 672 673 673 674 [[package]] 675 + name = "futures-macro" 676 + version = "0.3.32" 677 + source = "registry+https://github.com/rust-lang/crates.io-index" 678 + checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 679 + dependencies = [ 680 + "proc-macro2", 681 + "quote", 682 + "syn", 683 + ] 684 + 685 + [[package]] 686 + name = "futures-sink" 687 + version = "0.3.32" 688 + source = "registry+https://github.com/rust-lang/crates.io-index" 689 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 690 + 691 + [[package]] 674 692 name = "futures-task" 675 693 version = "0.3.32" 676 694 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 683 701 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 684 702 dependencies = [ 685 703 "futures-core", 704 + "futures-io", 705 + "futures-macro", 706 + "futures-sink", 686 707 "futures-task", 708 + "memchr", 687 709 "pin-project-lite", 688 710 "slab", 689 711 ] ··· 1309 1331 "aes-gcm", 1310 1332 "aes-kw", 1311 1333 "base64", 1334 + "bytes", 1312 1335 "ed25519-dalek", 1336 + "futures-channel", 1337 + "futures-util", 1313 1338 "getrandom 0.2.17", 1314 1339 "hickory-resolver", 1315 1340 "hkdf", ··· 1694 1719 "base64", 1695 1720 "bytes", 1696 1721 "futures-core", 1722 + "futures-util", 1697 1723 "http", 1698 1724 "http-body", 1699 1725 "http-body-util", ··· 1713 1739 "sync_wrapper", 1714 1740 "tokio", 1715 1741 "tokio-rustls", 1742 + "tokio-util", 1716 1743 "tower", 1717 1744 "tower-http", 1718 1745 "tower-service", 1719 1746 "url", 1720 1747 "wasm-bindgen", 1721 1748 "wasm-bindgen-futures", 1749 + "wasm-streams", 1722 1750 "web-sys", 1723 1751 ] 1724 1752 ··· 2247 2275 ] 2248 2276 2249 2277 [[package]] 2278 + name = "tokio-util" 2279 + version = "0.7.18" 2280 + source = "registry+https://github.com/rust-lang/crates.io-index" 2281 + checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" 2282 + dependencies = [ 2283 + "bytes", 2284 + "futures-core", 2285 + "futures-sink", 2286 + "pin-project-lite", 2287 + "tokio", 2288 + ] 2289 + 2290 + [[package]] 2250 2291 name = "toml" 2251 2292 version = "1.0.3+spec-1.1.0" 2252 2293 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2528 2569 checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" 2529 2570 dependencies = [ 2530 2571 "unicode-ident", 2572 + ] 2573 + 2574 + [[package]] 2575 + name = "wasm-streams" 2576 + version = "0.5.0" 2577 + source = "registry+https://github.com/rust-lang/crates.io-index" 2578 + checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" 2579 + dependencies = [ 2580 + "futures-util", 2581 + "js-sys", 2582 + "wasm-bindgen", 2583 + "wasm-bindgen-futures", 2584 + "web-sys", 2531 2585 ] 2532 2586 2533 2587 [[package]]
+3 -1
Cargo.toml
··· 14 14 clap = { version = "4", features = ["derive", "color"] } 15 15 ed25519-dalek = { version = "2", features = ["rand_core"] } 16 16 env_logger = "0.11" 17 + futures-channel = { version = "0.3", default-features = false, features = ["std", "sink"] } 18 + futures-util = { version = "0.3", default-features = false, features = ["std", "async-await-macro"] } 17 19 log = "0.4" 18 - reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } 20 + reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "stream"] } 19 21 serde = { version = "1", features = ["derive"] } 20 22 serde_json = "1" 21 23 tempfile = "3"
+24 -5
apps/appview/lib/opake_appview/indexer/heartbeat.ex
··· 101 101 now_us = DateTime.utc_now() |> DateTime.to_unix(:microsecond) 102 102 lag_secs = div(now_us - time_us, 1_000_000) 103 103 104 + # Composite units (1m30s, 8h24m, 2d4h) rather than rounded decimals 105 + # so live-updating lag values visibly tick per minute when the 106 + # indexer is catching up — `8.4h` stayed `8.4h` for 20 minutes, 107 + # whereas `8h24m` ticks every minute. 104 108 cond do 105 - lag_secs < 0 -> "0s" 106 - lag_secs < 60 -> "#{lag_secs}s" 107 - lag_secs < 3600 -> "#{div(lag_secs, 60)}m" 108 - lag_secs < 86_400 -> "#{Float.round(lag_secs / 3600, 1)}h" 109 - true -> "#{Float.round(lag_secs / 86_400, 1)}d" 109 + lag_secs < 0 -> 110 + "0s" 111 + 112 + lag_secs < 60 -> 113 + "#{lag_secs}s" 114 + 115 + lag_secs < 3600 -> 116 + mins = div(lag_secs, 60) 117 + secs = rem(lag_secs, 60) 118 + if secs == 0, do: "#{mins}m", else: "#{mins}m#{secs}s" 119 + 120 + lag_secs < 86_400 -> 121 + hours = div(lag_secs, 3600) 122 + mins = div(rem(lag_secs, 3600), 60) 123 + if mins == 0, do: "#{hours}h", else: "#{hours}h#{mins}m" 124 + 125 + true -> 126 + days = div(lag_secs, 86_400) 127 + hours = div(rem(lag_secs, 86_400), 3600) 128 + if hours == 0, do: "#{days}d", else: "#{days}d#{hours}h" 110 129 end 111 130 end 112 131
+38 -5
apps/appview/test/opake_appview/indexer/heartbeat_test.exs
··· 82 82 assert line =~ "cursor_lag=0s" 83 83 end 84 84 85 - test "cursor_lag formats stale cursors in human units" do 86 - one_hour_ago_us = 85 + test "cursor_lag formats minute-granular lag under an hour" do 86 + ninety_seconds_ago = 87 + DateTime.utc_now() 88 + |> DateTime.add(-90, :second) 89 + |> DateTime.to_unix(:microsecond) 90 + 91 + line = Heartbeat.format_snapshot(base_snapshot(%{cursor_time_us: ninety_seconds_ago})) 92 + assert line =~ "cursor_lag=1m30s" 93 + end 94 + 95 + test "cursor_lag formats hours and minutes together" do 96 + # 8 hours 24 minutes ago = 30240 seconds 97 + lag_us = 98 + DateTime.utc_now() 99 + |> DateTime.add(-30_240, :second) 100 + |> DateTime.to_unix(:microsecond) 101 + 102 + line = Heartbeat.format_snapshot(base_snapshot(%{cursor_time_us: lag_us})) 103 + assert line =~ "cursor_lag=8h24m" 104 + end 105 + 106 + test "cursor_lag formats whole hours without minute suffix" do 107 + two_hours_ago = 108 + DateTime.utc_now() 109 + |> DateTime.add(-7200, :second) 110 + |> DateTime.to_unix(:microsecond) 111 + 112 + line = Heartbeat.format_snapshot(base_snapshot(%{cursor_time_us: two_hours_ago})) 113 + assert line =~ "cursor_lag=2h" 114 + refute line =~ "cursor_lag=2h0m" 115 + end 116 + 117 + test "cursor_lag formats days and hours together" do 118 + # 2 days 4 hours = 187200 seconds 119 + lag_us = 87 120 DateTime.utc_now() 88 - |> DateTime.add(-3700, :second) 121 + |> DateTime.add(-187_200, :second) 89 122 |> DateTime.to_unix(:microsecond) 90 123 91 - line = Heartbeat.format_snapshot(base_snapshot(%{cursor_time_us: one_hour_ago_us})) 92 - assert line =~ "cursor_lag=1.0h" 124 + line = Heartbeat.format_snapshot(base_snapshot(%{cursor_time_us: lag_us})) 125 + assert line =~ "cursor_lag=2d4h" 93 126 end 94 127 end
+9 -109
apps/web/eslint.config.ts
··· 1 - import tseslint from "typescript-eslint" 2 - import reactHooks from "eslint-plugin-react-hooks" 3 - import jsxA11y from "eslint-plugin-jsx-a11y" 4 - import functional from "eslint-plugin-functional" 5 - import security from "eslint-plugin-security" 6 - import noSecrets from "eslint-plugin-no-secrets" 7 - import sonarjs from "eslint-plugin-sonarjs" 8 - import prettier from "eslint-config-prettier" 1 + import tseslint from "typescript-eslint"; 2 + import jsxA11y from "eslint-plugin-jsx-a11y"; 3 + import { makeBaseConfig } from "../../eslint.config.base.ts"; 9 4 10 5 export default tseslint.config( 6 + ...makeBaseConfig({ tsconfigRootDir: import.meta.dirname }), 7 + 11 8 // --------------------------------------------------------------------------- 12 - // Global ignores 9 + // Additional ignores — web has generated + embedded content 13 10 // --------------------------------------------------------------------------- 14 11 { 15 12 ignores: [ ··· 17 14 "src/wasm/**", 18 15 "src/content/**/*.mdx", 19 16 ".output/**", 20 - "dist/**", 21 - "node_modules/**", 22 17 ], 23 18 }, 24 19 25 20 // --------------------------------------------------------------------------- 26 - // TypeScript — strict + type-checked (includes eslint:recommended overrides) 21 + // jsx-a11y — web-only, we render UI for humans 27 22 // --------------------------------------------------------------------------- 28 - ...tseslint.configs.strictTypeChecked, 29 - ...tseslint.configs.stylisticTypeChecked, 30 - { 31 - languageOptions: { 32 - parserOptions: { 33 - projectService: true, 34 - tsconfigRootDir: import.meta.dirname, 35 - }, 36 - }, 37 - }, 38 - 39 - // --------------------------------------------------------------------------- 40 - // React 41 - // --------------------------------------------------------------------------- 42 - reactHooks.configs.flat.recommended, 43 23 jsxA11y.flatConfigs.recommended, 44 24 45 25 // --------------------------------------------------------------------------- 46 - // Functional — immutability enforcement 47 - // --------------------------------------------------------------------------- 48 - { 49 - plugins: { functional: functional }, 50 - rules: { 51 - "functional/immutable-data": "error", 52 - "functional/no-let": "error", 53 - "functional/no-loop-statements": "error", 54 - "functional/prefer-immutable-types": ["error", { 55 - enforcement: "None", 56 - ignoreInferredTypes: true, 57 - parameters: { enforcement: "None" }, 58 - returnTypes: { enforcement: "None" }, 59 - variables: { enforcement: "ReadonlyShallow" }, 60 - }], 61 - }, 62 - }, 63 - 64 - // --------------------------------------------------------------------------- 65 - // Security 66 - // --------------------------------------------------------------------------- 67 - security.configs.recommended, 68 - sonarjs.configs.recommended, 69 - { 70 - plugins: { "no-secrets": noSecrets }, 71 - rules: { 72 - "no-secrets/no-secrets": "error", 73 - }, 74 - }, 75 - 76 - // --------------------------------------------------------------------------- 77 - // Prettier — must be LAST (disables conflicting format rules) 78 - // --------------------------------------------------------------------------- 79 - prettier, 80 - 81 - // --------------------------------------------------------------------------- 82 - // Project-wide rule tuning 83 - // --------------------------------------------------------------------------- 84 - { 85 - rules: { 86 - // Allow void for fire-and-forget promises (e.g. `void store.boot()`) 87 - "@typescript-eslint/no-confusing-void-expression": "off", 88 - // Console is acceptable in a client app with dev tooling 89 - "no-console": "off", 90 - // Allow numbers in template literals — common and safe 91 - "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], 92 - // Optional chains are safer than non-null assertions 93 - "@typescript-eslint/non-nullable-type-assertion-style": "off", 94 - 95 - // --- sonarjs dedup: disable rules that overlap with typescript-eslint --- 96 - "sonarjs/no-unused-vars": "off", 97 - "sonarjs/no-dead-store": "off", 98 - "sonarjs/deprecation": "off", 99 - // TODOs are intentional markers referencing issue numbers 100 - "sonarjs/todo-tag": "off", 101 - 102 - // High false-positive rate with bracket notation on typed objects 103 - "security/detect-object-injection": "off", 104 - 105 - // --- sonarjs: disable rules that don't suit React codebases --- 106 - // Nested template literals are standard in Tailwind className expressions 107 - "sonarjs/no-nested-template-literals": "off", 108 - // React components routinely define callbacks/handlers as nested functions 109 - "sonarjs/no-nested-functions": "off", 110 - // JSX uses ternaries extensively for conditional rendering 111 - "sonarjs/no-nested-conditional": "off", 112 - 113 - // Allow local object construction patterns (headers, records) and browser APIs 114 - "functional/immutable-data": ["error", { 115 - ignoreClasses: true, 116 - ignoreImmediateMutation: true, 117 - ignoreNonConstDeclarations: true, 118 - ignoreAccessorPattern: ["window.**", "document.**", "**.current"], 119 - }], 120 - // Raise entropy threshold to avoid false positives on charsets/alphabets 121 - "no-secrets/no-secrets": ["error", { tolerance: 5.5 }], 122 - }, 123 - }, 124 - 125 - // --------------------------------------------------------------------------- 126 26 // Overrides: networking files (retry/nonce patterns need let + mutation) 127 27 // --------------------------------------------------------------------------- 128 28 { ··· 156 56 // Zustand store methods accessed via useStore(s => s.method) are safe 157 57 "@typescript-eslint/unbound-method": "off", 158 58 // TanStack Router's beforeLoad guards use `throw redirect(...)` which 159 - // returns a Response, not an Error. This is the documented API pattern. 59 + // returns a Response, not an Error. Documented API pattern. 160 60 "@typescript-eslint/only-throw-error": "off", 161 61 }, 162 62 }, ··· 174 74 "@typescript-eslint/no-unsafe-call": "off", 175 75 }, 176 76 }, 177 - ) 77 + );
-10
apps/web/package.json
··· 59 59 "@types/react-dom": "^19.2.3", 60 60 "@types/react-syntax-highlighter": "^15.5.13", 61 61 "daisyui": "^5.5.19", 62 - "eslint": "^10.0.3", 63 - "eslint-config-prettier": "^10.1.8", 64 - "eslint-plugin-functional": "^9.0.4", 65 - "eslint-plugin-jsx-a11y": "^6.10.2", 66 - "eslint-plugin-no-secrets": "^2.3.3", 67 - "eslint-plugin-react-hooks": "^7.0.1", 68 - "eslint-plugin-security": "^4.0.0", 69 - "eslint-plugin-sonarjs": "^4.0.1", 70 62 "fake-indexeddb": "^6.2.5", 71 63 "happy-dom": "^20.8.3", 72 64 "jiti": "^2.6.1", 73 - "prettier": "^3.8.1", 74 65 "prettier-plugin-tailwindcss": "^0.7.2", 75 66 "tailwindcss": "^4.2.1", 76 67 "typescript": "^5.9.3", 77 - "typescript-eslint": "^8.56.1", 78 68 "vite": "^7.3.1", 79 69 "vite-plugin-comlink": "^5.3.0", 80 70 "vite-plugin-wasm": "^3.5.0",
+19 -21
apps/web/src/routes/cabinet/route.lazy.tsx
··· 8 8 } from "@/components/cabinet/CreateWorkspaceDialog"; 9 9 import { useWorkspaceStore } from "@/stores/workspace"; 10 10 import { getOpake, useAuthStore } from "@/stores/auth"; 11 - import { useDocumentsStore } from "@/stores/documents/store"; 12 11 import { taskStore } from "@/stores/tasks"; 13 12 import { startDaemon } from "@opake/daemon"; 14 13 import { Opake } from "@opake/sdk"; 15 14 import { toastError, toastSuccess } from "@/stores/toast"; 16 15 17 - /** Re-sync the currently viewed directory from the PDS. */ 18 - function reloadCurrentDirectory(): void { 19 - const { loaded, currentDirectoryUri } = useDocumentsStore.getState(); 20 - if (loaded) { 21 - void useDocumentsStore.getState().loadDirectory(currentDirectoryUri); 22 - } 23 - } 24 - 25 16 function CabinetLayout() { 26 17 const [sidebarOpen, setSidebarOpen] = useState(false); 27 18 const dialogRef = useRef<CreateWorkspaceDialogHandle>(null); ··· 31 22 void useWorkspaceStore.getState().loadWorkspaces(); 32 23 }, []); 33 24 34 - // Background daemon — PDS maintenance writes + SSE-driven proposal sync. 35 - // When `sse` is provided, directory-sync downgrades to a low-frequency 36 - // fallback and SSE events drive targeted per-workspace sync. Other tasks 37 - // (pair-cleanup, grant-healing, share-retry) always run on intervals. 25 + // Start the WASM SSE consumer; stop it on teardown so 26 + // `TreeKeeper::uninstall_all` runs and the previous user's 27 + // `ContentKey`s / decrypted names don't linger across login. 38 28 useEffect(() => { 39 29 const appviewUrl = import.meta.env.VITE_APPVIEW_URL as string | undefined; 30 + if (!appviewUrl) return; 31 + const opake = getOpake(); 32 + void opake.startSseConsumer(appviewUrl).catch((err: unknown) => { 33 + console.warn("[opake] startSseConsumer failed:", err); 34 + }); 35 + return () => { 36 + try { 37 + opake.stopSseConsumer(); 38 + } catch (err) { 39 + console.warn("[opake] stopSseConsumer failed:", err); 40 + } 41 + }; 42 + }, []); 43 + 44 + // Background daemon — timer polling for maintenance tasks only. 45 + useEffect(() => { 40 46 // eslint-disable-next-line functional/no-let -- handle assigned inside async IIFE 41 47 let handle: ReturnType<typeof startDaemon> | null = null; 42 48 void Opake.taskDefs().then((defs) => { 43 49 handle = startDaemon(getOpake(), defs, taskStore, { 44 - sse: appviewUrl ? { 45 - appviewUrl, 46 - onRecordChanged: () => reloadCurrentDirectory(), 47 - } : undefined, 48 - onWorkspaceUpdated: () => { 49 - reloadCurrentDirectory(); 50 - void useWorkspaceStore.getState().loadWorkspaces(); 51 - }, 52 50 onSessionExpired: () => { 53 51 handle?.stop(); 54 52 void useAuthStore.getState().logout();
+83 -5
apps/web/src/stores/documents/store.ts
··· 8 8 9 9 import { create } from "zustand"; 10 10 import { immer } from "zustand/middleware/immer"; 11 - import type { FileManager, DownloadResult, DocumentMetadata } from "@opake/sdk"; 11 + import type { FileManager, DownloadResult, DocumentMetadata, DirectoryWatcher } from "@opake/sdk"; 12 12 import type { DirectoryTreeSnapshot } from "@/lib/pdsTypes"; 13 13 import type { FileItem } from "@/components/cabinet/types"; 14 14 import { findParentUri, ancestorsOf, resolveDirectoryFromSplat } from "@/lib/directoryTree"; ··· 58 58 // eslint-disable-next-line functional/no-let 59 59 let mutationChain: Promise<unknown> = Promise.resolve(); 60 60 61 + // SSE-driven live updates for the current directory. installed by 62 + // `loadDirectory` after a successful load, closed by `close()` and 63 + // re-installed when the directory URI changes. See the comment on 64 + // `installDirectoryWatcher` below for debounce semantics. 65 + // eslint-disable-next-line functional/no-let 66 + let activeWatcher: DirectoryWatcher | null = null; 67 + // Per-watcher debounce timer. Bound to a closure reference so two 68 + // overlapping watchers (during a fast directory switch) can't steal 69 + // each other's scheduled reloads. 70 + // eslint-disable-next-line functional/no-let 71 + let watcherReloadTimer: ReturnType<typeof setTimeout> | null = null; 72 + 73 + const WATCHER_RELOAD_DEBOUNCE_MS = 250; 74 + 61 75 /** Access the active FileManager. Throws if none — only call within an active context. */ 62 76 export function getActiveFileManager(): FileManager { 63 77 if (!activeManager) throw new Error("No active file context — call open() first"); 64 78 return activeManager; 65 79 } 66 80 81 + /** 82 + * Install a SSE-driven watcher for the given directory URI. Replaces any 83 + * previously-active watcher. 84 + * 85 + * The eager fire that `watchDirectory` delivers at install time is NOT 86 + * swallowed — it schedules a real `loadDirectory` through the 87 + * FileManager, which re-syncs and re-fetches metadata unconditionally. 88 + * That's one extra round-trip per directory navigation, accepted 89 + * because it also closes the drift window between `syncAndLoadTree` 90 + * (T0) and watcher registration (T2): any SSE event that patched the 91 + * TreeKeeper in between shows up here instead of being lost. 92 + * 93 + * On a `null` snapshot (watched directory deleted) we clear 94 + * `currentDirectoryUri` synchronously — otherwise any consumer 95 + * reading the store between this tick and the async load completing 96 + * would see a dead URI. 97 + */ 98 + function installDirectoryWatcher(directoryUri: string): void { 99 + closeDirectoryWatcher(); 100 + if (!activeManager) return; 101 + 102 + activeWatcher = activeManager.watchDirectory(directoryUri, (snapshot) => { 103 + if (snapshot === null) { 104 + // Deletion: drop the stale pointer synchronously before the 105 + // async load kicks off. Otherwise any caller reading 106 + // `currentDirectoryUri` between this tick and the store update 107 + // inside `loadDirectory` would see the deleted URI. 108 + useDocumentsStore.setState((draft) => { 109 + draft.currentDirectoryUri = null; 110 + }); 111 + void useDocumentsStore.getState().loadDirectory(null); 112 + return; 113 + } 114 + // Debounce: collapse rapid SSE bursts (e.g., 10 events in 100ms) 115 + // into one reload. Also swallows the eager first fire on watcher 116 + // installation when the store state is already current — the 117 + // scheduled reload is a no-op refresh in that case. 118 + if (watcherReloadTimer) return; 119 + watcherReloadTimer = setTimeout(() => { 120 + watcherReloadTimer = null; 121 + void useDocumentsStore 122 + .getState() 123 + .loadDirectory(useDocumentsStore.getState().currentDirectoryUri); 124 + }, WATCHER_RELOAD_DEBOUNCE_MS); 125 + }); 126 + } 127 + 128 + function closeDirectoryWatcher(): void { 129 + activeWatcher?.close(); 130 + activeWatcher = null; 131 + if (watcherReloadTimer) { 132 + clearTimeout(watcherReloadTimer); 133 + watcherReloadTimer = null; 134 + } 135 + } 136 + 67 137 function contextsMatch(a: FileContext | null, b: FileContext): boolean { 68 138 if (!a) return false; 69 139 if (a.kind !== b.kind) return false; ··· 223 293 try { 224 294 await enqueueMutation(async () => { 225 295 if (gen !== generation) return; 226 - // Open the suppression window BEFORE the write — SSE echo can arrive 227 - // as early as the appview indexes the firehose frame, before fn resolves. 228 - getOpake().markWrite(); 229 296 await fn(getActiveFileManager()); 230 297 }); 231 298 if (gen !== generation) return; ··· 275 342 // Increment generation — stale async ops will check this 276 343 generation++; 277 344 345 + // Close any watcher bound to the previous context 346 + closeDirectoryWatcher(); 347 + 278 348 // Dispose previous manager 279 349 activeManager?.dispose(); 280 350 activeManager = null; ··· 327 397 328 398 close() { 329 399 generation++; 400 + closeDirectoryWatcher(); 330 401 activeManager?.dispose(); 331 402 activeManager = null; 332 403 activeContext = null; ··· 369 440 // Resolve the actual directory (may be root if targetUri was undefined) 370 441 const resolvedUri = directoryUri ?? snapshot.rootUri; 371 442 if (!resolvedUri) { 372 - // No root directory exists yet — empty cabinet/workspace 443 + // No root directory exists yet — empty cabinet/workspace. 444 + // Close any stale watcher; there's nothing to observe. 445 + closeDirectoryWatcher(); 373 446 set((draft) => { 374 447 draft.items = []; 375 448 // Cast: SDK snapshot is deeply readonly, immer draft expects mutable. ··· 392 465 draft.loaded = true; 393 466 draft.error = null; 394 467 }); 468 + 469 + // Install SSE watcher for live updates. We watch the resolved URI 470 + // so the watcher stays bound to an existing tree node (the 471 + // persistent tree needs a real URI, not null). 472 + installDirectoryWatcher(resolvedUri); 395 473 } catch (err) { 396 474 if (gen !== generation) return; 397 475 set((draft) => {
+12 -13
apps/web/src/stores/workspace.ts
··· 29 29 const prevKeys = Object.keys(prev); 30 30 const nextKeys = Object.keys(next); 31 31 if (prevKeys.length !== nextKeys.length) return true; 32 - for (const key of nextKeys) { 33 - const a = prev[key]; 34 - const b = next[key]; 35 - if (!a) return true; 36 - if (a.rotation !== b.rotation) return true; 37 - if (a.memberCount !== b.memberCount) return true; 38 - if (a.name !== b.name) return true; 39 - if (a.description !== b.description) return true; 40 - } 41 - return false; 32 + return nextKeys.some((key) => { 33 + const a = prev[key] as WorkspaceEntry | undefined; 34 + const b = next[key] as WorkspaceEntry | undefined; 35 + if (!a || !b) return true; 36 + return ( 37 + a.rotation !== b.rotation || 38 + a.memberCount !== b.memberCount || 39 + a.name !== b.name || 40 + a.description !== b.description 41 + ); 42 + }); 42 43 } 43 44 44 45 // --------------------------------------------------------------------------- ··· 114 115 // Dialog disables its button during the async call. 115 116 const done = loading("create-workspace"); 116 117 try { 117 - const opake = getOpake(); 118 - opake.markWrite(); 119 - const result = await opake.createWorkspace(name, description ?? ""); 118 + const result = await getOpake().createWorkspace(name, description ?? ""); 120 119 loadPromise = null; 121 120 await useWorkspaceStore.getState().loadWorkspaces(); 122 121 return result.keyringUri;
+5 -1
crates/opake-core/Cargo.toml
··· 7 7 8 8 [features] 9 9 test-utils = [] 10 - reqwest-transport = ["dep:reqwest"] 10 + reqwest-transport = ["dep:reqwest", "dep:bytes"] 11 11 wasm-transport = ["dep:web-sys", "dep:wasm-bindgen", "dep:wasm-bindgen-futures"] 12 12 dns = ["dep:hickory-resolver"] 13 13 native = ["reqwest-transport", "dns"] ··· 16 16 opake-derive = { path = "../opake-derive" } 17 17 base64.workspace = true 18 18 ed25519-dalek.workspace = true 19 + futures-channel.workspace = true 20 + futures-util.workspace = true 19 21 log.workspace = true 20 22 serde.workspace = true 21 23 serde_json.workspace = true 22 24 thiserror.workspace = true 23 25 24 26 reqwest = { workspace = true, optional = true } 27 + bytes = { version = "1", optional = true } 25 28 hickory-resolver = { version = "0.24", optional = true } 26 29 27 30 aes-gcm = "0.10" ··· 45 48 js-sys = "0.3" 46 49 web-sys = { version = "0.3", features = [ 47 50 "Headers", "Request", "RequestInit", "Response", 51 + "EventSource", "MessageEvent", "Event", "EventTarget", 48 52 ], optional = true } 49 53 wasm-bindgen = { version = "0.2", optional = true } 50 54 wasm-bindgen-futures = { version = "0.4", optional = true }
+6 -2
crates/opake-core/src/daemon.rs
··· 44 44 description: "Retry pending shares for recipients who haven't set up yet", 45 45 }, 46 46 TaskDef { 47 + // CLI-only. The web daemon deliberately has no handler for 48 + // this task — web clients drive proposal application via the 49 + // WASM SSE consumer. The CLI still polls because it doesn't 50 + // run an SSE consumer of its own yet. 47 51 name: "directory-sync", 48 - interval_seconds: 5, 49 - description: "Apply pending directory updates from workspace members", 52 + interval_seconds: 60, 53 + description: "CLI-only: apply pending directory updates from workspace members", 50 54 }, 51 55 ]; 52 56
+3 -1
crates/opake-core/src/directories/mod.rs
··· 18 18 pub(crate) use get_or_create_root::{get_or_create_root, get_or_create_workspace_root}; 19 19 pub use move_entry::{check_cycle, move_entry, MoveResult}; 20 20 pub use remove::{remove, RemoveResult}; 21 - pub use tree::{DirectoryTree, DocumentNameResolver, EntryKind, ResolvedPath}; 21 + pub use tree::{ 22 + DecryptionCtx, DirectoryTree, DocumentNameResolver, EntryKind, ResolvedPath, TreeChange, 23 + }; 22 24 23 25 pub const DIRECTORY_COLLECTION: &str = "app.opake.directory"; 24 26 pub const ROOT_DIRECTORY_RKEY: &str = "self";
+204 -1
crates/opake-core/src/directories/tree.rs
··· 10 10 11 11 use crate::atproto; 12 12 use crate::client::TreeDirectory; 13 - use crate::crypto::{self, DirectoryMetadata, X25519PrivateKey}; 13 + use crate::crypto::{self, ContentKey, DirectoryMetadata, X25519PrivateKey}; 14 14 use crate::documents::DOCUMENT_COLLECTION; 15 15 use crate::error::Error; 16 16 use crate::records::{Directory, EncryptedMetadata, KeyWrapping}; 17 + use crate::sse::events::SseDirectoryRecord; 17 18 use crate::storage::CachedRecord; 18 19 19 20 use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_NAME, ROOT_DIRECTORY_RKEY}; ··· 33 34 Document, 34 35 Directory, 35 36 } 37 + 38 + /// Result of an incremental mutation via `apply_directory_delta`. 39 + /// 40 + /// Consumers use this to decide whether to notify watchers and which 41 + /// parent directories are affected (for URI-targeted watcher routing). 42 + #[derive(Debug, Clone, PartialEq, Eq)] 43 + pub enum TreeChange { 44 + /// A directory was newly added to the tree. 45 + Inserted { uri: String }, 46 + /// A directory's entries or metadata changed. 47 + Updated { uri: String }, 48 + /// A directory was removed from the tree. 49 + Removed { uri: String }, 50 + /// The delta matched the existing state — no effective change. 51 + /// Watchers should skip notification. 52 + NoOp, 53 + } 54 + 55 + impl TreeChange { 56 + /// The URI affected by this change, if any. 57 + pub fn uri(&self) -> Option<&str> { 58 + match self { 59 + Self::Inserted { uri } | Self::Updated { uri } | Self::Removed { uri } => Some(uri), 60 + Self::NoOp => None, 61 + } 62 + } 63 + 64 + /// True if this change warrants firing watcher notifications. 65 + pub fn is_effective(&self) -> bool { 66 + !matches!(self, Self::NoOp) 67 + } 68 + } 69 + 70 + /// Decryption context for applying SSE events to an in-memory tree. 71 + /// 72 + /// Provides the keys needed to decrypt directory names. Cabinet trees 73 + /// use `private_key` for direct key wrapping; workspace trees use 74 + /// `group_keys` (keyring URI → unwrapped content key) for keyring 75 + /// wrapping. Trees can hold directories of either kind, so both fields 76 + /// may be needed on the same context. 77 + #[derive(Debug)] 78 + pub struct DecryptionCtx<'a> { 79 + pub did: &'a str, 80 + pub private_key: Option<&'a X25519PrivateKey>, 81 + pub group_keys: &'a HashMap<String, ContentKey>, 82 + } 83 + 84 + impl<'a> DecryptionCtx<'a> { 85 + pub fn cabinet(did: &'a str, private_key: &'a X25519PrivateKey) -> Self { 86 + Self { 87 + did, 88 + private_key: Some(private_key), 89 + group_keys: EMPTY_GROUP_KEYS.get_or_init(HashMap::new), 90 + } 91 + } 92 + 93 + pub fn workspace(did: &'a str, group_keys: &'a HashMap<String, ContentKey>) -> Self { 94 + Self { 95 + did, 96 + private_key: None, 97 + group_keys, 98 + } 99 + } 100 + } 101 + 102 + static EMPTY_GROUP_KEYS: std::sync::OnceLock<HashMap<String, ContentKey>> = 103 + std::sync::OnceLock::new(); 36 104 37 105 #[derive(Debug, Clone)] 38 106 pub struct ResolvedPath { ··· 747 815 Self::from_records(pairs) 748 816 } 749 817 818 + // ----------------------------------------------------------------------- 819 + // Incremental mutation (for SSE-driven tree patching) 820 + // ----------------------------------------------------------------------- 821 + 822 + /// Apply a single indexed directory record to the in-memory tree. 823 + /// 824 + /// This is the incremental sibling of [`from_records`] and 825 + /// [`with_delta`]. SSE-driven consumers call it per event to keep 826 + /// a persistent tree in sync without rebuilding from scratch. 827 + /// 828 + /// The record's name is decrypted in place using `ctx`, so 829 + /// subsequent reads see the correct plaintext name. If decryption 830 + /// fails (wrong key, missing keyring), the name falls back to `"?"` 831 + /// — consistent with [`decrypt_names_with_group_keys`]. 832 + /// 833 + /// Returns a [`TreeChange`] describing the effect. Callers use this 834 + /// to decide whether to fire watcher notifications. 835 + pub fn apply_directory_delta( 836 + &mut self, 837 + dir: &SseDirectoryRecord, 838 + ctx: &DecryptionCtx<'_>, 839 + ) -> Result<TreeChange, Error> { 840 + let uri = &dir.directory_uri; 841 + 842 + // Handle deletion first. 843 + if dir.deleted_at.is_some() { 844 + if self.directories.remove(uri).is_none() { 845 + return Ok(TreeChange::NoOp); 846 + } 847 + // Clear root_uri if we just removed the root. 848 + if self.root_uri.as_deref() == Some(uri.as_str()) { 849 + self.root_uri = None; 850 + } 851 + return Ok(TreeChange::Removed { uri: uri.clone() }); 852 + } 853 + 854 + // Upsert path. Parse key_wrapping and encrypted_metadata from the 855 + // raw JSON values — the SSE payload mirrors the PDS record shape. 856 + let key_wrapping = parse_key_wrapping(dir.key_wrapping.as_ref())?; 857 + let encrypted_metadata = parse_encrypted_metadata(dir.encrypted_metadata.as_ref())?; 858 + 859 + // NoOp detection happens at the TreeKeeper layer via snapshot 860 + // comparison — DirectoryInfo's nested types don't all derive Eq, 861 + // and structural equality on encrypted bytes isn't meaningful 862 + // anyway (two apply calls of the same record always match). 863 + let existed = self.directories.contains_key(uri); 864 + 865 + // Build the new DirectoryInfo. 866 + let mut info = DirectoryInfo { 867 + name: String::new(), 868 + key_wrapping, 869 + encrypted_metadata, 870 + entries: dir.entries.clone(), 871 + }; 872 + 873 + // Decrypt name in place. Errors fall through to "?". 874 + info.name = decrypt_directory_name(&info, ctx).unwrap_or_else(|| "?".into()); 875 + 876 + self.directories.insert(uri.clone(), info); 877 + 878 + // Detect root by rkey=="self" unless we already have a root set 879 + // via set_root() (workspace case, where root is ws-<keyring_rkey>). 880 + if self.root_uri.is_none() { 881 + if let Ok(parsed) = atproto::parse_at_uri(uri) { 882 + if parsed.rkey == ROOT_DIRECTORY_RKEY { 883 + self.root_uri = Some(uri.clone()); 884 + // Override the just-decrypted name with the canonical root name. 885 + if let Some(entry) = self.directories.get_mut(uri) { 886 + entry.name = ROOT_DIRECTORY_NAME.into(); 887 + } 888 + } 889 + } 890 + } 891 + 892 + if existed { 893 + Ok(TreeChange::Updated { uri: uri.clone() }) 894 + } else { 895 + Ok(TreeChange::Inserted { uri: uri.clone() }) 896 + } 897 + } 898 + 899 + /// Clear cached decrypted names. Used after a keyring rotation, 900 + /// since the content keys that produced the names are now stale. 901 + /// Subsequent reads will show `"?"` until the tree is re-decrypted 902 + /// (either via a full `decrypt_names` pass or per-directory apply). 903 + pub fn invalidate_decrypted_names(&mut self) { 904 + for info in self.directories.values_mut() { 905 + info.name.clear(); 906 + } 907 + } 908 + 750 909 /// Apply a delta from the AppView to cached records, returning a new set. 751 910 /// 752 911 /// Deleted directories are filtered out. New/updated directories replace ··· 780 939 .chain(upserted.into_values()) 781 940 .collect() 782 941 } 942 + } 943 + 944 + // --------------------------------------------------------------------------- 945 + // Incremental-apply helpers 946 + // --------------------------------------------------------------------------- 947 + 948 + fn parse_key_wrapping(value: Option<&serde_json::Value>) -> Result<KeyWrapping, Error> { 949 + let value = value 950 + .ok_or_else(|| Error::InvalidRecord("SSE directory event missing key_wrapping".into()))?; 951 + serde_json::from_value(value.clone()) 952 + .map_err(|e| Error::InvalidRecord(format!("invalid key_wrapping: {e}"))) 953 + } 954 + 955 + fn parse_encrypted_metadata(value: Option<&serde_json::Value>) -> Result<EncryptedMetadata, Error> { 956 + let value = value.ok_or_else(|| { 957 + Error::InvalidRecord("SSE directory event missing encrypted_metadata".into()) 958 + })?; 959 + serde_json::from_value(value.clone()) 960 + .map_err(|e| Error::InvalidRecord(format!("invalid encrypted_metadata: {e}"))) 961 + } 962 + 963 + /// Decrypt a single directory's name using the decryption context. 964 + /// 965 + /// Mirrors the logic in `decrypt_names_with_group_keys` but operates on 966 + /// one directory at a time. Returns `None` if the key can't be unwrapped 967 + /// or the metadata can't be decrypted — the caller falls back to `"?"`. 968 + fn decrypt_directory_name(info: &DirectoryInfo, ctx: &DecryptionCtx<'_>) -> Option<String> { 969 + let content_key = match &info.key_wrapping { 970 + KeyWrapping::Direct(direct) => { 971 + let wrapped = direct.keys.iter().find(|k| k.did == ctx.did)?; 972 + let private_key = ctx.private_key?; 973 + crypto::unwrap_key(wrapped, private_key).ok()? 974 + } 975 + KeyWrapping::Keyring(kr) => { 976 + let keyring_uri = &kr.keyring_ref.keyring; 977 + let group_key = ctx.group_keys.get(keyring_uri)?; 978 + let wrapped_bytes = kr.keyring_ref.wrapped_content_key.decode().ok()?; 979 + crypto::unwrap_content_key_from_keyring(&wrapped_bytes, group_key).ok()? 980 + } 981 + }; 982 + 983 + crypto::decrypt_metadata::<DirectoryMetadata>(&content_key, &info.encrypted_metadata) 984 + .ok() 985 + .map(|meta| meta.name) 783 986 } 784 987 785 988 #[cfg(test)]
+269
crates/opake-core/src/directories/tree_tests.rs
··· 592 592 Some("?") 593 593 ); 594 594 } 595 + 596 + // --------------------------------------------------------------------------- 597 + // Incremental mutation via apply_directory_delta (SSE path) 598 + // --------------------------------------------------------------------------- 599 + 600 + use crate::sse::events::SseDirectoryRecord; 601 + 602 + /// Convert a test `Directory` into the SSE payload shape. Mirrors how the 603 + /// appview broadcaster formats directory records — `key_wrapping` and 604 + /// `encrypted_metadata` go through as opaque JSON values. 605 + fn sse_record(uri: &str, dir: &Directory, keyring_uri: Option<&str>) -> SseDirectoryRecord { 606 + SseDirectoryRecord { 607 + directory_uri: uri.into(), 608 + owner_did: TEST_DID.into(), 609 + entries: dir.entries.clone(), 610 + encrypted_metadata: Some(serde_json::to_value(&dir.encrypted_metadata).unwrap()), 611 + key_wrapping: Some(serde_json::to_value(&dir.key_wrapping).unwrap()), 612 + keyring_uri: keyring_uri.map(String::from), 613 + deleted_at: None, 614 + indexed_at: None, 615 + } 616 + } 617 + 618 + fn sse_deleted(uri: &str) -> SseDirectoryRecord { 619 + SseDirectoryRecord { 620 + directory_uri: uri.into(), 621 + owner_did: TEST_DID.into(), 622 + entries: Vec::new(), 623 + encrypted_metadata: None, 624 + key_wrapping: None, 625 + keyring_uri: None, 626 + deleted_at: Some("2026-04-11T12:00:00Z".into()), 627 + indexed_at: None, 628 + } 629 + } 630 + 631 + fn cabinet_ctx<'a>(private_key: &'a crate::crypto::X25519PrivateKey) -> DecryptionCtx<'a> { 632 + DecryptionCtx::cabinet(TEST_DID, private_key) 633 + } 634 + 635 + #[test] 636 + fn apply_directory_delta_inserts_new_directory() { 637 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 638 + let (_, private_key) = test_keypair(); 639 + 640 + let photos_dir = dummy_directory_with_entries("Photos", vec![DOC_BEACH_URI.into()]); 641 + let record = sse_record(DIR_PHOTOS_URI, &photos_dir, None); 642 + 643 + let change = tree 644 + .apply_directory_delta(&record, &cabinet_ctx(&private_key)) 645 + .unwrap(); 646 + 647 + match change { 648 + TreeChange::Inserted { uri } => assert_eq!(uri, DIR_PHOTOS_URI), 649 + other => panic!("expected Inserted, got {other:?}"), 650 + } 651 + 652 + // The name should have been decrypted in place. 653 + assert_eq!(tree.directory_name(DIR_PHOTOS_URI), Some("Photos")); 654 + assert_eq!( 655 + tree.entries_for(DIR_PHOTOS_URI), 656 + Some(&[DOC_BEACH_URI.to_string()][..]) 657 + ); 658 + } 659 + 660 + #[test] 661 + fn apply_directory_delta_detects_root_by_self_rkey() { 662 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 663 + let (_, private_key) = test_keypair(); 664 + 665 + let root_dir = dummy_directory_with_entries("/", vec![]); 666 + let record = sse_record(ROOT_URI, &root_dir, None); 667 + 668 + let change = tree 669 + .apply_directory_delta(&record, &cabinet_ctx(&private_key)) 670 + .unwrap(); 671 + assert!(matches!(change, TreeChange::Inserted { .. })); 672 + 673 + assert_eq!(tree.root_uri(), Some(ROOT_URI)); 674 + // Root directory always renders as "/" regardless of the decrypted name. 675 + assert_eq!(tree.directory_name(ROOT_URI), Some("/")); 676 + } 677 + 678 + #[test] 679 + fn apply_directory_delta_updates_existing_directory() { 680 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 681 + let (_, private_key) = test_keypair(); 682 + 683 + // First insert. 684 + let initial = dummy_directory_with_entries("Photos", vec![DOC_BEACH_URI.into()]); 685 + let record = sse_record(DIR_PHOTOS_URI, &initial, None); 686 + let change = tree 687 + .apply_directory_delta(&record, &cabinet_ctx(&private_key)) 688 + .unwrap(); 689 + assert!(matches!(change, TreeChange::Inserted { .. })); 690 + 691 + // Second apply: same URI, different entries. 692 + let updated = 693 + dummy_directory_with_entries("Photos", vec![DOC_BEACH_URI.into(), DOC_NOTES_URI.into()]); 694 + let record2 = sse_record(DIR_PHOTOS_URI, &updated, None); 695 + let change = tree 696 + .apply_directory_delta(&record2, &cabinet_ctx(&private_key)) 697 + .unwrap(); 698 + 699 + match change { 700 + TreeChange::Updated { uri } => assert_eq!(uri, DIR_PHOTOS_URI), 701 + other => panic!("expected Updated, got {other:?}"), 702 + } 703 + 704 + assert_eq!( 705 + tree.entries_for(DIR_PHOTOS_URI), 706 + Some(&[DOC_BEACH_URI.to_string(), DOC_NOTES_URI.to_string()][..]) 707 + ); 708 + } 709 + 710 + #[test] 711 + fn apply_directory_delta_removes_directory_on_deleted_at() { 712 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 713 + let (_, private_key) = test_keypair(); 714 + 715 + // Insert first. 716 + let photos = dummy_directory_with_entries("Photos", vec![]); 717 + tree.apply_directory_delta( 718 + &sse_record(DIR_PHOTOS_URI, &photos, None), 719 + &cabinet_ctx(&private_key), 720 + ) 721 + .unwrap(); 722 + assert!(tree.is_directory(DIR_PHOTOS_URI)); 723 + 724 + // Now apply a deleted delta. 725 + let change = tree 726 + .apply_directory_delta(&sse_deleted(DIR_PHOTOS_URI), &cabinet_ctx(&private_key)) 727 + .unwrap(); 728 + 729 + match change { 730 + TreeChange::Removed { uri } => assert_eq!(uri, DIR_PHOTOS_URI), 731 + other => panic!("expected Removed, got {other:?}"), 732 + } 733 + 734 + assert!(!tree.is_directory(DIR_PHOTOS_URI)); 735 + } 736 + 737 + #[test] 738 + fn apply_directory_delta_delete_on_missing_is_noop() { 739 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 740 + let (_, private_key) = test_keypair(); 741 + 742 + let change = tree 743 + .apply_directory_delta(&sse_deleted(DIR_PHOTOS_URI), &cabinet_ctx(&private_key)) 744 + .unwrap(); 745 + 746 + assert_eq!(change, TreeChange::NoOp); 747 + assert!(!change.is_effective()); 748 + } 749 + 750 + #[test] 751 + fn apply_directory_delta_removing_root_clears_root_uri() { 752 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 753 + let (_, private_key) = test_keypair(); 754 + 755 + let root_dir = dummy_directory_with_entries("/", vec![]); 756 + tree.apply_directory_delta( 757 + &sse_record(ROOT_URI, &root_dir, None), 758 + &cabinet_ctx(&private_key), 759 + ) 760 + .unwrap(); 761 + assert_eq!(tree.root_uri(), Some(ROOT_URI)); 762 + 763 + tree.apply_directory_delta(&sse_deleted(ROOT_URI), &cabinet_ctx(&private_key)) 764 + .unwrap(); 765 + assert_eq!(tree.root_uri(), None); 766 + } 767 + 768 + #[test] 769 + fn apply_directory_delta_idempotent_repeat_apply_preserves_state() { 770 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 771 + let (_, private_key) = test_keypair(); 772 + 773 + let dir = dummy_directory_with_entries("Photos", vec![DOC_BEACH_URI.into()]); 774 + let record = sse_record(DIR_PHOTOS_URI, &dir, None); 775 + 776 + // First apply — inserts. 777 + let c1 = tree 778 + .apply_directory_delta(&record, &cabinet_ctx(&private_key)) 779 + .unwrap(); 780 + assert!(matches!(c1, TreeChange::Inserted { .. })); 781 + 782 + // Second apply of the same record — counts as Updated (at the 783 + // DirectoryTree layer we don't dedupe; TreeKeeper dedupes via 784 + // snapshot comparison). State should remain identical. 785 + let c2 = tree 786 + .apply_directory_delta(&record, &cabinet_ctx(&private_key)) 787 + .unwrap(); 788 + assert!(matches!(c2, TreeChange::Updated { .. })); 789 + 790 + assert_eq!(tree.directory_name(DIR_PHOTOS_URI), Some("Photos")); 791 + assert_eq!( 792 + tree.entries_for(DIR_PHOTOS_URI), 793 + Some(&[DOC_BEACH_URI.to_string()][..]) 794 + ); 795 + } 796 + 797 + #[test] 798 + fn apply_directory_delta_falls_back_to_question_mark_on_missing_key() { 799 + // Build a DecryptionCtx with a different DID than the one the 800 + // directory was encrypted for. Decryption fails, name becomes "?". 801 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 802 + let (_, private_key) = test_keypair(); 803 + 804 + let dir = dummy_directory_with_entries("Photos", vec![]); 805 + let record = sse_record(DIR_PHOTOS_URI, &dir, None); 806 + 807 + let wrong_ctx = DecryptionCtx::cabinet("did:plc:wrong", &private_key); 808 + let change = tree.apply_directory_delta(&record, &wrong_ctx).unwrap(); 809 + assert!(matches!(change, TreeChange::Inserted { .. })); 810 + assert_eq!(tree.directory_name(DIR_PHOTOS_URI), Some("?")); 811 + } 812 + 813 + #[test] 814 + fn apply_directory_delta_missing_key_wrapping_errors() { 815 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 816 + let (_, private_key) = test_keypair(); 817 + 818 + let malformed = SseDirectoryRecord { 819 + directory_uri: DIR_PHOTOS_URI.into(), 820 + owner_did: TEST_DID.into(), 821 + entries: Vec::new(), 822 + encrypted_metadata: Some(serde_json::json!({"ciphertext": "", "nonce": ""})), 823 + key_wrapping: None, // missing 824 + keyring_uri: None, 825 + deleted_at: None, 826 + indexed_at: None, 827 + }; 828 + 829 + let err = tree 830 + .apply_directory_delta(&malformed, &cabinet_ctx(&private_key)) 831 + .unwrap_err(); 832 + assert!(matches!(err, Error::InvalidRecord(_))); 833 + } 834 + 835 + #[test] 836 + fn invalidate_decrypted_names_clears_all_names() { 837 + let mut tree = DirectoryTree::from_records(std::iter::empty()); 838 + let (_, private_key) = test_keypair(); 839 + 840 + let dir = dummy_directory_with_entries("Photos", vec![]); 841 + tree.apply_directory_delta( 842 + &sse_record(DIR_PHOTOS_URI, &dir, None), 843 + &cabinet_ctx(&private_key), 844 + ) 845 + .unwrap(); 846 + assert_eq!(tree.directory_name(DIR_PHOTOS_URI), Some("Photos")); 847 + 848 + tree.invalidate_decrypted_names(); 849 + assert_eq!(tree.directory_name(DIR_PHOTOS_URI), Some("")); 850 + } 851 + 852 + #[test] 853 + fn tree_change_uri_and_is_effective() { 854 + let inserted = TreeChange::Inserted { 855 + uri: "at://a".into(), 856 + }; 857 + assert_eq!(inserted.uri(), Some("at://a")); 858 + assert!(inserted.is_effective()); 859 + 860 + let noop = TreeChange::NoOp; 861 + assert_eq!(noop.uri(), None); 862 + assert!(!noop.is_effective()); 863 + }
+3
crates/opake-core/src/error.rs
··· 44 44 45 45 #[error("storage error: {0}")] 46 46 Storage(String), 47 + 48 + #[error("SSE error: {0}")] 49 + Sse(String), 47 50 }
+2
crates/opake-core/src/lib.rs
··· 40 40 pub mod resolve; 41 41 pub mod scope; 42 42 pub mod sharing; 43 + pub mod sse; 43 44 pub mod storage; 44 45 pub mod tid; 46 + pub mod tree_keeper; 45 47 pub mod workspace; 46 48 47 49 #[cfg(any(test, feature = "test-utils"))]
+11 -1
crates/opake-core/src/opake.rs
··· 1395 1395 } 1396 1396 1397 1397 /// Resolve the appview URL: cached config → caller default → error. 1398 - fn resolve_appview_url(&self, default: Option<&str>) -> Result<String, Error> { 1398 + /// Resolve the appview URL to use for a request. 1399 + /// 1400 + /// Returns the URL stored on this Opake instance (loaded from config 1401 + /// during `init`), falling back to the provided `default` if no URL 1402 + /// is stored. Returns `NotFound` if neither source has a URL. 1403 + /// 1404 + /// This is the shared helper behind every appview-touching method 1405 + /// (`request_sse_token`, `list_inbox`, `discover_member_keyrings`, 1406 + /// etc.) and also used by WASM bindings that need to auto-resolve 1407 + /// the URL before starting long-lived tasks like the SSE consumer. 1408 + pub fn resolve_appview_url(&self, default: Option<&str>) -> Result<String, Error> { 1399 1409 self.appview_url 1400 1410 .clone() 1401 1411 .or_else(|| default.map(|s| s.to_string()))
+424
crates/opake-core/src/sse/consumer.rs
··· 1 + // SseConsumer — the outer reconnect loop. 2 + // 3 + // Wraps an [`SseTransport`] and handles: 4 + // - Token refresh (fetch a fresh SSE token on every connect attempt) 5 + // - Initial connect with backoff on failure 6 + // - Reconnection with exponential backoff + jitter when the stream drops 7 + // - Synthetic `Reconnect` event emission after a previously-successful 8 + // connection recovers (caller treats it as "full sync the affected 9 + // tree to cover the gap") 10 + // 11 + // Callers use it like an [`SseConnection`]: poll `next_event` in a loop. 12 + // The consumer never returns transport errors — internally it logs, 13 + // backs off, and retries. A returned `Err` from `next_event` means 14 + // "the consumer has been explicitly stopped" or an unrecoverable 15 + // programming error. 16 + 17 + use std::future::Future; 18 + use std::pin::Pin; 19 + use std::time::Duration; 20 + 21 + use crate::error::Error; 22 + use crate::sse::events::SseEvent; 23 + use crate::sse::reconnect::BackoffPolicy; 24 + use crate::sse::transport::{SseConnection, SseTransport}; 25 + 26 + /// A future-returning token fetcher. Called before every connect attempt. 27 + /// Own-your-captures: the closure holds clones of whatever state it needs 28 + /// (transport, DID, signing key, appview URL). 29 + pub type TokenFetcher = Box<dyn FnMut() -> Pin<Box<dyn Future<Output = Result<String, Error>>>>>; 30 + 31 + /// A future-returning sleep function. Abstracts over tokio::time::sleep 32 + /// (native) vs setTimeout via gloo-timers (WASM), so the consumer doesn't 33 + /// pull a runtime dependency. 34 + pub type SleepFn = Box<dyn FnMut(Duration) -> Pin<Box<dyn Future<Output = ()>>>>; 35 + 36 + /// A source of uniform random f64 in [0, 1) for jitter sampling. Injected 37 + /// so tests can use a deterministic sequence. 38 + pub type JitterRng = Box<dyn FnMut() -> f64>; 39 + 40 + /// Configuration and state for the outer reconnect loop. 41 + pub struct SseConsumer<T: SseTransport> { 42 + transport: T, 43 + appview_url: String, 44 + fetch_token: TokenFetcher, 45 + sleep: SleepFn, 46 + jitter: JitterRng, 47 + backoff: BackoffPolicy, 48 + 49 + /// Set once any `next_event` has succeeded from the current or a 50 + /// previous connection. Drives whether the next successful reconnect 51 + /// fires a synthetic [`SseEvent::Reconnect`]. 52 + was_connected: bool, 53 + 54 + /// The active connection, if any. Replaced on every reconnect. 55 + connection: Option<T::Connection>, 56 + 57 + /// Set true when the next successful reconnect should surface a 58 + /// synthetic Reconnect event before delivering real events. 59 + pending_reconnect_event: bool, 60 + } 61 + 62 + impl<T: SseTransport> SseConsumer<T> { 63 + pub fn new( 64 + transport: T, 65 + appview_url: impl Into<String>, 66 + fetch_token: TokenFetcher, 67 + sleep: SleepFn, 68 + jitter: JitterRng, 69 + ) -> Self { 70 + Self { 71 + transport, 72 + appview_url: appview_url.into(), 73 + fetch_token, 74 + sleep, 75 + jitter, 76 + backoff: BackoffPolicy::new(), 77 + was_connected: false, 78 + connection: None, 79 + pending_reconnect_event: false, 80 + } 81 + } 82 + 83 + /// Poll for the next event. 84 + /// 85 + /// Handles reconnection internally: on disconnect or token failure, 86 + /// applies backoff and retries indefinitely. Returns `Ok(event)` for 87 + /// real events and synthetic [`SseEvent::Reconnect`] markers. 88 + /// 89 + /// The only way `next_event` returns without producing an event is 90 + /// if the consumer's sleep future is cancelled — typically because 91 + /// the entire `SseConsumer` is being dropped. In that case the caller 92 + /// sees a pending future that never resolves, which is the correct 93 + /// shutdown signal. 94 + pub async fn next_event(&mut self) -> Result<SseEvent, Error> { 95 + loop { 96 + // Surface a previously-queued reconnect synthetic event before 97 + // delivering real events. This runs exactly once per reconnect. 98 + if self.pending_reconnect_event { 99 + self.pending_reconnect_event = false; 100 + return Ok(SseEvent::Reconnect); 101 + } 102 + 103 + // If we have an active connection, try to pull from it. 104 + if let Some(conn) = self.connection.as_mut() { 105 + match conn.next_event().await { 106 + Ok(Some(event)) => { 107 + // First successful event ever seen — flip the 108 + // was_connected flag so any future reconnect 109 + // emits a synthetic Reconnect. 110 + self.was_connected = true; 111 + // Reset backoff on every successful delivery (not 112 + // just connect). Connect success isn't enough — 113 + // the server might 500 immediately after OK. 114 + self.backoff.reset(); 115 + return Ok(event); 116 + } 117 + Ok(None) => { 118 + log::info!("[sse] stream closed cleanly, reconnecting"); 119 + self.connection = None; 120 + // Back off before reconnecting. Without this, a 121 + // connect→error→reconnect cycle hammers the token 122 + // endpoint unchecked and trips the rate limiter. 123 + self.sleep_with_backoff().await; 124 + } 125 + Err(e) => { 126 + log::warn!("[sse] stream error: {e}, reconnecting"); 127 + self.connection = None; 128 + // Same backoff requirement as the Ok(None) case. 129 + self.sleep_with_backoff().await; 130 + } 131 + } 132 + // Fall through to reconnect (or loop back to pending_reconnect). 133 + continue; 134 + } 135 + 136 + // No active connection. Fetch a fresh token and reconnect. 137 + let token = match (self.fetch_token)().await { 138 + Ok(t) => t, 139 + Err(e) => { 140 + log::warn!("[sse] token fetch failed: {e}"); 141 + self.sleep_with_backoff().await; 142 + continue; 143 + } 144 + }; 145 + 146 + match self.transport.connect(&self.appview_url, token).await { 147 + Ok(conn) => { 148 + self.connection = Some(conn); 149 + // If we were previously delivering events, queue a 150 + // synthetic Reconnect to surface before the next real 151 + // event. Only after the initial connect is skipped — 152 + // `was_connected` is only set on first event delivery. 153 + if self.was_connected { 154 + self.pending_reconnect_event = true; 155 + } 156 + // Loop back to next_event() on the new connection. We 157 + // don't reset backoff here — it'll reset on the first 158 + // event actually delivered (see above). 159 + } 160 + Err(e) => { 161 + log::warn!("[sse] connect failed: {e}"); 162 + self.sleep_with_backoff().await; 163 + } 164 + } 165 + } 166 + } 167 + 168 + /// Sleep for the next backoff-computed delay. Advances backoff state. 169 + async fn sleep_with_backoff(&mut self) { 170 + let rand = (self.jitter)(); 171 + let delay = self.backoff.next_delay(rand); 172 + log::debug!("[sse] backoff sleeping for {:?}", delay); 173 + (self.sleep)(delay).await; 174 + } 175 + 176 + /// Force the consumer to close its current connection. The next 177 + /// `next_event` call will reconnect from scratch. 178 + pub fn force_reconnect(&mut self) { 179 + self.connection = None; 180 + } 181 + } 182 + 183 + // --------------------------------------------------------------------------- 184 + // Tests 185 + // --------------------------------------------------------------------------- 186 + 187 + #[cfg(test)] 188 + mod tests { 189 + use super::*; 190 + use crate::sse::events::{SseDeletePayload, SseEvent}; 191 + use crate::sse::mock::MockSseTransport; 192 + use std::cell::RefCell; 193 + use std::rc::Rc; 194 + 195 + fn delete(uri: &str) -> SseEvent { 196 + SseEvent::KeyringDelete(SseDeletePayload { 197 + uri: Some(uri.into()), 198 + directory_uri: None, 199 + document_uri: None, 200 + }) 201 + } 202 + 203 + /// Build a consumer that uses deterministic jitter (always 0.5 → no 204 + /// offset) and an instant sleep (returns immediately, no real wait). 205 + fn build_consumer( 206 + transport: MockSseTransport, 207 + token_responses: Vec<Result<String, Error>>, 208 + ) -> SseConsumer<MockSseTransport> { 209 + let token_responses = Rc::new(RefCell::new(token_responses)); 210 + 211 + let token_fn: TokenFetcher = Box::new(move || { 212 + let responses = Rc::clone(&token_responses); 213 + Box::pin(async move { 214 + let mut q = responses.borrow_mut(); 215 + if q.is_empty() { 216 + Ok("token".into()) 217 + } else { 218 + q.remove(0) 219 + } 220 + }) 221 + }); 222 + 223 + let sleep_fn: SleepFn = Box::new(|_| Box::pin(async {})); 224 + let jitter_fn: JitterRng = Box::new(|| 0.5); 225 + 226 + SseConsumer::new( 227 + transport, 228 + "https://example.com", 229 + token_fn, 230 + sleep_fn, 231 + jitter_fn, 232 + ) 233 + } 234 + 235 + #[tokio::test] 236 + async fn delivers_single_event() { 237 + let transport = MockSseTransport::new(); 238 + transport.push_event(delete("at://a")); 239 + 240 + let mut consumer = build_consumer(transport, vec![]); 241 + let event = consumer.next_event().await.unwrap(); 242 + match event { 243 + SseEvent::KeyringDelete(d) => { 244 + assert_eq!(d.best_uri(), Some("at://a")); 245 + } 246 + _ => panic!("expected KeyringDelete"), 247 + } 248 + } 249 + 250 + #[tokio::test] 251 + async fn delivers_events_in_order() { 252 + let transport = MockSseTransport::new(); 253 + transport.push_event(delete("at://a")); 254 + transport.push_event(delete("at://b")); 255 + transport.push_event(delete("at://c")); 256 + 257 + let mut consumer = build_consumer(transport, vec![]); 258 + 259 + for uri in ["at://a", "at://b", "at://c"] { 260 + match consumer.next_event().await.unwrap() { 261 + SseEvent::KeyringDelete(d) => assert_eq!(d.best_uri(), Some(uri)), 262 + _ => panic!("expected KeyringDelete"), 263 + } 264 + } 265 + } 266 + 267 + #[tokio::test] 268 + async fn reconnect_after_error_emits_synthetic_event() { 269 + let transport = MockSseTransport::new(); 270 + // First connection: one event, then error. 271 + transport.push_event(delete("at://a")); 272 + transport.push_error(Error::Sse("drop".into())); 273 + // Second connection: one event. 274 + transport.push_event(delete("at://b")); 275 + 276 + let mut consumer = build_consumer(transport, vec![]); 277 + 278 + // First real event — flips was_connected. 279 + let e1 = consumer.next_event().await.unwrap(); 280 + assert!(matches!(e1, SseEvent::KeyringDelete(ref d) if d.best_uri() == Some("at://a"))); 281 + 282 + // Connection drops (internal loop reconnects), synthetic Reconnect 283 + // arrives before the next real event. 284 + let e2 = consumer.next_event().await.unwrap(); 285 + assert!(matches!(e2, SseEvent::Reconnect)); 286 + 287 + // Next real event. 288 + let e3 = consumer.next_event().await.unwrap(); 289 + assert!(matches!(e3, SseEvent::KeyringDelete(ref d) if d.best_uri() == Some("at://b"))); 290 + } 291 + 292 + #[tokio::test] 293 + async fn initial_connect_failure_does_not_emit_reconnect() { 294 + let transport = MockSseTransport::new(); 295 + // First connect fails. 296 + transport.fail_next_connect(Error::Sse("initial failure".into())); 297 + // Second connect succeeds with one event. 298 + transport.push_event(delete("at://a")); 299 + 300 + let mut consumer = build_consumer(transport, vec![]); 301 + 302 + // The consumer retries internally and eventually delivers the real 303 + // event. Since was_connected was never true before the failure, 304 + // no synthetic Reconnect is emitted. 305 + let event = consumer.next_event().await.unwrap(); 306 + match event { 307 + SseEvent::KeyringDelete(d) => assert_eq!(d.best_uri(), Some("at://a")), 308 + SseEvent::Reconnect => panic!("unexpected synthetic reconnect on initial failure"), 309 + _ => panic!("unexpected event type"), 310 + } 311 + } 312 + 313 + #[tokio::test] 314 + async fn token_fetch_failure_retries_with_backoff() { 315 + let transport = MockSseTransport::new(); 316 + transport.push_event(delete("at://a")); 317 + 318 + let mut consumer = build_consumer( 319 + transport, 320 + vec![ 321 + Err(Error::Sse("token fetch fail 1".into())), 322 + Err(Error::Sse("token fetch fail 2".into())), 323 + Ok("good-token".into()), 324 + ], 325 + ); 326 + 327 + // The consumer should silently retry token fetches until success, 328 + // then deliver the event. No error surfaces to the caller. 329 + let event = consumer.next_event().await.unwrap(); 330 + match event { 331 + SseEvent::KeyringDelete(d) => assert_eq!(d.best_uri(), Some("at://a")), 332 + _ => panic!("expected KeyringDelete"), 333 + } 334 + } 335 + 336 + #[tokio::test] 337 + async fn eof_triggers_reconnect() { 338 + let transport = MockSseTransport::new(); 339 + transport.push_event(delete("at://a")); 340 + transport.push_eof(); // clean close after one event 341 + transport.push_event(delete("at://b")); 342 + 343 + let mut consumer = build_consumer(transport, vec![]); 344 + 345 + let e1 = consumer.next_event().await.unwrap(); 346 + assert!(matches!(e1, SseEvent::KeyringDelete(ref d) if d.best_uri() == Some("at://a"))); 347 + 348 + let e2 = consumer.next_event().await.unwrap(); 349 + assert!(matches!(e2, SseEvent::Reconnect)); 350 + 351 + let e3 = consumer.next_event().await.unwrap(); 352 + assert!(matches!(e3, SseEvent::KeyringDelete(ref d) if d.best_uri() == Some("at://b"))); 353 + } 354 + 355 + #[tokio::test] 356 + async fn tight_stream_error_loop_applies_backoff() { 357 + // Regression for the "infinite 429s" bug: if every freshly-opened 358 + // connection immediately errors out (e.g., server-side auth 359 + // failure), the consumer must apply backoff between reconnects 360 + // rather than tight-looping token requests. 361 + // 362 + // We inject a stream of Err results and track how many sleep 363 + // invocations occur. With the bug, sleep count == 0 (the 364 + // reconnect path never slept). With the fix, each failed cycle 365 + // triggers a sleep. 366 + 367 + let transport = MockSseTransport::new(); 368 + // 5 consecutive errors, then a good event to break the loop. 369 + for _ in 0..5 { 370 + transport.push_error(Error::Sse("tight-loop".into())); 371 + } 372 + transport.push_event(delete("at://recovered")); 373 + 374 + let sleep_count = Rc::new(RefCell::new(0usize)); 375 + let sleep_count_inner = Rc::clone(&sleep_count); 376 + let sleep_fn: SleepFn = Box::new(move |_d| { 377 + *sleep_count_inner.borrow_mut() += 1; 378 + Box::pin(async {}) 379 + }); 380 + 381 + let token_fn: TokenFetcher = Box::new(|| Box::pin(async { Ok("t".into()) })); 382 + let jitter_fn: JitterRng = Box::new(|| 0.5); 383 + let mut consumer = SseConsumer::new( 384 + transport, 385 + "https://example.com", 386 + token_fn, 387 + sleep_fn, 388 + jitter_fn, 389 + ); 390 + 391 + // Drain until the good event arrives. Each error cycle should 392 + // have applied at least one sleep. 393 + let event = consumer.next_event().await.unwrap(); 394 + assert!(matches!(event, SseEvent::KeyringDelete(_))); 395 + 396 + let count = *sleep_count.borrow(); 397 + assert!( 398 + count >= 5, 399 + "expected at least 5 backoff sleeps across 5 error cycles, got {count}" 400 + ); 401 + } 402 + 403 + #[tokio::test] 404 + async fn force_reconnect_triggers_reconnection() { 405 + let transport = MockSseTransport::new(); 406 + transport.push_event(delete("at://a")); 407 + transport.push_event(delete("at://b")); 408 + 409 + let mut consumer = build_consumer(transport, vec![]); 410 + 411 + let e1 = consumer.next_event().await.unwrap(); 412 + assert!(matches!(e1, SseEvent::KeyringDelete(_))); 413 + 414 + consumer.force_reconnect(); 415 + 416 + // After force_reconnect, the next event is a synthetic Reconnect 417 + // (because was_connected was set), then the next real event. 418 + let e2 = consumer.next_event().await.unwrap(); 419 + assert!(matches!(e2, SseEvent::Reconnect)); 420 + 421 + let e3 = consumer.next_event().await.unwrap(); 422 + assert!(matches!(e3, SseEvent::KeyringDelete(ref d) if d.best_uri() == Some("at://b"))); 423 + } 424 + }
+520
crates/opake-core/src/sse/events.rs
··· 1 + // SSE event types. Mirrors the payload shapes emitted by 2 + // `apps/appview/lib/opake_appview/sse/broadcaster.ex` and validated by the 3 + // Zod schemas in the shipped SDK's `packages/opake-sdk/src/event-stream.ts`. 4 + // 5 + // Fields are serde-lenient — every field that the broadcaster marks with 6 + // `maybe_put` must deserialize successfully when absent. The SDK Zod schemas 7 + // use `.nullish()` for the same fields, and this file mirrors that contract 8 + // one-for-one so asymmetric parsing failures between platforms never happen. 9 + 10 + use serde::{Deserialize, Serialize}; 11 + 12 + // --------------------------------------------------------------------------- 13 + // Record events — drive TreeKeeper::apply_record 14 + // --------------------------------------------------------------------------- 15 + 16 + /// A directory record event. Sent when the indexer commits a directory 17 + /// create/update from the firehose. 18 + #[derive(Debug, Clone, Deserialize, Serialize)] 19 + pub struct SseDirectoryRecord { 20 + pub directory_uri: String, 21 + pub owner_did: String, 22 + #[serde(default)] 23 + pub entries: Vec<String>, 24 + #[serde(default)] 25 + pub encrypted_metadata: Option<serde_json::Value>, 26 + #[serde(default)] 27 + pub key_wrapping: Option<serde_json::Value>, 28 + /// Workspace scope. Absent for personal cabinet directories. 29 + #[serde(default)] 30 + pub keyring_uri: Option<String>, 31 + #[serde(default)] 32 + pub deleted_at: Option<String>, 33 + #[serde(default)] 34 + pub indexed_at: Option<String>, 35 + } 36 + 37 + /// A document record event. 38 + #[derive(Debug, Clone, Deserialize, Serialize)] 39 + pub struct SseDocumentRecord { 40 + pub document_uri: String, 41 + pub owner_did: String, 42 + #[serde(default)] 43 + pub encrypted_metadata: Option<serde_json::Value>, 44 + #[serde(default)] 45 + pub encryption: Option<serde_json::Value>, 46 + #[serde(default)] 47 + pub blob_ref: Option<serde_json::Value>, 48 + #[serde(default)] 49 + pub keyring_uri: Option<String>, 50 + #[serde(default)] 51 + pub rotation: Option<u64>, 52 + #[serde(default)] 53 + pub deleted_at: Option<String>, 54 + #[serde(default)] 55 + pub indexed_at: Option<String>, 56 + } 57 + 58 + /// A keyring (workspace) record event. 59 + #[derive(Debug, Clone, Deserialize, Serialize)] 60 + pub struct SseKeyringRecord { 61 + pub uri: String, 62 + pub owner_did: String, 63 + #[serde(default)] 64 + pub rotation: Option<u64>, 65 + #[serde(default)] 66 + pub member_entries: Vec<serde_json::Value>, 67 + #[serde(default)] 68 + pub encrypted_metadata: Option<serde_json::Value>, 69 + #[serde(default)] 70 + pub created_at: Option<String>, 71 + #[serde(default)] 72 + pub indexed_at: Option<String>, 73 + } 74 + 75 + /// A grant record event. 76 + #[derive(Debug, Clone, Deserialize, Serialize)] 77 + pub struct SseGrantRecord { 78 + pub uri: String, 79 + pub owner_did: String, 80 + #[serde(default)] 81 + pub recipient_did: Option<String>, 82 + pub document_uri: String, 83 + #[serde(default)] 84 + pub created_at: Option<String>, 85 + } 86 + 87 + /// Delete payload — most delete events only carry a URI. Different event 88 + /// types populate different keys (`uri`, `directory_uri`, `document_uri`), 89 + /// so all three are optional and `uri()` picks whichever is present. 90 + #[derive(Debug, Clone, Deserialize, Serialize, Default)] 91 + pub struct SseDeletePayload { 92 + #[serde(default)] 93 + pub uri: Option<String>, 94 + #[serde(default)] 95 + pub directory_uri: Option<String>, 96 + #[serde(default)] 97 + pub document_uri: Option<String>, 98 + } 99 + 100 + impl SseDeletePayload { 101 + /// Return whichever URI field is populated, preferring the collection- 102 + /// specific one if both are present. 103 + pub fn best_uri(&self) -> Option<&str> { 104 + self.directory_uri 105 + .as_deref() 106 + .or(self.document_uri.as_deref()) 107 + .or(self.uri.as_deref()) 108 + } 109 + } 110 + 111 + // --------------------------------------------------------------------------- 112 + // Proposal events — drive ProposalDebouncer (NOT the tree) 113 + // --------------------------------------------------------------------------- 114 + 115 + /// A directory update proposal from a workspace member. 116 + #[derive(Debug, Clone, Deserialize, Serialize)] 117 + pub struct SseDirectoryUpdate { 118 + pub uri: String, 119 + pub author_did: String, 120 + pub action_type: String, 121 + #[serde(default)] 122 + pub keyring_uri: Option<String>, 123 + #[serde(default)] 124 + pub directory_uri: Option<String>, 125 + #[serde(default)] 126 + pub entry_uri: Option<String>, 127 + #[serde(default)] 128 + pub encrypted_metadata: Option<serde_json::Value>, 129 + #[serde(default)] 130 + pub source_directory_uri: Option<String>, 131 + #[serde(default)] 132 + pub target_directory_uri: Option<String>, 133 + #[serde(default)] 134 + pub parent_directory_uri: Option<String>, 135 + } 136 + 137 + /// A keyring update proposal. 138 + #[derive(Debug, Clone, Deserialize, Serialize)] 139 + pub struct SseKeyringUpdate { 140 + pub uri: String, 141 + pub author_did: String, 142 + pub action_type: String, 143 + #[serde(default)] 144 + pub keyring_uri: Option<String>, 145 + #[serde(default)] 146 + pub member_did: Option<String>, 147 + #[serde(default)] 148 + pub member_public_key: Option<String>, 149 + #[serde(default)] 150 + pub role: Option<String>, 151 + #[serde(default)] 152 + pub encrypted_metadata: Option<serde_json::Value>, 153 + } 154 + 155 + /// A document update proposal. 156 + /// 157 + /// Note: `keyring_uri` is nearly always absent because the 158 + /// `app.opake.documentUpdate` lexicon has no `keyring` field. The 159 + /// appview routes these to the author's personal topic, not a workspace 160 + /// topic, so the workspace owner never receives them via SSE. The CLI 161 + /// daemon's `directory-sync` polling task still picks them up, but the 162 + /// web client has no polling fallback — document proposals authored on 163 + /// other devices won't apply until either the lexicon grows a `keyring` 164 + /// field or the appview indexer joins through the documents table. 165 + /// Tracked in the post-POC cleanup sweep. 166 + #[derive(Debug, Clone, Deserialize, Serialize)] 167 + pub struct SseDocumentUpdate { 168 + pub uri: String, 169 + pub document_uri: String, 170 + pub author_did: String, 171 + #[serde(default)] 172 + pub keyring_uri: Option<String>, 173 + #[serde(default)] 174 + pub supersedes_uri: Option<String>, 175 + } 176 + 177 + // --------------------------------------------------------------------------- 178 + // Top-level event enum 179 + // --------------------------------------------------------------------------- 180 + 181 + /// All events that flow through an `SseConnection`. Parsed from the 182 + /// `event: <name>\ndata: {json}\n\n` framing by `sse::parser`. 183 + /// 184 + /// `Reconnect` is a synthetic event — the parser never produces one. The 185 + /// [`SseConsumer`](super::SseConsumer) emits it when a previously-successful 186 + /// connection recovers from an error. Subscribers treat it as "full sync the 187 + /// world because we may have missed events." 188 + #[derive(Debug, Clone)] 189 + pub enum SseEvent { 190 + // Record events — drive TreeKeeper::apply_record 191 + DirectoryUpsert(SseDirectoryRecord), 192 + DirectoryDelete(SseDeletePayload), 193 + DocumentUpsert(SseDocumentRecord), 194 + DocumentDelete(SseDeletePayload), 195 + KeyringUpsert(SseKeyringRecord), 196 + KeyringDelete(SseDeletePayload), 197 + GrantUpsert(SseGrantRecord), 198 + GrantDelete(SseDeletePayload), 199 + 200 + // Proposal events — drive ProposalDebouncer, NOT the tree. A proposal 201 + // is a pending-but-not-applied change. Patching the tree with one would 202 + // show unapplied proposals as if they were live, then "jump" when the 203 + // owner applies them seconds later. 204 + DirectoryUpdateUpsert(SseDirectoryUpdate), 205 + DirectoryUpdateDelete(SseDeletePayload), 206 + KeyringUpdateUpsert(SseKeyringUpdate), 207 + KeyringUpdateDelete(SseDeletePayload), 208 + DocumentUpdateUpsert(SseDocumentUpdate), 209 + DocumentUpdateDelete(SseDeletePayload), 210 + 211 + // Synthetic 212 + Reconnect, 213 + } 214 + 215 + impl SseEvent { 216 + /// The event type string as emitted by the broadcaster. Used for 217 + /// dispatch and for test assertions. 218 + pub fn event_name(&self) -> &'static str { 219 + match self { 220 + Self::DirectoryUpsert(_) => "directory:upsert", 221 + Self::DirectoryDelete(_) => "directory:delete", 222 + Self::DocumentUpsert(_) => "document:upsert", 223 + Self::DocumentDelete(_) => "document:delete", 224 + Self::KeyringUpsert(_) => "keyring:upsert", 225 + Self::KeyringDelete(_) => "keyring:delete", 226 + Self::GrantUpsert(_) => "grant:upsert", 227 + Self::GrantDelete(_) => "grant:delete", 228 + Self::DirectoryUpdateUpsert(_) => "directory_update:upsert", 229 + Self::DirectoryUpdateDelete(_) => "directory_update:delete", 230 + Self::KeyringUpdateUpsert(_) => "keyring_update:upsert", 231 + Self::KeyringUpdateDelete(_) => "keyring_update:delete", 232 + Self::DocumentUpdateUpsert(_) => "document_update:upsert", 233 + Self::DocumentUpdateDelete(_) => "document_update:delete", 234 + Self::Reconnect => "__reconnect__", 235 + } 236 + } 237 + 238 + /// Parse an event from its name + JSON data payload. Used by the parser 239 + /// after it has extracted the `event: name` and `data: { ... }` fields 240 + /// from the SSE frame. 241 + pub fn from_name_and_data(name: &str, data: &[u8]) -> Result<Self, crate::error::Error> { 242 + let parse = |tag: &str| -> Result<serde_json::Value, crate::error::Error> { 243 + serde_json::from_slice(data).map_err(|e| { 244 + crate::error::Error::Sse(format!("failed to parse {tag} payload: {e}")) 245 + }) 246 + }; 247 + 248 + // Helper to reduce boilerplate: parse JSON → strongly-typed variant. 249 + fn decode<T: serde::de::DeserializeOwned>( 250 + data: &[u8], 251 + tag: &str, 252 + ) -> Result<T, crate::error::Error> { 253 + serde_json::from_slice(data).map_err(|e| { 254 + crate::error::Error::Sse(format!("failed to parse {tag} payload: {e}")) 255 + }) 256 + } 257 + 258 + let event = match name { 259 + "directory:upsert" => Self::DirectoryUpsert(decode(data, "directory:upsert")?), 260 + "directory:delete" => Self::DirectoryDelete(decode(data, "directory:delete")?), 261 + "document:upsert" => Self::DocumentUpsert(decode(data, "document:upsert")?), 262 + "document:delete" => Self::DocumentDelete(decode(data, "document:delete")?), 263 + "keyring:upsert" => Self::KeyringUpsert(decode(data, "keyring:upsert")?), 264 + "keyring:delete" => Self::KeyringDelete(decode(data, "keyring:delete")?), 265 + "grant:upsert" => Self::GrantUpsert(decode(data, "grant:upsert")?), 266 + "grant:delete" => Self::GrantDelete(decode(data, "grant:delete")?), 267 + "directory_update:upsert" => { 268 + Self::DirectoryUpdateUpsert(decode(data, "directory_update:upsert")?) 269 + } 270 + "directory_update:delete" => { 271 + Self::DirectoryUpdateDelete(decode(data, "directory_update:delete")?) 272 + } 273 + "keyring_update:upsert" => { 274 + Self::KeyringUpdateUpsert(decode(data, "keyring_update:upsert")?) 275 + } 276 + "keyring_update:delete" => { 277 + Self::KeyringUpdateDelete(decode(data, "keyring_update:delete")?) 278 + } 279 + "document_update:upsert" => { 280 + Self::DocumentUpdateUpsert(decode(data, "document_update:upsert")?) 281 + } 282 + "document_update:delete" => { 283 + Self::DocumentUpdateDelete(decode(data, "document_update:delete")?) 284 + } 285 + other => { 286 + // Silent drop — the appview may add event types we don't 287 + // understand yet, and forward-compat beats hard-failure. 288 + log::debug!("[sse] ignoring unknown event type: {other}"); 289 + let _ = parse; // satisfy unused-binding when all variants 290 + // above use `decode` directly 291 + return Err(crate::error::Error::Sse(format!( 292 + "unknown event type: {other}" 293 + ))); 294 + } 295 + }; 296 + 297 + Ok(event) 298 + } 299 + 300 + /// True if this is a proposal event (drives the debouncer, not the tree). 301 + pub fn is_proposal(&self) -> bool { 302 + matches!( 303 + self, 304 + Self::DirectoryUpdateUpsert(_) 305 + | Self::DirectoryUpdateDelete(_) 306 + | Self::KeyringUpdateUpsert(_) 307 + | Self::KeyringUpdateDelete(_) 308 + | Self::DocumentUpdateUpsert(_) 309 + | Self::DocumentUpdateDelete(_) 310 + ) 311 + } 312 + 313 + /// The workspace keyring URI this event affects, if any. 314 + /// 315 + /// Record events carry `keyring_uri` natively (for workspace-scoped 316 + /// writes). Proposal upserts carry it when the lexicon includes a 317 + /// `keyring` field — `directoryUpdate` and `keyringUpdate` do, 318 + /// `documentUpdate` does not, so those always return None and the 319 + /// caller must decide how to route them. Delete payloads and 320 + /// control events never carry one. 321 + pub fn keyring_uri(&self) -> Option<&str> { 322 + match self { 323 + Self::DirectoryUpsert(r) => r.keyring_uri.as_deref(), 324 + Self::DocumentUpsert(r) => r.keyring_uri.as_deref(), 325 + Self::KeyringUpsert(r) => Some(r.uri.as_str()), 326 + Self::DirectoryUpdateUpsert(p) => p.keyring_uri.as_deref(), 327 + Self::KeyringUpdateUpsert(p) => p.keyring_uri.as_deref(), 328 + Self::DocumentUpdateUpsert(p) => p.keyring_uri.as_deref(), 329 + Self::DirectoryDelete(_) 330 + | Self::DocumentDelete(_) 331 + | Self::KeyringDelete(_) 332 + | Self::GrantUpsert(_) 333 + | Self::GrantDelete(_) 334 + | Self::DirectoryUpdateDelete(_) 335 + | Self::KeyringUpdateDelete(_) 336 + | Self::DocumentUpdateDelete(_) 337 + | Self::Reconnect => None, 338 + } 339 + } 340 + } 341 + 342 + // --------------------------------------------------------------------------- 343 + // Tests 344 + // --------------------------------------------------------------------------- 345 + 346 + #[cfg(test)] 347 + mod tests { 348 + use super::*; 349 + 350 + #[test] 351 + fn decodes_directory_upsert_with_all_fields() { 352 + let json = br#"{ 353 + "directory_uri": "at://did:plc:alice/app.opake.directory/abc", 354 + "owner_did": "did:plc:alice", 355 + "entries": ["at://did:plc:alice/app.opake.document/xyz"], 356 + "encrypted_metadata": {"ciphertext": "...", "nonce": "..."}, 357 + "key_wrapping": {"type": "direct"}, 358 + "keyring_uri": "at://did:plc:alice/app.opake.keyring/kr1", 359 + "deleted_at": null, 360 + "indexed_at": "2026-04-11T12:00:00Z" 361 + }"#; 362 + let event = SseEvent::from_name_and_data("directory:upsert", json).unwrap(); 363 + match event { 364 + SseEvent::DirectoryUpsert(d) => { 365 + assert_eq!( 366 + d.directory_uri, 367 + "at://did:plc:alice/app.opake.directory/abc" 368 + ); 369 + assert_eq!(d.entries.len(), 1); 370 + assert_eq!( 371 + d.keyring_uri.as_deref(), 372 + Some("at://did:plc:alice/app.opake.keyring/kr1") 373 + ); 374 + assert_eq!(d.indexed_at.as_deref(), Some("2026-04-11T12:00:00Z")); 375 + } 376 + _ => panic!("expected DirectoryUpsert"), 377 + } 378 + } 379 + 380 + #[test] 381 + fn decodes_directory_upsert_with_absent_optional_fields() { 382 + // Cabinet directory — no keyring_uri, no indexed_at (broadcaster omits both). 383 + let json = br#"{ 384 + "directory_uri": "at://did:plc:alice/app.opake.directory/abc", 385 + "owner_did": "did:plc:alice", 386 + "entries": [] 387 + }"#; 388 + let event = SseEvent::from_name_and_data("directory:upsert", json).unwrap(); 389 + match event { 390 + SseEvent::DirectoryUpsert(d) => { 391 + assert_eq!(d.keyring_uri, None); 392 + assert_eq!(d.indexed_at, None); 393 + assert!(d.entries.is_empty()); 394 + } 395 + _ => panic!("expected DirectoryUpsert"), 396 + } 397 + } 398 + 399 + #[test] 400 + fn decodes_delete_with_only_uri() { 401 + let json = br#"{"uri": "at://did:plc:alice/app.opake.keyring/kr1"}"#; 402 + let event = SseEvent::from_name_and_data("keyring:delete", json).unwrap(); 403 + match event { 404 + SseEvent::KeyringDelete(d) => { 405 + assert_eq!( 406 + d.best_uri(), 407 + Some("at://did:plc:alice/app.opake.keyring/kr1") 408 + ); 409 + } 410 + _ => panic!("expected KeyringDelete"), 411 + } 412 + } 413 + 414 + #[test] 415 + fn decodes_directory_delete_with_specific_key() { 416 + let json = br#"{"directory_uri": "at://did:plc:alice/app.opake.directory/abc"}"#; 417 + let event = SseEvent::from_name_and_data("directory:delete", json).unwrap(); 418 + match event { 419 + SseEvent::DirectoryDelete(d) => { 420 + assert_eq!( 421 + d.best_uri(), 422 + Some("at://did:plc:alice/app.opake.directory/abc") 423 + ); 424 + } 425 + _ => panic!("expected DirectoryDelete"), 426 + } 427 + } 428 + 429 + #[test] 430 + fn decodes_keyring_upsert_with_members() { 431 + let json = br#"{ 432 + "uri": "at://did:plc:alice/app.opake.keyring/kr1", 433 + "owner_did": "did:plc:alice", 434 + "rotation": 3, 435 + "member_entries": [ 436 + {"did": "did:plc:bob", "wrappedKey": "..."} 437 + ] 438 + }"#; 439 + let event = SseEvent::from_name_and_data("keyring:upsert", json).unwrap(); 440 + match event { 441 + SseEvent::KeyringUpsert(k) => { 442 + assert_eq!(k.rotation, Some(3)); 443 + assert_eq!(k.member_entries.len(), 1); 444 + } 445 + _ => panic!("expected KeyringUpsert"), 446 + } 447 + } 448 + 449 + #[test] 450 + fn decodes_directory_update_proposal() { 451 + let json = br#"{ 452 + "uri": "at://did:plc:alice/app.opake.directoryUpdate/prop1", 453 + "author_did": "did:plc:bob", 454 + "action_type": "addEntry", 455 + "keyring_uri": "at://did:plc:alice/app.opake.keyring/kr1", 456 + "directory_uri": "at://did:plc:alice/app.opake.directory/abc", 457 + "entry_uri": "at://did:plc:bob/app.opake.document/xyz" 458 + }"#; 459 + let event = SseEvent::from_name_and_data("directory_update:upsert", json).unwrap(); 460 + assert!(event.is_proposal()); 461 + match event { 462 + SseEvent::DirectoryUpdateUpsert(p) => { 463 + assert_eq!(p.action_type, "addEntry"); 464 + assert_eq!( 465 + p.keyring_uri.as_deref(), 466 + Some("at://did:plc:alice/app.opake.keyring/kr1") 467 + ); 468 + } 469 + _ => panic!("expected DirectoryUpdateUpsert"), 470 + } 471 + } 472 + 473 + #[test] 474 + fn decodes_document_update_without_keyring_uri() { 475 + // document_update has no `keyring` field in the lexicon. 476 + let json = br#"{ 477 + "uri": "at://did:plc:bob/app.opake.documentUpdate/upd1", 478 + "document_uri": "at://did:plc:alice/app.opake.document/doc1", 479 + "author_did": "did:plc:bob", 480 + "supersedes_uri": null 481 + }"#; 482 + let event = SseEvent::from_name_and_data("document_update:upsert", json).unwrap(); 483 + assert!(event.is_proposal()); 484 + match event { 485 + SseEvent::DocumentUpdateUpsert(p) => { 486 + assert_eq!(p.keyring_uri, None); 487 + assert_eq!(p.supersedes_uri, None); 488 + } 489 + _ => panic!("expected DocumentUpdateUpsert"), 490 + } 491 + } 492 + 493 + #[test] 494 + fn record_events_are_not_proposals() { 495 + let json = br#"{"directory_uri":"a","owner_did":"b","entries":[]}"#; 496 + let event = SseEvent::from_name_and_data("directory:upsert", json).unwrap(); 497 + assert!(!event.is_proposal()); 498 + } 499 + 500 + #[test] 501 + fn unknown_event_type_errors() { 502 + let json = br#"{}"#; 503 + let err = SseEvent::from_name_and_data("weather:sunny", json).unwrap_err(); 504 + assert!(matches!(err, crate::error::Error::Sse(_))); 505 + } 506 + 507 + #[test] 508 + fn event_name_roundtrips() { 509 + let json = br#"{"uri":"a","owner_did":"b","document_uri":"c"}"#; 510 + let event = SseEvent::from_name_and_data("grant:upsert", json).unwrap(); 511 + assert_eq!(event.event_name(), "grant:upsert"); 512 + } 513 + 514 + #[test] 515 + fn reconnect_synthetic_name() { 516 + let event = SseEvent::Reconnect; 517 + assert_eq!(event.event_name(), "__reconnect__"); 518 + assert!(!event.is_proposal()); 519 + } 520 + }
+218
crates/opake-core/src/sse/mock.rs
··· 1 + // Mock SSE connection + transport for unit and integration tests. 2 + // 3 + // Feeds canned events into a consumer on demand. The `push_event` method 4 + // lets tests interleave event delivery with other async work (e.g., drive 5 + // a TreeKeeper to apply one event, assert state, then push the next). 6 + // 7 + // This lives under `#[cfg(any(test, feature = "test-utils"))]` so the 8 + // integration tests in `crates/opake-core/tests/` can construct it without 9 + // touching opake-core internals. 10 + 11 + use crate::error::Error; 12 + use crate::sse::events::SseEvent; 13 + use crate::sse::transport::{SseConnection, SseTransport}; 14 + use std::cell::RefCell; 15 + use std::collections::VecDeque; 16 + use std::rc::Rc; 17 + 18 + /// Shared inbox — multiple `MockSseConnection` clones observe the same queue. 19 + #[derive(Debug, Default)] 20 + pub struct MockInbox { 21 + /// Events waiting to be consumed. 22 + pending: VecDeque<Result<Option<SseEvent>, Error>>, 23 + } 24 + 25 + impl MockInbox { 26 + /// Push a successful event for the next `next_event` call. 27 + pub fn push_event(&mut self, event: SseEvent) { 28 + self.pending.push_back(Ok(Some(event))); 29 + } 30 + 31 + /// Push an error to simulate a transport failure. The consumer will 32 + /// treat this as "disconnect, reconnect now." 33 + pub fn push_error(&mut self, err: Error) { 34 + self.pending.push_back(Err(err)); 35 + } 36 + 37 + /// Push a clean EOF. The consumer treats this identically to an error 38 + /// for reconnect purposes. 39 + pub fn push_eof(&mut self) { 40 + self.pending.push_back(Ok(None)); 41 + } 42 + 43 + /// Number of events currently queued. 44 + pub fn len(&self) -> usize { 45 + self.pending.len() 46 + } 47 + 48 + /// True if the queue is empty. 49 + pub fn is_empty(&self) -> bool { 50 + self.pending.is_empty() 51 + } 52 + } 53 + 54 + /// Mock SSE transport. Clone-cheap — all clones share the same inbox. 55 + #[derive(Debug, Clone, Default)] 56 + pub struct MockSseTransport { 57 + inbox: Rc<RefCell<MockInbox>>, 58 + /// If set, `connect` returns this error instead of a connection. 59 + /// Used to test initial-connection-failure paths. 60 + connect_error: Rc<RefCell<Option<Error>>>, 61 + } 62 + 63 + impl MockSseTransport { 64 + pub fn new() -> Self { 65 + Self::default() 66 + } 67 + 68 + /// Push an event into the inbox. All subsequent `next_event` calls on 69 + /// connections created from this transport will drain from the same 70 + /// inbox. 71 + pub fn push_event(&self, event: SseEvent) { 72 + self.inbox.borrow_mut().push_event(event); 73 + } 74 + 75 + pub fn push_error(&self, err: Error) { 76 + self.inbox.borrow_mut().push_error(err); 77 + } 78 + 79 + pub fn push_eof(&self) { 80 + self.inbox.borrow_mut().push_eof(); 81 + } 82 + 83 + /// Make the next `connect` call fail with the given error. One-shot. 84 + pub fn fail_next_connect(&self, err: Error) { 85 + *self.connect_error.borrow_mut() = Some(err); 86 + } 87 + 88 + pub fn inbox_len(&self) -> usize { 89 + self.inbox.borrow().len() 90 + } 91 + } 92 + 93 + impl SseTransport for MockSseTransport { 94 + type Connection = MockSseConnection; 95 + 96 + async fn connect(&self, _appview_url: &str, _token: String) -> Result<Self::Connection, Error> { 97 + if let Some(err) = self.connect_error.borrow_mut().take() { 98 + return Err(err); 99 + } 100 + Ok(MockSseConnection { 101 + inbox: Rc::clone(&self.inbox), 102 + }) 103 + } 104 + } 105 + 106 + /// A connection view into the mock inbox. 107 + #[derive(Debug)] 108 + pub struct MockSseConnection { 109 + inbox: Rc<RefCell<MockInbox>>, 110 + } 111 + 112 + impl SseConnection for MockSseConnection { 113 + async fn next_event(&mut self) -> Result<Option<SseEvent>, Error> { 114 + // Non-blocking: if the inbox is empty, synthesize a clean EOF. 115 + // Tests that want to assert "consumer is waiting" should push an 116 + // explicit event before awaiting the consumer loop. 117 + match self.inbox.borrow_mut().pending.pop_front() { 118 + Some(result) => result, 119 + None => Ok(None), 120 + } 121 + } 122 + } 123 + 124 + // --------------------------------------------------------------------------- 125 + // Tests 126 + // --------------------------------------------------------------------------- 127 + 128 + #[cfg(test)] 129 + mod tests { 130 + use super::*; 131 + use crate::sse::events::SseDeletePayload; 132 + 133 + fn delete_event(uri: &str) -> SseEvent { 134 + SseEvent::KeyringDelete(SseDeletePayload { 135 + uri: Some(uri.into()), 136 + directory_uri: None, 137 + document_uri: None, 138 + }) 139 + } 140 + 141 + #[tokio::test] 142 + async fn inbox_delivers_events_in_order() { 143 + let transport = MockSseTransport::new(); 144 + transport.push_event(delete_event("at://a")); 145 + transport.push_event(delete_event("at://b")); 146 + 147 + let mut conn = transport 148 + .connect("https://example.com", "token".into()) 149 + .await 150 + .unwrap(); 151 + 152 + let first = conn.next_event().await.unwrap().unwrap(); 153 + assert!(matches!( 154 + first, 155 + SseEvent::KeyringDelete(ref d) if d.best_uri() == Some("at://a") 156 + )); 157 + 158 + let second = conn.next_event().await.unwrap().unwrap(); 159 + assert!(matches!( 160 + second, 161 + SseEvent::KeyringDelete(ref d) if d.best_uri() == Some("at://b") 162 + )); 163 + } 164 + 165 + #[tokio::test] 166 + async fn empty_inbox_returns_none() { 167 + let transport = MockSseTransport::new(); 168 + let mut conn = transport 169 + .connect("https://example.com", "token".into()) 170 + .await 171 + .unwrap(); 172 + let result = conn.next_event().await.unwrap(); 173 + assert!(result.is_none()); 174 + } 175 + 176 + #[tokio::test] 177 + async fn push_error_surfaces_from_next_event() { 178 + let transport = MockSseTransport::new(); 179 + transport.push_error(Error::Sse("simulated disconnect".into())); 180 + 181 + let mut conn = transport 182 + .connect("https://example.com", "token".into()) 183 + .await 184 + .unwrap(); 185 + let err = conn.next_event().await.unwrap_err(); 186 + assert!(matches!(err, Error::Sse(_))); 187 + } 188 + 189 + #[tokio::test] 190 + async fn fail_next_connect_is_one_shot() { 191 + let transport = MockSseTransport::new(); 192 + transport.fail_next_connect(Error::Sse("nope".into())); 193 + 194 + let first = transport.connect("https://example.com", "t".into()).await; 195 + assert!(first.is_err()); 196 + 197 + let second = transport.connect("https://example.com", "t".into()).await; 198 + assert!(second.is_ok()); 199 + } 200 + 201 + #[tokio::test] 202 + async fn cloned_transport_shares_inbox() { 203 + let transport = MockSseTransport::new(); 204 + let clone = transport.clone(); 205 + transport.push_event(delete_event("at://shared")); 206 + 207 + // Connect via the clone, but the event was pushed via the original. 208 + let mut conn = clone 209 + .connect("https://example.com", "t".into()) 210 + .await 211 + .unwrap(); 212 + let evt = conn.next_event().await.unwrap().unwrap(); 213 + assert!(matches!( 214 + evt, 215 + SseEvent::KeyringDelete(ref d) if d.best_uri() == Some("at://shared") 216 + )); 217 + } 218 + }
+49
crates/opake-core/src/sse/mod.rs
··· 1 + //! Server-Sent Events consumer infrastructure. 2 + //! 3 + //! This module provides the building blocks for consuming the appview's 4 + //! `/api/events` SSE stream from both WASM (browser `EventSource`) and 5 + //! native (tokio + `reqwest::Response::bytes_stream()`) targets. The 6 + //! design mirrors the existing [`crate::client::Transport`] trait style: 7 + //! request/response shapes live in core types, platform-specific I/O 8 + //! lives behind feature-gated implementations. 9 + //! 10 + //! ## Layering 11 + //! 12 + //! - [`events`] — typed `SseEvent` enum matching the broadcaster's payload 13 + //! shapes with serde-lenient deserialization. 14 + //! - [`parser`] — SSE line-framing parser used by native connections and 15 + //! all tests. WASM `EventSource` handles framing natively. 16 + //! - [`transport`] — `SseTransport` + `SseConnection` traits (polling style, 17 + //! no `Stream`, no `async_trait`, no `Send` bound). 18 + //! - [`mock`] — test-only `MockSseTransport` with a push-driven inbox. 19 + //! - `wasm_connection` — `EventSource` wrapper (requires `wasm-transport`). 20 + //! - `reqwest_connection` — `bytes_stream` wrapper (requires 21 + //! `reqwest-transport`). 22 + //! - [`reconnect`] — exponential backoff + jitter helper. 23 + //! - `consumer` (at module root) — the outer loop that drives a transport, 24 + //! refreshes tokens, emits synthetic Reconnect events. 25 + //! 26 + //! Neither `TreeKeeper` nor the daemon's `ProposalDebouncer` lives here — 27 + //! they're consumers of this infrastructure, not part of it. 28 + 29 + pub mod consumer; 30 + pub mod events; 31 + pub mod parser; 32 + pub mod reconnect; 33 + pub mod transport; 34 + 35 + #[cfg(any(test, feature = "test-utils"))] 36 + pub mod mock; 37 + 38 + #[cfg(all(feature = "wasm-transport", target_arch = "wasm32"))] 39 + pub mod wasm_connection; 40 + 41 + #[cfg(feature = "reqwest-transport")] 42 + pub mod reqwest_connection; 43 + 44 + pub use consumer::{JitterRng, SleepFn, SseConsumer, TokenFetcher}; 45 + pub use events::{ 46 + SseDeletePayload, SseDirectoryRecord, SseDirectoryUpdate, SseDocumentRecord, SseDocumentUpdate, 47 + SseEvent, SseGrantRecord, SseKeyringRecord, SseKeyringUpdate, 48 + }; 49 + pub use transport::{SseConnection, SseTransport};
+339
crates/opake-core/src/sse/parser.rs
··· 1 + // SSE line-framing parser. 2 + // 3 + // Server-Sent Events frames each event as a series of lines terminated by 4 + // a double newline: 5 + // 6 + // event: directory:upsert 7 + // data: {"directory_uri":"at://...","owner_did":"..."} 8 + // \n 9 + // 10 + // Lines beginning with `:` are comments (used by the broadcaster for 11 + // `: keepalive\n\n` heartbeats, which we silently drop). Multi-line 12 + // data is concatenated with newlines between chunks. Unrecognized fields 13 + // (`id:`, `retry:`) are parsed but currently ignored — we don't use 14 + // Last-Event-ID replay or server-suggested reconnect delays. 15 + // 16 + // This parser is used by both the native (reqwest) and WASM (EventSource) 17 + // SSE connections. On WASM, EventSource handles framing natively in the 18 + // browser and we only use this parser for testing. On native, we drive it 19 + // over bytes from `reqwest::Response::bytes_stream()`. 20 + 21 + use crate::error::Error; 22 + use crate::sse::events::SseEvent; 23 + 24 + /// Accumulates SSE field lines into complete events. Call [`feed_line`] 25 + /// for each `\n`-stripped line; it returns `Some(event)` when a blank line 26 + /// indicates a complete frame. 27 + #[derive(Debug, Default)] 28 + pub struct SseFrameBuffer { 29 + /// Event type (`event:` field). Defaults to `"message"` per the spec 30 + /// but we treat missing names as a parse error since the broadcaster 31 + /// always sets an explicit type. 32 + event: Option<String>, 33 + /// Accumulated `data:` payload. Multi-line data is newline-joined. 34 + data: Vec<u8>, 35 + } 36 + 37 + impl SseFrameBuffer { 38 + pub fn new() -> Self { 39 + Self::default() 40 + } 41 + 42 + /// Feed one line (without the trailing `\n`) to the parser. A blank 43 + /// line signals the end of a frame and causes the buffer to emit an 44 + /// event (or `None` if the frame was empty / comment-only). 45 + /// 46 + /// Returns `None` for in-progress frames and comments. On a blank line, 47 + /// returns: 48 + /// - `Some(Ok(event))` for a complete parseable frame 49 + /// - `Some(Err(_))` for a frame with malformed JSON data 50 + /// 51 + /// After a frame is emitted (or errors), the buffer is reset and ready 52 + /// for the next frame. 53 + pub fn feed_line(&mut self, line: &[u8]) -> Option<Result<SseEvent, Error>> { 54 + // Blank line = frame terminator. 55 + if line.is_empty() { 56 + return self.flush_frame(); 57 + } 58 + 59 + // Comments start with `:`. The broadcaster uses `: keepalive\n\n` 60 + // every 15s. Drop them silently. 61 + if line.starts_with(b":") { 62 + return None; 63 + } 64 + 65 + // Field lines are `field: value` or `field:value`. The space after 66 + // the colon is optional per the spec. 67 + let (field, value) = match line.iter().position(|&b| b == b':') { 68 + Some(i) => { 69 + let field = &line[..i]; 70 + let mut value_start = i + 1; 71 + if line.get(value_start) == Some(&b' ') { 72 + value_start += 1; 73 + } 74 + (field, &line[value_start..]) 75 + } 76 + None => { 77 + // A line with no colon is treated as a field name with an 78 + // empty value per the spec. The broadcaster doesn't emit 79 + // these, but we tolerate them. 80 + (line, &b""[..]) 81 + } 82 + }; 83 + 84 + match field { 85 + b"event" => { 86 + self.event = Some(String::from_utf8_lossy(value).into_owned()); 87 + } 88 + b"data" => { 89 + // Multi-line data: newline-join successive chunks. 90 + if !self.data.is_empty() { 91 + self.data.push(b'\n'); 92 + } 93 + self.data.extend_from_slice(value); 94 + } 95 + b"id" | b"retry" => { 96 + // Not used — we don't do Last-Event-ID replay or honor 97 + // server-suggested reconnect intervals. 98 + } 99 + _ => { 100 + // Unknown field — skip per spec. 101 + } 102 + } 103 + 104 + None 105 + } 106 + 107 + /// Flush the accumulated frame state as an event, resetting the buffer. 108 + fn flush_frame(&mut self) -> Option<Result<SseEvent, Error>> { 109 + // Empty frame (just a blank line after nothing) = comment-only or 110 + // start-of-stream; yield nothing. 111 + if self.event.is_none() && self.data.is_empty() { 112 + return None; 113 + } 114 + 115 + let event_name = self.event.take(); 116 + let data = std::mem::take(&mut self.data); 117 + 118 + match event_name { 119 + Some(name) => Some(SseEvent::from_name_and_data(&name, &data)), 120 + None => { 121 + // Data without an event type — the spec says this defaults 122 + // to the "message" type, but our broadcaster never emits 123 + // such frames. Treat as parse error for visibility. 124 + Some(Err(Error::Sse("SSE frame missing event: header".into()))) 125 + } 126 + } 127 + } 128 + } 129 + 130 + // --------------------------------------------------------------------------- 131 + // Byte stream helper — splits a chunk of bytes into line-terminated frames. 132 + // --------------------------------------------------------------------------- 133 + 134 + /// A running line buffer for feeding arbitrary byte chunks into the frame 135 + /// parser. Used by the native `reqwest` connection, where each poll of 136 + /// `bytes_stream()` yields an arbitrary chunk that may split mid-line. 137 + #[derive(Debug, Default)] 138 + pub struct SseLineAccumulator { 139 + frame: SseFrameBuffer, 140 + /// Partial line carried across chunks. 141 + carry: Vec<u8>, 142 + } 143 + 144 + impl SseLineAccumulator { 145 + pub fn new() -> Self { 146 + Self::default() 147 + } 148 + 149 + /// Feed a chunk of bytes. Emits any events that became complete as a 150 + /// result of this chunk. Partial lines are buffered until the next 151 + /// call delivers their terminator. 152 + /// 153 + /// Accepts both `\n` and `\r\n` line endings (the broadcaster uses `\n` 154 + /// but we normalize for robustness against reverse proxies). 155 + pub fn feed_bytes(&mut self, chunk: &[u8]) -> Vec<Result<SseEvent, Error>> { 156 + let mut events = Vec::new(); 157 + // eslint wouldn't approve of this — prepend carry, walk the buffer 158 + // looking for line terminators. The alternative (slicing with split) 159 + // allocates per-chunk and is measurably slower in the hot path. 160 + let mut merged: Vec<u8> = Vec::with_capacity(self.carry.len() + chunk.len()); 161 + merged.append(&mut self.carry); 162 + merged.extend_from_slice(chunk); 163 + 164 + let mut start = 0; 165 + let mut i = 0; 166 + while i < merged.len() { 167 + if merged[i] == b'\n' { 168 + // Strip optional preceding \r. 169 + let line_end = if i > 0 && merged[i - 1] == b'\r' { 170 + i - 1 171 + } else { 172 + i 173 + }; 174 + let line = &merged[start..line_end]; 175 + if let Some(result) = self.frame.feed_line(line) { 176 + events.push(result); 177 + } 178 + start = i + 1; 179 + } 180 + i += 1; 181 + } 182 + 183 + // Carry the unterminated trailing bytes. 184 + if start < merged.len() { 185 + self.carry = merged[start..].to_vec(); 186 + } 187 + 188 + events 189 + } 190 + } 191 + 192 + // --------------------------------------------------------------------------- 193 + // Tests 194 + // --------------------------------------------------------------------------- 195 + 196 + #[cfg(test)] 197 + mod tests { 198 + use super::*; 199 + 200 + fn feed_string(acc: &mut SseLineAccumulator, s: &str) -> Vec<Result<SseEvent, Error>> { 201 + acc.feed_bytes(s.as_bytes()) 202 + } 203 + 204 + #[test] 205 + fn parses_single_frame() { 206 + let mut acc = SseLineAccumulator::new(); 207 + let data = "event: directory:delete\ndata: {\"uri\":\"at://a/b/c\"}\n\n"; 208 + let events = feed_string(&mut acc, data); 209 + assert_eq!(events.len(), 1); 210 + assert!(events[0].is_ok()); 211 + assert_eq!(events[0].as_ref().unwrap().event_name(), "directory:delete"); 212 + } 213 + 214 + #[test] 215 + fn drops_keepalive_comments() { 216 + let mut acc = SseLineAccumulator::new(); 217 + let events = feed_string(&mut acc, ": keepalive\n\n"); 218 + assert_eq!(events.len(), 0); 219 + } 220 + 221 + #[test] 222 + fn handles_split_chunks() { 223 + let mut acc = SseLineAccumulator::new(); 224 + // Split an event across three feeds, including mid-data and 225 + // mid-terminator. 226 + assert_eq!(feed_string(&mut acc, "event: directory:").len(), 0); 227 + assert_eq!(feed_string(&mut acc, "delete\ndata: {\"uri").len(), 0); 228 + let events = feed_string(&mut acc, "\":\"at://x\"}\n\n"); 229 + assert_eq!(events.len(), 1); 230 + match events[0].as_ref().unwrap() { 231 + SseEvent::DirectoryDelete(d) => { 232 + assert_eq!(d.best_uri(), Some("at://x")); 233 + } 234 + _ => panic!("expected DirectoryDelete"), 235 + } 236 + } 237 + 238 + #[test] 239 + fn handles_crlf_line_endings() { 240 + let mut acc = SseLineAccumulator::new(); 241 + let events = feed_string( 242 + &mut acc, 243 + "event: keyring:delete\r\ndata: {\"uri\":\"at://kr\"}\r\n\r\n", 244 + ); 245 + assert_eq!(events.len(), 1); 246 + assert!(events[0].is_ok()); 247 + } 248 + 249 + #[test] 250 + fn parses_multiple_frames_in_one_chunk() { 251 + let mut acc = SseLineAccumulator::new(); 252 + let data = concat!( 253 + "event: grant:delete\n", 254 + "data: {\"uri\":\"at://g1\"}\n", 255 + "\n", 256 + "event: grant:delete\n", 257 + "data: {\"uri\":\"at://g2\"}\n", 258 + "\n", 259 + ); 260 + let events = feed_string(&mut acc, data); 261 + assert_eq!(events.len(), 2); 262 + assert!(events[0].is_ok()); 263 + assert!(events[1].is_ok()); 264 + } 265 + 266 + #[test] 267 + fn multi_line_data_is_newline_joined() { 268 + // Split a JSON string literal across two `data:` lines. JSON 269 + // strings can't contain raw newlines, so newline-joining must 270 + // produce invalid JSON — confirming the parser joins multi-line 271 + // data with a newline per the SSE spec. 272 + let mut buf = SseFrameBuffer::new(); 273 + assert!(buf.feed_line(b"event: keyring:delete").is_none()); 274 + assert!(buf.feed_line(b"data: {\"uri\": \"at://").is_none()); 275 + assert!(buf.feed_line(b"data: a\"}").is_none()); 276 + let result = buf.feed_line(b"").unwrap(); 277 + // The combined data is `{"uri": "at://\na"}` — the raw newline 278 + // inside the string literal is not allowed in JSON. 279 + assert!( 280 + result.is_err(), 281 + "expected parse error from newline-joined JSON string, got {:?}", 282 + result 283 + ); 284 + } 285 + 286 + #[test] 287 + fn multi_line_data_with_whitespace_safe_join_succeeds() { 288 + // Sanity check: splitting between JSON tokens (where newline is 289 + // whitespace) parses fine. 290 + let mut buf = SseFrameBuffer::new(); 291 + assert!(buf.feed_line(b"event: keyring:delete").is_none()); 292 + assert!(buf.feed_line(b"data: {\"uri\":").is_none()); 293 + assert!(buf.feed_line(b"data: \"at://a\"}").is_none()); 294 + let result = buf.feed_line(b"").unwrap(); 295 + assert!(result.is_ok()); 296 + } 297 + 298 + #[test] 299 + fn ignores_id_and_retry_fields() { 300 + let mut acc = SseLineAccumulator::new(); 301 + let data = concat!( 302 + "id: 42\n", 303 + "retry: 5000\n", 304 + "event: directory:delete\n", 305 + "data: {\"uri\":\"at://x\"}\n", 306 + "\n", 307 + ); 308 + let events = feed_string(&mut acc, data); 309 + assert_eq!(events.len(), 1); 310 + assert!(events[0].is_ok()); 311 + } 312 + 313 + #[test] 314 + fn tolerates_optional_space_after_colon() { 315 + // "data:X" is legal per the spec (space optional). 316 + let mut acc = SseLineAccumulator::new(); 317 + let events = feed_string( 318 + &mut acc, 319 + "event:directory:delete\ndata:{\"uri\":\"at://y\"}\n\n", 320 + ); 321 + assert_eq!(events.len(), 1); 322 + assert!(events[0].is_ok()); 323 + } 324 + 325 + #[test] 326 + fn empty_frame_between_events_is_noop() { 327 + let mut acc = SseLineAccumulator::new(); 328 + let events = feed_string(&mut acc, "\n\n\n"); 329 + assert_eq!(events.len(), 0); 330 + } 331 + 332 + #[test] 333 + fn frame_with_data_but_no_event_errors() { 334 + let mut acc = SseLineAccumulator::new(); 335 + let events = feed_string(&mut acc, "data: {\"uri\":\"at://x\"}\n\n"); 336 + assert_eq!(events.len(), 1); 337 + assert!(events[0].is_err()); 338 + } 339 + }
+132
crates/opake-core/src/sse/reconnect.rs
··· 1 + // Exponential backoff policy for SSE reconnection. 2 + // 3 + // Matches the semantics of the shipped TypeScript EventStream: 4 + // - Initial delay: 1s 5 + // - Doubling: 1s → 2s → 4s → 8s → 16s → 30s (capped) 6 + // - Jitter: ±20% (uniform) to avoid thundering herds on server restart 7 + // - `wasConnected` flag: only trigger full-sync on recovery from a 8 + // previously-open connection, not on initial-connect failures 9 + 10 + use std::time::Duration; 11 + 12 + const DEFAULT_INITIAL_DELAY_MS: u64 = 1_000; 13 + const DEFAULT_MAX_DELAY_MS: u64 = 30_000; 14 + const JITTER_FRACTION: f64 = 0.20; 15 + 16 + /// Exponential backoff state machine. Each call to [`next_delay`] doubles 17 + /// the delay and applies jitter, saturating at `max_delay_ms`. 18 + #[derive(Debug, Clone)] 19 + pub struct BackoffPolicy { 20 + current_ms: u64, 21 + max_ms: u64, 22 + } 23 + 24 + impl BackoffPolicy { 25 + pub fn new() -> Self { 26 + Self { 27 + current_ms: DEFAULT_INITIAL_DELAY_MS, 28 + max_ms: DEFAULT_MAX_DELAY_MS, 29 + } 30 + } 31 + 32 + pub fn with_max(max_ms: u64) -> Self { 33 + Self { 34 + current_ms: DEFAULT_INITIAL_DELAY_MS, 35 + max_ms, 36 + } 37 + } 38 + 39 + /// Reset to the initial delay. Called after a successful connection. 40 + pub fn reset(&mut self) { 41 + self.current_ms = DEFAULT_INITIAL_DELAY_MS; 42 + } 43 + 44 + /// Compute the next reconnect delay, advancing internal state. 45 + /// 46 + /// The `rand` parameter supplies the jitter factor in `[0.0, 1.0)`. 47 + /// Taking it as a parameter (rather than sampling internally) keeps 48 + /// this pure and testable without pulling `rand` into opake-core. 49 + pub fn next_delay(&mut self, rand: f64) -> Duration { 50 + let base = self.current_ms as f64; 51 + let jitter_range = base * JITTER_FRACTION; 52 + // Map [0,1) uniformly to [-jitter_range, +jitter_range). 53 + let offset = (rand * 2.0 - 1.0) * jitter_range; 54 + let jittered_ms = (base + offset).max(0.0) as u64; 55 + 56 + // Double for next time, clamped. 57 + self.current_ms = (self.current_ms * 2).min(self.max_ms); 58 + 59 + Duration::from_millis(jittered_ms) 60 + } 61 + } 62 + 63 + impl Default for BackoffPolicy { 64 + fn default() -> Self { 65 + Self::new() 66 + } 67 + } 68 + 69 + // --------------------------------------------------------------------------- 70 + // Tests 71 + // --------------------------------------------------------------------------- 72 + 73 + #[cfg(test)] 74 + mod tests { 75 + use super::*; 76 + 77 + #[test] 78 + fn initial_delay_near_one_second() { 79 + let mut policy = BackoffPolicy::new(); 80 + // With rand=0.5 (no offset), first delay should be exactly 1000ms. 81 + let delay = policy.next_delay(0.5); 82 + assert_eq!(delay, Duration::from_millis(1000)); 83 + } 84 + 85 + #[test] 86 + fn jitter_at_bounds_stays_in_range() { 87 + let mut policy = BackoffPolicy::new(); 88 + // rand=0.0 → -20% → 800ms 89 + let low = policy.next_delay(0.0); 90 + assert_eq!(low, Duration::from_millis(800)); 91 + 92 + // Reset and try the other bound. 93 + policy.reset(); 94 + // rand=0.999... → +20% → ~1200ms 95 + let high = policy.next_delay(0.9999); 96 + // Approximate check since we use f64 math. 97 + assert!(high >= Duration::from_millis(1199)); 98 + assert!(high <= Duration::from_millis(1200)); 99 + } 100 + 101 + #[test] 102 + fn exponential_doubling_up_to_cap() { 103 + let mut policy = BackoffPolicy::new(); 104 + // Consume successive delays at rand=0.5 (no jitter offset). 105 + let expected = [ 106 + 1_000u64, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000, 30_000, 107 + ]; 108 + for ms in expected { 109 + let d = policy.next_delay(0.5); 110 + assert_eq!(d, Duration::from_millis(ms)); 111 + } 112 + } 113 + 114 + #[test] 115 + fn reset_returns_to_initial() { 116 + let mut policy = BackoffPolicy::new(); 117 + let _ = policy.next_delay(0.5); 118 + let _ = policy.next_delay(0.5); 119 + policy.reset(); 120 + let d = policy.next_delay(0.5); 121 + assert_eq!(d, Duration::from_millis(1000)); 122 + } 123 + 124 + #[test] 125 + fn custom_max_is_respected() { 126 + let mut policy = BackoffPolicy::with_max(5_000); 127 + for _ in 0..10 { 128 + let d = policy.next_delay(0.5); 129 + assert!(d <= Duration::from_millis(5_000)); 130 + } 131 + } 132 + }
+130
crates/opake-core/src/sse/reqwest_connection.rs
··· 1 + // Native SSE connection — wraps `reqwest::Response::bytes_stream()`. 2 + // 3 + // Reqwest's streaming API gives us `impl Stream<Item = Result<Bytes>>`. 4 + // Each chunk may split mid-line, so we feed it into an 5 + // [`SseLineAccumulator`](crate::sse::parser::SseLineAccumulator) which 6 + // buffers partial lines and emits complete events as they parse. 7 + // 8 + // Unlike the WASM side (browser EventSource auto-reconnects, we just 9 + // listen), reqwest has no built-in reconnect. All reconnect logic lives 10 + // in the outer `SseConsumer` — this file just manages a single stream. 11 + 12 + use std::collections::VecDeque; 13 + use std::pin::Pin; 14 + 15 + use futures_util::stream::{Stream, StreamExt}; 16 + use reqwest::Client; 17 + 18 + use crate::error::Error; 19 + use crate::sse::events::SseEvent; 20 + use crate::sse::parser::SseLineAccumulator; 21 + use crate::sse::transport::{SseConnection, SseTransport}; 22 + 23 + type BytesStream = Pin<Box<dyn Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send>>; 24 + 25 + /// SSE transport for native environments. Wraps a shared [`reqwest::Client`]. 26 + #[derive(Clone)] 27 + pub struct ReqwestSseTransport { 28 + client: Client, 29 + } 30 + 31 + impl ReqwestSseTransport { 32 + /// Build from an existing client. Allows sharing connection pooling 33 + /// with the regular XRPC transport. 34 + pub fn new(client: Client) -> Self { 35 + Self { client } 36 + } 37 + 38 + /// Build with a fresh default client. 39 + pub fn with_default_client() -> Self { 40 + Self { 41 + client: Client::new(), 42 + } 43 + } 44 + } 45 + 46 + impl Default for ReqwestSseTransport { 47 + fn default() -> Self { 48 + Self::with_default_client() 49 + } 50 + } 51 + 52 + impl SseTransport for ReqwestSseTransport { 53 + type Connection = ReqwestSseConnection; 54 + 55 + async fn connect(&self, appview_url: &str, token: String) -> Result<Self::Connection, Error> { 56 + let url = format!( 57 + "{}/api/events?token={}", 58 + appview_url.trim_end_matches('/'), 59 + urlencoding::encode(&token) 60 + ); 61 + 62 + let response = self 63 + .client 64 + .get(&url) 65 + .header("Accept", "text/event-stream") 66 + .send() 67 + .await 68 + .map_err(|e| Error::Sse(format!("SSE connect failed: {e}")))?; 69 + 70 + let status = response.status(); 71 + if !status.is_success() { 72 + let body = response 73 + .text() 74 + .await 75 + .unwrap_or_else(|_| "<unreadable>".into()); 76 + return Err(Error::Sse(format!( 77 + "SSE connect returned {}: {}", 78 + status.as_u16(), 79 + body 80 + ))); 81 + } 82 + 83 + let stream: BytesStream = Box::pin(response.bytes_stream()); 84 + Ok(ReqwestSseConnection { 85 + stream, 86 + accumulator: SseLineAccumulator::new(), 87 + pending: VecDeque::new(), 88 + }) 89 + } 90 + } 91 + 92 + /// A live native SSE connection. 93 + pub struct ReqwestSseConnection { 94 + stream: BytesStream, 95 + accumulator: SseLineAccumulator, 96 + /// Events parsed from the most recent chunk but not yet returned to 97 + /// the caller. Drained one per `next_event` call before pulling a 98 + /// new chunk. 99 + pending: VecDeque<Result<SseEvent, Error>>, 100 + } 101 + 102 + impl SseConnection for ReqwestSseConnection { 103 + async fn next_event(&mut self) -> Result<Option<SseEvent>, Error> { 104 + loop { 105 + // Drain the pending buffer first. 106 + if let Some(result) = self.pending.pop_front() { 107 + return result.map(Some); 108 + } 109 + 110 + // Pull the next chunk from the stream. 111 + match self.stream.next().await { 112 + Some(Ok(bytes)) => { 113 + let events = self.accumulator.feed_bytes(&bytes); 114 + self.pending.extend(events); 115 + // Fall through to the drain step on the next loop iter. 116 + } 117 + Some(Err(e)) => { 118 + return Err(Error::Sse(format!("SSE stream error: {e}"))); 119 + } 120 + None => { 121 + // Stream ended cleanly. Per SSE spec this shouldn't 122 + // happen during a healthy connection — the broadcaster 123 + // keeps streams open indefinitely. Treat as reconnect 124 + // signal. 125 + return Ok(None); 126 + } 127 + } 128 + } 129 + } 130 + }
+48
crates/opake-core/src/sse/transport.rs
··· 1 + // SseTransport + SseConnection traits. 2 + // 3 + // Matches the style of `crate::client::Transport::send` — uses return-position 4 + // `impl Future` rather than `async_trait` to avoid forcing a `Send` bound on 5 + // futures that won't be Send on WASM (EventSource and js_sys::Function are 6 + // both `!Send`). 7 + // 8 + // The shape is intentionally minimal: `SseTransport::connect` opens a 9 + // connection, `SseConnection::next_event` polls for the next event. The 10 + // outer `SseConsumer` handles reconnection, token refresh, and synthetic 11 + // Reconnect events. 12 + 13 + use crate::error::Error; 14 + use crate::sse::events::SseEvent; 15 + use std::future::Future; 16 + 17 + /// Opens SSE connections against the appview's `/api/events` endpoint. 18 + /// 19 + /// Each call to [`connect`](Self::connect) establishes a fresh connection 20 + /// using a one-shot token from `request_sse_token`. Reconnection is the 21 + /// responsibility of the outer consumer, not the transport. 22 + pub trait SseTransport { 23 + type Connection: SseConnection; 24 + 25 + /// Open a new SSE connection. The token is passed as a query parameter 26 + /// (EventSource can't carry custom headers) and is single-use on the 27 + /// appview side, so every call must use a fresh token. 28 + fn connect( 29 + &self, 30 + appview_url: &str, 31 + token: String, 32 + ) -> impl Future<Output = Result<Self::Connection, Error>>; 33 + } 34 + 35 + /// A live SSE connection. Poll [`next_event`](Self::next_event) in a loop 36 + /// to consume events until it returns `Ok(None)` (clean EOF) or `Err(_)`. 37 + pub trait SseConnection { 38 + /// Await the next parsed event. 39 + /// 40 + /// Returns: 41 + /// - `Ok(Some(event))` on a parsed frame 42 + /// - `Ok(None)` on clean connection close (rare — the broadcaster 43 + /// keeps streams open indefinitely) 44 + /// - `Err(_)` on transport failure, parse error, or closed browser tab 45 + /// 46 + /// The consumer treats any error or clean-close as "reconnect now." 47 + fn next_event(&mut self) -> impl Future<Output = Result<Option<SseEvent>, Error>>; 48 + }
+170
crates/opake-core/src/sse/wasm_connection.rs
··· 1 + // WASM SSE connection — wraps browser `EventSource` via web_sys. 2 + // 3 + // `EventSource` is callback-based, so we bridge to the `async fn next_event` 4 + // polling API via a `futures_channel::mpsc::unbounded` channel. Each event 5 + // listener is a `Closure` that parses the `data` payload and sends a typed 6 + // `SseEvent` through the channel. 7 + // 8 + // Listener closures must outlive the `EventSource` (otherwise the JS side 9 + // loses its callback reference and events are dropped silently). We store 10 + // them in fields on `WasmSseConnection` and drop them on `Drop` after 11 + // calling `es.close()`. 12 + // 13 + // Back-pressure: unbounded is fine for the browser. EventSource delivery 14 + // is throttled by the browser's event loop and the server's rate limiter; 15 + // bounded would force us into `try_send` which silently drops in a sync 16 + // callback (no await point, no retry). 17 + 18 + use std::cell::RefCell; 19 + use std::rc::Rc; 20 + 21 + use futures_channel::mpsc::{unbounded, UnboundedReceiver}; 22 + use futures_util::StreamExt; 23 + use wasm_bindgen::closure::Closure; 24 + use wasm_bindgen::{JsCast, JsValue}; 25 + use web_sys::{Event, EventSource, MessageEvent}; 26 + 27 + use crate::error::Error; 28 + use crate::sse::events::SseEvent; 29 + use crate::sse::transport::{SseConnection, SseTransport}; 30 + 31 + /// Every named event the broadcaster can emit. Registered as individual 32 + /// listeners because `onmessage` only fires for untyped (`event: message`) 33 + /// frames, not typed ones. 34 + const NAMED_EVENTS: &[&str] = &[ 35 + "directory:upsert", 36 + "directory:delete", 37 + "document:upsert", 38 + "document:delete", 39 + "keyring:upsert", 40 + "keyring:delete", 41 + "grant:upsert", 42 + "grant:delete", 43 + "directory_update:upsert", 44 + "directory_update:delete", 45 + "keyring_update:upsert", 46 + "keyring_update:delete", 47 + "document_update:upsert", 48 + "document_update:delete", 49 + ]; 50 + 51 + /// SSE transport for browser environments. Stateless — all per-connection 52 + /// state lives on [`WasmSseConnection`]. 53 + #[derive(Default, Clone)] 54 + pub struct WasmSseTransport; 55 + 56 + impl WasmSseTransport { 57 + pub fn new() -> Self { 58 + Self 59 + } 60 + } 61 + 62 + impl SseTransport for WasmSseTransport { 63 + type Connection = WasmSseConnection; 64 + 65 + async fn connect(&self, appview_url: &str, token: String) -> Result<Self::Connection, Error> { 66 + let url = format!( 67 + "{}/api/events?token={}", 68 + appview_url.trim_end_matches('/'), 69 + urlencoding::encode(&token) 70 + ); 71 + 72 + let es = EventSource::new(&url).map_err(js_err)?; 73 + 74 + let (tx, rx) = unbounded::<Result<SseEvent, Error>>(); 75 + // Flag set by the error closure; guards against the onerror handler 76 + // firing repeatedly after a single error (EventSource sometimes 77 + // pumps multiple error events on disconnect). 78 + let errored = Rc::new(RefCell::new(false)); 79 + 80 + let mut message_closures: Vec<Closure<dyn FnMut(MessageEvent)>> = Vec::new(); 81 + for event_name in NAMED_EVENTS { 82 + let tx_clone = tx.clone(); 83 + let name = *event_name; 84 + let closure = Closure::wrap(Box::new(move |evt: MessageEvent| { 85 + let data_str = match evt.data().as_string() { 86 + Some(s) => s, 87 + None => { 88 + log::warn!("[sse] {} event with non-string data", name); 89 + return; 90 + } 91 + }; 92 + let result = SseEvent::from_name_and_data(name, data_str.as_bytes()); 93 + // Channel may be closed if the connection was dropped 94 + // mid-flight; silently ignore. 95 + let _ = tx_clone.unbounded_send(result); 96 + }) as Box<dyn FnMut(MessageEvent)>); 97 + 98 + es.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref()) 99 + .map_err(|e| { 100 + // Clean up the EventSource before returning — otherwise the 101 + // browser keeps the connection open. 102 + es.close(); 103 + js_err(e) 104 + })?; 105 + 106 + message_closures.push(closure); 107 + } 108 + 109 + // Error handler. EventSource fires onerror both on initial connect 110 + // failure (before we ever see a real event) and on mid-session 111 + // disconnect. We send an error through the channel either way — 112 + // the outer SseConsumer handles the difference. 113 + let err_tx = tx.clone(); 114 + let errored_clone = Rc::clone(&errored); 115 + let error_closure = Closure::wrap(Box::new(move |_evt: Event| { 116 + if *errored_clone.borrow() { 117 + return; // already errored, ignore repeat 118 + } 119 + *errored_clone.borrow_mut() = true; 120 + let _ = err_tx.unbounded_send(Err(Error::Sse("EventSource error".into()))); 121 + }) as Box<dyn FnMut(Event)>); 122 + es.set_onerror(Some(error_closure.as_ref().unchecked_ref())); 123 + 124 + Ok(WasmSseConnection { 125 + es, 126 + rx, 127 + _message_closures: message_closures, 128 + _error_closure: error_closure, 129 + }) 130 + } 131 + } 132 + 133 + /// A live SSE connection to the appview. 134 + /// 135 + /// The underlying `EventSource` is closed when this value is dropped. 136 + pub struct WasmSseConnection { 137 + es: EventSource, 138 + rx: UnboundedReceiver<Result<SseEvent, Error>>, 139 + // Kept alive so listeners stay registered. Never read. 140 + _message_closures: Vec<Closure<dyn FnMut(MessageEvent)>>, 141 + _error_closure: Closure<dyn FnMut(Event)>, 142 + } 143 + 144 + impl SseConnection for WasmSseConnection { 145 + async fn next_event(&mut self) -> Result<Option<SseEvent>, Error> { 146 + match self.rx.next().await { 147 + Some(Ok(event)) => Ok(Some(event)), 148 + Some(Err(e)) => Err(e), 149 + None => Ok(None), // channel closed — treat as clean EOF 150 + } 151 + } 152 + } 153 + 154 + impl Drop for WasmSseConnection { 155 + fn drop(&mut self) { 156 + self.es.close(); 157 + } 158 + } 159 + 160 + fn js_err(e: JsValue) -> Error { 161 + let message = e 162 + .as_string() 163 + .or_else(|| { 164 + js_sys::Reflect::get(&e, &"message".into()) 165 + .ok()? 166 + .as_string() 167 + }) 168 + .unwrap_or_else(|| format!("{e:?}")); 169 + Error::Sse(message) 170 + }
+458
crates/opake-core/src/tree_keeper/mod.rs
··· 1 + //! Persistent in-memory tree state driven by SSE events. 2 + //! 3 + //! `TreeKeeper` holds a [`DirectoryTree`] per context (cabinet + 4 + //! per-workspace) and applies SSE record events incrementally via 5 + //! [`DirectoryTree::apply_directory_delta`]. Watchers register against 6 + //! a specific directory URI and receive the updated tree whenever an 7 + //! event affects it. 8 + //! 9 + //! This is the replacement for the "rebuild the tree on every load" 10 + //! model — once a tree is installed, it stays alive and patches in 11 + //! place as events arrive. 12 + //! 13 + //! ## Cold-start semantics 14 + //! 15 + //! Events for a context that hasn't been installed yet are silently 16 + //! dropped. Consumers are expected to call [`install_cabinet_tree`] or 17 + //! [`install_workspace_tree`] during the normal "load this view" flow 18 + //! (typically after the existing `FileManager::load_tree` call). The 19 + //! reconnect contract then covers any gap: on reconnect, all installed 20 + //! contexts should be full-synced from the appview, and the SSE stream 21 + //! resumes from current state. 22 + //! 23 + //! [`install_cabinet_tree`]: TreeKeeper::install_cabinet_tree 24 + //! [`install_workspace_tree`]: TreeKeeper::install_workspace_tree 25 + 26 + use std::collections::HashMap; 27 + 28 + use crate::crypto::{ContentKey, X25519PrivateKey}; 29 + use crate::directories::{DecryptionCtx, DirectoryTree, TreeChange}; 30 + use crate::error::Error; 31 + use crate::sse::events::{SseDirectoryRecord, SseEvent}; 32 + 33 + /// Callback fired when a watched directory's tree state changes. 34 + /// 35 + /// Receives `Some(&DirectoryTree)` for normal updates (the binding layer 36 + /// builds whatever snapshot shape the platform needs) and `None` when 37 + /// the watched directory itself has been deleted (the watcher auto-closes 38 + /// after this call per the API contract — the delete notification is a 39 + /// one-shot "gone" signal). 40 + pub type WatcherCallback = Box<dyn FnMut(Option<&DirectoryTree>)>; 41 + 42 + /// Opaque handle returned by [`TreeKeeper::watch_cabinet`] / 43 + /// [`TreeKeeper::watch_workspace`]. Pass to [`TreeKeeper::unwatch`] to 44 + /// remove the watcher explicitly; watchers also auto-close when their 45 + /// watched directory is deleted. 46 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 47 + pub struct WatcherHandle(u64); 48 + 49 + /// A persistent tree for one context (cabinet or workspace). 50 + struct HeldTree { 51 + tree: DirectoryTree, 52 + /// For cabinet trees: the private X25519 key for direct key unwrapping. 53 + /// For workspace trees: None. 54 + private_key: Option<X25519PrivateKey>, 55 + /// For workspace trees: single-entry map of keyring URI → group key. 56 + /// For cabinet trees: empty. 57 + group_keys: HashMap<String, ContentKey>, 58 + } 59 + 60 + /// Which context a watcher is attached to. 61 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 62 + enum TreeScope { 63 + Cabinet, 64 + Workspace(String), 65 + } 66 + 67 + struct WatcherEntry { 68 + scope: TreeScope, 69 + directory_uri: String, 70 + callback: WatcherCallback, 71 + } 72 + 73 + /// Owns per-context directory trees and routes SSE events to them. 74 + /// 75 + /// Watchers are registered against a specific directory URI. On each 76 + /// applied event, all watchers in the affected context are invoked with 77 + /// a reference to the updated tree. For the POC we don't do URI-targeted 78 + /// filtering at this layer — React's reconciliation handles redundant 79 + /// snapshots, and per-watcher precision can be added later via a 80 + /// parent index. 81 + pub struct TreeKeeper { 82 + did: String, 83 + cabinet: Option<HeldTree>, 84 + workspaces: HashMap<String, HeldTree>, 85 + watchers: HashMap<WatcherHandle, WatcherEntry>, 86 + next_watcher_id: u64, 87 + } 88 + 89 + impl TreeKeeper { 90 + pub fn new(did: impl Into<String>) -> Self { 91 + Self { 92 + did: did.into(), 93 + cabinet: None, 94 + workspaces: HashMap::new(), 95 + watchers: HashMap::new(), 96 + next_watcher_id: 0, 97 + } 98 + } 99 + 100 + pub fn did(&self) -> &str { 101 + &self.did 102 + } 103 + 104 + // -- Tree installation (called after the initial load) -- 105 + 106 + /// Install a cabinet tree. Replaces any previously-installed cabinet. 107 + pub fn install_cabinet_tree(&mut self, tree: DirectoryTree, private_key: X25519PrivateKey) { 108 + self.cabinet = Some(HeldTree { 109 + tree, 110 + private_key: Some(private_key), 111 + group_keys: HashMap::new(), 112 + }); 113 + } 114 + 115 + /// Install a workspace tree. Replaces any previously-installed tree 116 + /// for the same keyring URI. `group_key` is the unwrapped content 117 + /// key for the workspace's keyring. 118 + pub fn install_workspace_tree( 119 + &mut self, 120 + keyring_uri: String, 121 + tree: DirectoryTree, 122 + group_key: ContentKey, 123 + ) { 124 + let mut group_keys = HashMap::new(); 125 + group_keys.insert(keyring_uri.clone(), group_key); 126 + self.workspaces.insert( 127 + keyring_uri, 128 + HeldTree { 129 + tree, 130 + private_key: None, 131 + group_keys, 132 + }, 133 + ); 134 + } 135 + 136 + /// Remove a tree for a specific context. Called on account switch 137 + /// or workspace removal. Also closes all watchers in that scope. 138 + pub fn uninstall_cabinet(&mut self) { 139 + self.cabinet = None; 140 + self.watchers.retain(|_, w| w.scope != TreeScope::Cabinet); 141 + } 142 + 143 + pub fn uninstall_workspace(&mut self, keyring_uri: &str) { 144 + self.workspaces.remove(keyring_uri); 145 + self.watchers 146 + .retain(|_, w| !matches!(&w.scope, TreeScope::Workspace(uri) if uri == keyring_uri)); 147 + } 148 + 149 + /// Drain every installed tree and drop every watcher. 150 + /// 151 + /// Dropping the `HeldTree` entries triggers `ContentKey`'s 152 + /// `ZeroizeOnDrop` impl, wiping any cached group keys and cabinet 153 + /// private key from memory. The decrypted directory name cache on 154 + /// each `DirectoryTree` is freed along with the tree itself. 155 + /// 156 + /// Called on `stopSseConsumer` so that account switches don't leak 157 + /// a previous user's crypto material or metadata into the next 158 + /// session's address space. 159 + pub fn uninstall_all(&mut self) { 160 + self.cabinet = None; 161 + self.workspaces.clear(); 162 + self.watchers.clear(); 163 + } 164 + 165 + // -- Watcher management -- 166 + 167 + pub fn watch_cabinet( 168 + &mut self, 169 + directory_uri: String, 170 + callback: WatcherCallback, 171 + ) -> WatcherHandle { 172 + self.install_watcher(TreeScope::Cabinet, directory_uri, callback) 173 + } 174 + 175 + pub fn watch_workspace( 176 + &mut self, 177 + keyring_uri: String, 178 + directory_uri: String, 179 + callback: WatcherCallback, 180 + ) -> WatcherHandle { 181 + self.install_watcher(TreeScope::Workspace(keyring_uri), directory_uri, callback) 182 + } 183 + 184 + fn install_watcher( 185 + &mut self, 186 + scope: TreeScope, 187 + directory_uri: String, 188 + mut callback: WatcherCallback, 189 + ) -> WatcherHandle { 190 + let handle = WatcherHandle(self.next_watcher_id); 191 + self.next_watcher_id += 1; 192 + 193 + // Fire once immediately with the current state, if the tree is 194 + // already installed. Mirrors the "eager first snapshot" contract. 195 + if let Some(tree) = self.tree_for_scope(&scope) { 196 + callback(Some(tree)); 197 + } 198 + 199 + self.watchers.insert( 200 + handle, 201 + WatcherEntry { 202 + scope, 203 + directory_uri, 204 + callback, 205 + }, 206 + ); 207 + handle 208 + } 209 + 210 + pub fn unwatch(&mut self, handle: WatcherHandle) { 211 + self.watchers.remove(&handle); 212 + } 213 + 214 + pub fn watcher_count(&self) -> usize { 215 + self.watchers.len() 216 + } 217 + 218 + // -- Read helpers for bindings / tests -- 219 + 220 + pub fn cabinet_tree(&self) -> Option<&DirectoryTree> { 221 + self.cabinet.as_ref().map(|h| &h.tree) 222 + } 223 + 224 + pub fn workspace_tree(&self, keyring_uri: &str) -> Option<&DirectoryTree> { 225 + self.workspaces.get(keyring_uri).map(|h| &h.tree) 226 + } 227 + 228 + fn tree_for_scope(&self, scope: &TreeScope) -> Option<&DirectoryTree> { 229 + match scope { 230 + TreeScope::Cabinet => self.cabinet_tree(), 231 + TreeScope::Workspace(uri) => self.workspace_tree(uri), 232 + } 233 + } 234 + 235 + // -- Event application -- 236 + 237 + /// Apply one SSE event, patching the affected tree and firing 238 + /// watchers. Events for contexts not yet installed are dropped. 239 + pub fn apply_event(&mut self, event: &SseEvent) -> Result<(), Error> { 240 + match event { 241 + SseEvent::DirectoryUpsert(record) => self.apply_directory_upsert(record)?, 242 + SseEvent::DirectoryDelete(payload) => { 243 + if let Some(uri) = payload.best_uri() { 244 + self.apply_directory_delete(uri)?; 245 + } 246 + } 247 + // Document events: documents aren't in the DirectoryTree (they're 248 + // leaf URIs referenced from parents). A document upsert doesn't 249 + // change tree structure, but watcher consumers may still want to 250 + // know — for the POC, we skip these. TODO: expose a doc event 251 + // channel later for document-metadata reloads. 252 + SseEvent::DocumentUpsert(_) | SseEvent::DocumentDelete(_) => {} 253 + // Keyring events: TODO — detect rotation bump and call 254 + // invalidate_decrypted_names on the affected workspace tree. 255 + SseEvent::KeyringUpsert(_) | SseEvent::KeyringDelete(_) => {} 256 + // Grant events: don't affect the tree. Consumers can react 257 + // separately for sharing UI updates. 258 + SseEvent::GrantUpsert(_) | SseEvent::GrantDelete(_) => {} 259 + // Proposal events: routed upstream by the WASM SSE consumer 260 + // via `dispatch_proposal_sync`, not applied to the tree 261 + // directly. By the time they'd reach here they've already 262 + // been handled (or dropped as unroutable). TreeKeeper is 263 + // pure tree state — proposals require a sync round-trip 264 + // that can't happen under the keeper lock anyway. 265 + SseEvent::DirectoryUpdateUpsert(_) 266 + | SseEvent::DirectoryUpdateDelete(_) 267 + | SseEvent::KeyringUpdateUpsert(_) 268 + | SseEvent::KeyringUpdateDelete(_) 269 + | SseEvent::DocumentUpdateUpsert(_) 270 + | SseEvent::DocumentUpdateDelete(_) => {} 271 + // Reconnect: callers handle full-sync out-of-band. We just 272 + // fire all watchers so the UI repaints from current state 273 + // (which is stale until a full sync lands). 274 + SseEvent::Reconnect => { 275 + self.notify_all_watchers(); 276 + } 277 + } 278 + Ok(()) 279 + } 280 + 281 + fn apply_directory_upsert(&mut self, record: &SseDirectoryRecord) -> Result<(), Error> { 282 + let scope = match record.keyring_uri.as_deref() { 283 + Some(uri) => TreeScope::Workspace(uri.to_string()), 284 + None => TreeScope::Cabinet, 285 + }; 286 + 287 + // Split the borrow across held's disjoint fields so apply_directory_delta 288 + // can take &mut tree while DecryptionCtx borrows &private_key and &group_keys. 289 + let did = self.did.as_str(); 290 + let held = match &scope { 291 + TreeScope::Cabinet => self.cabinet.as_mut(), 292 + TreeScope::Workspace(uri) => self.workspaces.get_mut(uri), 293 + }; 294 + 295 + let Some(held) = held else { 296 + log::debug!( 297 + "[tree_keeper] dropping event for unloaded context: {}", 298 + record.directory_uri 299 + ); 300 + return Ok(()); 301 + }; 302 + 303 + let HeldTree { 304 + tree, 305 + private_key, 306 + group_keys, 307 + } = held; 308 + let ctx = DecryptionCtx { 309 + did, 310 + private_key: private_key.as_ref(), 311 + group_keys, 312 + }; 313 + let change = tree.apply_directory_delta(record, &ctx)?; 314 + 315 + self.notify_watchers_for_change(&scope, &change); 316 + Ok(()) 317 + } 318 + 319 + fn apply_directory_delete(&mut self, uri: &str) -> Result<(), Error> { 320 + // Delete payload has no keyring_uri. Try each context; only the 321 + // one containing the URI will report a change (others NoOp). 322 + let delete_payload = SseDirectoryRecord { 323 + directory_uri: uri.to_string(), 324 + owner_did: String::new(), 325 + entries: Vec::new(), 326 + encrypted_metadata: None, 327 + key_wrapping: None, 328 + keyring_uri: None, 329 + deleted_at: Some(String::new()), 330 + indexed_at: None, 331 + }; 332 + 333 + let did = self.did.as_str(); 334 + 335 + if let Some(held) = self.cabinet.as_mut() { 336 + let HeldTree { 337 + tree, 338 + private_key, 339 + group_keys, 340 + } = held; 341 + let ctx = DecryptionCtx { 342 + did, 343 + private_key: private_key.as_ref(), 344 + group_keys, 345 + }; 346 + let change = tree.apply_directory_delta(&delete_payload, &ctx)?; 347 + if change.is_effective() { 348 + self.notify_watchers_for_change(&TreeScope::Cabinet, &change); 349 + return Ok(()); 350 + } 351 + } 352 + 353 + // Collect workspace keys upfront to avoid borrowing conflicts. 354 + let keyring_uris: Vec<String> = self.workspaces.keys().cloned().collect(); 355 + for keyring_uri in keyring_uris { 356 + let change = { 357 + let Some(held) = self.workspaces.get_mut(&keyring_uri) else { 358 + continue; 359 + }; 360 + let HeldTree { 361 + tree, 362 + private_key, 363 + group_keys, 364 + } = held; 365 + let ctx = DecryptionCtx { 366 + did, 367 + private_key: private_key.as_ref(), 368 + group_keys, 369 + }; 370 + tree.apply_directory_delta(&delete_payload, &ctx)? 371 + }; 372 + if change.is_effective() { 373 + self.notify_watchers_for_change( 374 + &TreeScope::Workspace(keyring_uri.clone()), 375 + &change, 376 + ); 377 + return Ok(()); 378 + } 379 + } 380 + 381 + Ok(()) 382 + } 383 + 384 + // -- Watcher notification -- 385 + 386 + /// Fire all watchers in the given scope. If the change was a deletion 387 + /// AND the deleted URI matches a watcher's directory_uri, that watcher 388 + /// gets a None notification and is auto-closed. 389 + fn notify_watchers_for_change(&mut self, scope: &TreeScope, change: &TreeChange) { 390 + // Destructure self to split borrows: we need `&self.cabinet` / 391 + // `&self.workspaces` alongside `&mut self.watchers`. 392 + let Self { 393 + cabinet, 394 + workspaces, 395 + watchers, 396 + .. 397 + } = self; 398 + 399 + let tree = match scope { 400 + TreeScope::Cabinet => cabinet.as_ref().map(|h| &h.tree), 401 + TreeScope::Workspace(uri) => workspaces.get(uri).map(|h| &h.tree), 402 + }; 403 + let Some(tree) = tree else { 404 + return; 405 + }; 406 + 407 + let deleted_uri = match change { 408 + TreeChange::Removed { uri } => Some(uri.as_str()), 409 + _ => None, 410 + }; 411 + 412 + let mut auto_close: Vec<WatcherHandle> = Vec::new(); 413 + 414 + for (handle, watcher) in watchers.iter_mut() { 415 + if &watcher.scope != scope { 416 + continue; 417 + } 418 + if deleted_uri == Some(watcher.directory_uri.as_str()) { 419 + (watcher.callback)(None); 420 + auto_close.push(*handle); 421 + } else { 422 + (watcher.callback)(Some(tree)); 423 + } 424 + } 425 + 426 + for handle in auto_close { 427 + watchers.remove(&handle); 428 + } 429 + } 430 + 431 + /// Fire all watchers with the current state of their respective 432 + /// trees. Used on Reconnect. 433 + fn notify_all_watchers(&mut self) { 434 + let Self { 435 + cabinet, 436 + workspaces, 437 + watchers, 438 + .. 439 + } = self; 440 + 441 + for watcher in watchers.values_mut() { 442 + let tree = match &watcher.scope { 443 + TreeScope::Cabinet => cabinet.as_ref().map(|h| &h.tree), 444 + TreeScope::Workspace(uri) => workspaces.get(uri).map(|h| &h.tree), 445 + }; 446 + if let Some(tree) = tree { 447 + (watcher.callback)(Some(tree)); 448 + } 449 + } 450 + } 451 + } 452 + 453 + // --------------------------------------------------------------------------- 454 + // Tests 455 + // --------------------------------------------------------------------------- 456 + 457 + #[cfg(test)] 458 + mod tests;
+355
crates/opake-core/src/tree_keeper/tests.rs
··· 1 + use std::cell::RefCell; 2 + use std::rc::Rc; 3 + 4 + use super::*; 5 + use crate::crypto::ContentKey; 6 + use crate::directories::tests::{dummy_directory_with_entries, test_keypair, TEST_DID}; 7 + use crate::sse::events::{SseDeletePayload, SseDirectoryRecord, SseEvent}; 8 + 9 + const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 10 + const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.directory/photos"; 11 + const DOC_BEACH_URI: &str = "at://did:plc:test/app.opake.document/beach"; 12 + 13 + // -- Test helpers -- 14 + 15 + /// A callback sink that records every notification for later assertion. 16 + #[derive(Default, Clone)] 17 + struct RecordingSink { 18 + events: Rc<RefCell<Vec<RecordingEvent>>>, 19 + } 20 + 21 + #[derive(Debug, Clone)] 22 + #[allow(dead_code)] // root_uri captured for future assertion use 23 + enum RecordingEvent { 24 + /// Snapshot delivered. Captures the root URI (enough for the POC 25 + /// tests; full tree comparison would be overkill). 26 + Snapshot { root_uri: Option<String> }, 27 + /// Watcher was auto-closed due to the watched directory being deleted. 28 + Gone, 29 + } 30 + 31 + impl RecordingSink { 32 + fn new() -> Self { 33 + Self::default() 34 + } 35 + 36 + fn callback(&self) -> WatcherCallback { 37 + let events = Rc::clone(&self.events); 38 + Box::new(move |tree: Option<&DirectoryTree>| match tree { 39 + Some(t) => events.borrow_mut().push(RecordingEvent::Snapshot { 40 + root_uri: t.root_uri().map(str::to_owned), 41 + }), 42 + None => events.borrow_mut().push(RecordingEvent::Gone), 43 + }) 44 + } 45 + 46 + fn count(&self) -> usize { 47 + self.events.borrow().len() 48 + } 49 + 50 + fn was_closed(&self) -> bool { 51 + self.events 52 + .borrow() 53 + .iter() 54 + .any(|e| matches!(e, RecordingEvent::Gone)) 55 + } 56 + } 57 + 58 + fn cabinet_keeper() -> TreeKeeper { 59 + let mut keeper = TreeKeeper::new(TEST_DID); 60 + let (_, private_key) = test_keypair(); 61 + let tree = DirectoryTree::from_records(std::iter::empty()); 62 + keeper.install_cabinet_tree(tree, private_key); 63 + keeper 64 + } 65 + 66 + fn sse_dir_upsert(uri: &str, name: &str, entries: Vec<String>) -> SseEvent { 67 + let dir = dummy_directory_with_entries(name, entries); 68 + SseEvent::DirectoryUpsert(SseDirectoryRecord { 69 + directory_uri: uri.into(), 70 + owner_did: TEST_DID.into(), 71 + entries: dir.entries.clone(), 72 + encrypted_metadata: Some(serde_json::to_value(&dir.encrypted_metadata).unwrap()), 73 + key_wrapping: Some(serde_json::to_value(&dir.key_wrapping).unwrap()), 74 + keyring_uri: None, 75 + deleted_at: None, 76 + indexed_at: None, 77 + }) 78 + } 79 + 80 + fn sse_dir_delete(uri: &str) -> SseEvent { 81 + SseEvent::DirectoryDelete(SseDeletePayload { 82 + uri: None, 83 + directory_uri: Some(uri.into()), 84 + document_uri: None, 85 + }) 86 + } 87 + 88 + // -- Tests -- 89 + 90 + #[test] 91 + fn watcher_fires_once_on_install() { 92 + let mut keeper = cabinet_keeper(); 93 + let sink = RecordingSink::new(); 94 + 95 + keeper.watch_cabinet(ROOT_URI.into(), sink.callback()); 96 + 97 + // Eager first snapshot: 1 call at registration time. 98 + assert_eq!(sink.count(), 1); 99 + } 100 + 101 + #[test] 102 + fn watcher_not_fired_before_install() { 103 + let mut keeper = TreeKeeper::new(TEST_DID); 104 + let sink = RecordingSink::new(); 105 + 106 + // No tree installed yet. 107 + keeper.watch_cabinet(ROOT_URI.into(), sink.callback()); 108 + assert_eq!(sink.count(), 0); 109 + } 110 + 111 + #[test] 112 + fn upsert_event_fires_watchers() { 113 + let mut keeper = cabinet_keeper(); 114 + let sink = RecordingSink::new(); 115 + keeper.watch_cabinet(ROOT_URI.into(), sink.callback()); 116 + 117 + // Drop the eager first-snapshot call to isolate the event fire. 118 + let initial_count = sink.count(); 119 + 120 + keeper 121 + .apply_event(&sse_dir_upsert(ROOT_URI, "/", vec![])) 122 + .unwrap(); 123 + 124 + assert_eq!(sink.count(), initial_count + 1); 125 + } 126 + 127 + #[test] 128 + fn cold_start_drops_events_for_missing_context() { 129 + let mut keeper = TreeKeeper::new(TEST_DID); 130 + // No tree installed. 131 + 132 + // Should not panic — just drops the event silently. 133 + keeper 134 + .apply_event(&sse_dir_upsert(ROOT_URI, "/", vec![])) 135 + .unwrap(); 136 + } 137 + 138 + #[test] 139 + fn watch_workspace_scoped_events_only() { 140 + let mut keeper = TreeKeeper::new(TEST_DID); 141 + let (_, private_key) = test_keypair(); 142 + keeper.install_cabinet_tree(DirectoryTree::from_records(std::iter::empty()), private_key); 143 + 144 + let cabinet_sink = RecordingSink::new(); 145 + let ws_sink = RecordingSink::new(); 146 + 147 + keeper.watch_cabinet(ROOT_URI.into(), cabinet_sink.callback()); 148 + // Workspace tree isn't installed — ws_sink shouldn't receive events 149 + // (including the eager first snapshot, since we never install). 150 + keeper.watch_workspace( 151 + "at://did:plc:test/app.opake.keyring/ws1".into(), 152 + "at://did:plc:test/app.opake.directory/ws-root".into(), 153 + ws_sink.callback(), 154 + ); 155 + assert_eq!(ws_sink.count(), 0); 156 + 157 + let cabinet_start = cabinet_sink.count(); 158 + keeper 159 + .apply_event(&sse_dir_upsert(ROOT_URI, "/", vec![])) 160 + .unwrap(); 161 + 162 + assert_eq!(cabinet_sink.count(), cabinet_start + 1); 163 + assert_eq!(ws_sink.count(), 0); // unchanged — event was cabinet-scoped 164 + } 165 + 166 + #[test] 167 + fn deletion_of_watched_directory_fires_gone_and_auto_closes() { 168 + let mut keeper = cabinet_keeper(); 169 + 170 + // Insert the directory first so it exists in the tree. 171 + keeper 172 + .apply_event(&sse_dir_upsert( 173 + DIR_PHOTOS_URI, 174 + "Photos", 175 + vec![DOC_BEACH_URI.into()], 176 + )) 177 + .unwrap(); 178 + 179 + let sink = RecordingSink::new(); 180 + keeper.watch_cabinet(DIR_PHOTOS_URI.into(), sink.callback()); 181 + 182 + // At registration the watcher gets one snapshot. 183 + let start = sink.count(); 184 + 185 + // Delete the watched directory. 186 + keeper.apply_event(&sse_dir_delete(DIR_PHOTOS_URI)).unwrap(); 187 + 188 + // The watcher should have received a Gone marker, and been removed. 189 + assert!(sink.was_closed()); 190 + assert_eq!(sink.count(), start + 1); 191 + assert_eq!(keeper.watcher_count(), 0); 192 + 193 + // A subsequent event should NOT fire the closed watcher. 194 + keeper 195 + .apply_event(&sse_dir_upsert(DIR_PHOTOS_URI, "Photos", vec![])) 196 + .unwrap(); 197 + assert_eq!(sink.count(), start + 1); 198 + } 199 + 200 + #[test] 201 + fn unwatch_removes_watcher() { 202 + let mut keeper = cabinet_keeper(); 203 + let sink = RecordingSink::new(); 204 + 205 + let handle = keeper.watch_cabinet(ROOT_URI.into(), sink.callback()); 206 + assert_eq!(keeper.watcher_count(), 1); 207 + 208 + keeper.unwatch(handle); 209 + assert_eq!(keeper.watcher_count(), 0); 210 + 211 + // Events after unwatch shouldn't fire the callback. 212 + let count_before = sink.count(); 213 + keeper 214 + .apply_event(&sse_dir_upsert(ROOT_URI, "/", vec![])) 215 + .unwrap(); 216 + assert_eq!(sink.count(), count_before); 217 + } 218 + 219 + #[test] 220 + fn reconnect_event_fires_all_watchers() { 221 + let mut keeper = cabinet_keeper(); 222 + let a = RecordingSink::new(); 223 + let b = RecordingSink::new(); 224 + keeper.watch_cabinet(ROOT_URI.into(), a.callback()); 225 + keeper.watch_cabinet(DIR_PHOTOS_URI.into(), b.callback()); 226 + 227 + let a_start = a.count(); 228 + let b_start = b.count(); 229 + 230 + keeper.apply_event(&SseEvent::Reconnect).unwrap(); 231 + 232 + assert_eq!(a.count(), a_start + 1); 233 + assert_eq!(b.count(), b_start + 1); 234 + } 235 + 236 + #[test] 237 + fn uninstall_cabinet_drops_cabinet_watchers() { 238 + let mut keeper = cabinet_keeper(); 239 + let cabinet_sink = RecordingSink::new(); 240 + keeper.watch_cabinet(ROOT_URI.into(), cabinet_sink.callback()); 241 + assert_eq!(keeper.watcher_count(), 1); 242 + 243 + keeper.uninstall_cabinet(); 244 + assert_eq!(keeper.watcher_count(), 0); 245 + assert!(keeper.cabinet_tree().is_none()); 246 + } 247 + 248 + #[test] 249 + fn uninstall_all_drains_every_scope() { 250 + // Install cabinet + two workspace trees, attach watchers to each, 251 + // then call uninstall_all. This is the shape of the "account 252 + // switch" path: all scopes empty, all watchers removed. Drop 253 + // semantics on `ContentKey` (ZeroizeOnDrop) are covered in the 254 + // `crypto` module's own tests — here we only verify state drain. 255 + let mut keeper = cabinet_keeper(); 256 + let ws_a = "at://did:plc:test/app.opake.keyring/a".to_string(); 257 + let ws_b = "at://did:plc:test/app.opake.keyring/b".to_string(); 258 + 259 + let group_key_a = ContentKey([0u8; 32]); 260 + let group_key_b = ContentKey([1u8; 32]); 261 + keeper.install_workspace_tree( 262 + ws_a.clone(), 263 + DirectoryTree::from_records(std::iter::empty()), 264 + group_key_a, 265 + ); 266 + keeper.install_workspace_tree( 267 + ws_b.clone(), 268 + DirectoryTree::from_records(std::iter::empty()), 269 + group_key_b, 270 + ); 271 + 272 + let cabinet_sink = RecordingSink::new(); 273 + let ws_a_sink = RecordingSink::new(); 274 + let ws_b_sink = RecordingSink::new(); 275 + keeper.watch_cabinet(ROOT_URI.into(), cabinet_sink.callback()); 276 + keeper.watch_workspace( 277 + ws_a.clone(), 278 + "at://did:plc:test/app.opake.directory/a-root".into(), 279 + ws_a_sink.callback(), 280 + ); 281 + keeper.watch_workspace( 282 + ws_b.clone(), 283 + "at://did:plc:test/app.opake.directory/b-root".into(), 284 + ws_b_sink.callback(), 285 + ); 286 + 287 + assert_eq!(keeper.watcher_count(), 3); 288 + assert!(keeper.cabinet_tree().is_some()); 289 + assert!(keeper.workspace_tree(&ws_a).is_some()); 290 + assert!(keeper.workspace_tree(&ws_b).is_some()); 291 + 292 + keeper.uninstall_all(); 293 + 294 + assert_eq!(keeper.watcher_count(), 0); 295 + assert!(keeper.cabinet_tree().is_none()); 296 + assert!(keeper.workspace_tree(&ws_a).is_none()); 297 + assert!(keeper.workspace_tree(&ws_b).is_none()); 298 + 299 + // Subsequent events are silently dropped (cold-start semantics). 300 + keeper 301 + .apply_event(&sse_dir_upsert(ROOT_URI, "/", vec![])) 302 + .unwrap(); 303 + // No watcher should have fired from that event — all closed. 304 + assert_eq!(keeper.watcher_count(), 0); 305 + } 306 + 307 + #[test] 308 + fn apply_document_events_are_noops_for_tree_state() { 309 + // Document events don't touch the DirectoryTree (documents are leaves 310 + // referenced from parent directories). Calling apply_event with a 311 + // document upsert should neither error nor fire watchers. 312 + use crate::sse::events::SseDocumentRecord; 313 + 314 + let mut keeper = cabinet_keeper(); 315 + let sink = RecordingSink::new(); 316 + keeper.watch_cabinet(ROOT_URI.into(), sink.callback()); 317 + let start = sink.count(); 318 + 319 + let doc = SseEvent::DocumentUpsert(SseDocumentRecord { 320 + document_uri: DOC_BEACH_URI.into(), 321 + owner_did: TEST_DID.into(), 322 + encrypted_metadata: None, 323 + encryption: None, 324 + blob_ref: None, 325 + keyring_uri: None, 326 + rotation: None, 327 + deleted_at: None, 328 + indexed_at: None, 329 + }); 330 + keeper.apply_event(&doc).unwrap(); 331 + 332 + assert_eq!(sink.count(), start); // no watcher fire 333 + } 334 + 335 + #[test] 336 + fn multiple_watchers_same_directory_all_fire() { 337 + let mut keeper = cabinet_keeper(); 338 + let a = RecordingSink::new(); 339 + let b = RecordingSink::new(); 340 + let c = RecordingSink::new(); 341 + 342 + keeper.watch_cabinet(ROOT_URI.into(), a.callback()); 343 + keeper.watch_cabinet(ROOT_URI.into(), b.callback()); 344 + keeper.watch_cabinet(ROOT_URI.into(), c.callback()); 345 + 346 + let [a0, b0, c0] = [a.count(), b.count(), c.count()]; 347 + 348 + keeper 349 + .apply_event(&sse_dir_upsert(ROOT_URI, "/", vec![])) 350 + .unwrap(); 351 + 352 + assert_eq!(a.count(), a0 + 1); 353 + assert_eq!(b.count(), b0 + 1); 354 + assert_eq!(c.count(), c0 + 1); 355 + }
+1 -1
crates/opake-wasm/Cargo.toml
··· 22 22 23 23 # Async Mutex for the shared WasmOpake — RefCell panics on concurrent 24 24 # borrow across await points; Mutex queues instead. 25 - futures-util = { version = "0.3", default-features = false, features = ["std"] } 25 + futures-util = { workspace = true } 26 26 27 27 # OsRng on wasm32 needs crypto.getRandomValues() 28 28 getrandom = { version = "0.2", features = ["js"] }
+4
crates/opake-wasm/src/file_manager_wasm.rs
··· 16 16 17 17 use futures_util::lock::Mutex; 18 18 use opake_core::manager::{FileContext, UploadRequest}; 19 + use opake_core::tree_keeper::TreeKeeper; 19 20 use serde::Serialize; 20 21 use wasm_bindgen::prelude::*; 21 22 ··· 29 30 #[wasm_bindgen(js_name = FileManager)] 30 31 pub struct WasmFileManagerHandle { 31 32 pub(crate) opake: Rc<Mutex<Option<WasmOpake>>>, 33 + /// Shared TreeKeeper cloned from the parent OpakeContext. Used for 34 + /// SSE-driven watcher registration. 35 + pub(crate) tree_keeper: Rc<Mutex<TreeKeeper>>, 32 36 pub(crate) context: Option<FileContext>, 33 37 } 34 38
+2
crates/opake-wasm/src/lib.rs
··· 23 23 #[cfg(target_arch = "wasm32")] 24 24 mod opake_wasm; 25 25 #[cfg(target_arch = "wasm32")] 26 + mod sse_wasm; 27 + #[cfg(target_arch = "wasm32")] 26 28 pub(crate) mod wasm_util; 27 29 28 30 #[wasm_bindgen(start)]
+18 -1
crates/opake-wasm/src/opake_wasm.rs
··· 23 23 use std::rc::Rc; 24 24 25 25 use futures_util::lock::Mutex; 26 + use opake_core::tree_keeper::TreeKeeper; 26 27 use serde::Serialize; 27 28 use wasm_bindgen::prelude::*; 28 29 ··· 39 40 40 41 #[wasm_bindgen(js_name = OpakeContext)] 41 42 pub struct WasmOpakeHandle { 42 - inner: Rc<Mutex<Option<WasmOpake>>>, 43 + pub(crate) inner: Rc<Mutex<Option<WasmOpake>>>, 44 + /// Persistent tree state + SSE watcher registry. Held behind its own 45 + /// Mutex (separate from the Opake mutex) so SSE event application and 46 + /// file operations don't block each other. 47 + pub(crate) tree_keeper: Rc<Mutex<TreeKeeper>>, 48 + /// `true` while an SSE consumer task is alive. Doubles as both the 49 + /// idempotency gate on `startSseConsumer` and the cancellation 50 + /// signal read by the consumer loop — `stopSseConsumer` clears it, 51 + /// the loop checks it after each `next_event().await` and breaks 52 + /// on `false`. Single-threaded WASM — `Cell<bool>` is enough. 53 + pub(crate) sse_started: Rc<std::cell::Cell<bool>>, 43 54 } 44 55 45 56 #[wasm_bindgen(js_class = OpakeContext)] ··· 53 64 storage_adapter: JsStorageAdapter, 54 65 ) -> Result<WasmOpakeHandle, JsError> { 55 66 let opake = make_opake_from_storage(did.as_deref(), storage_adapter).await?; 67 + let did_owned = opake.did().to_string(); 56 68 Ok(Self { 57 69 inner: Rc::new(Mutex::new(Some(opake))), 70 + tree_keeper: Rc::new(Mutex::new(TreeKeeper::new(did_owned))), 71 + sse_started: Rc::new(std::cell::Cell::new(false)), 58 72 }) 59 73 } 60 74 ··· 70 84 71 85 Ok(WasmFileManagerHandle { 72 86 opake: Rc::clone(&self.inner), 87 + tree_keeper: Rc::clone(&self.tree_keeper), 73 88 context: Some(context), 74 89 }) 75 90 } ··· 93 108 let context = workspace_context(keyring_uri, owner_did, key, rotation)?; 94 109 Ok(WasmFileManagerHandle { 95 110 opake: Rc::clone(&self.inner), 111 + tree_keeper: Rc::clone(&self.tree_keeper), 96 112 context: Some(context), 97 113 }) 98 114 } ··· 117 133 118 134 Ok(WasmFileManagerHandle { 119 135 opake: Rc::clone(&self.inner), 136 + tree_keeper: Rc::clone(&self.tree_keeper), 120 137 context: Some(context), 121 138 }) 122 139 }
+466
crates/opake-wasm/src/sse_wasm.rs
··· 1 + // WASM bindings for the SSE consumer and tree watchers. 2 + // 3 + // Exposes: 4 + // - WasmOpakeHandle::startSseConsumer(appviewUrl) 5 + // - WasmOpakeHandle::stopSseConsumer() 6 + // - WasmFileManagerHandle::watchDirectory(uri, callback) 7 + // - WasmDirectoryWatcher::close() 8 + // 9 + // The consumer loop runs via wasm_bindgen_futures::spawn_local and pulls 10 + // events from the browser's EventSource (WasmSseTransport). Each event is 11 + // dispatched to the shared TreeKeeper, which patches the affected tree in 12 + // place and fires watcher callbacks with a fresh snapshot. 13 + 14 + use std::cell::RefCell; 15 + use std::collections::HashMap; 16 + use std::rc::Rc; 17 + use std::time::Duration; 18 + 19 + use futures_util::lock::Mutex; 20 + use opake_core::client::request_sse_token; 21 + use opake_core::directories::DirectoryTree; 22 + use opake_core::sse::consumer::{JitterRng, SleepFn, SseConsumer, TokenFetcher}; 23 + use opake_core::sse::wasm_connection::WasmSseTransport; 24 + use opake_core::tree_keeper::{TreeKeeper, WatcherCallback, WatcherHandle}; 25 + use serde::Serialize; 26 + use wasm_bindgen::prelude::*; 27 + use wasm_bindgen::JsCast; 28 + 29 + use crate::file_manager_wasm::WasmFileManagerHandle; 30 + use crate::opake_wasm::WasmOpakeHandle; 31 + use crate::wasm_util::{build_snapshot, wasm_err, WasmOpake}; 32 + 33 + // --------------------------------------------------------------------------- 34 + // WasmDirectoryWatcher — returned by watchDirectory, exposes close() 35 + // --------------------------------------------------------------------------- 36 + 37 + #[wasm_bindgen(js_name = DirectoryWatcher)] 38 + pub struct WasmDirectoryWatcher { 39 + tree_keeper: Rc<Mutex<TreeKeeper>>, 40 + handle: WatcherHandle, 41 + closed: Rc<std::cell::Cell<bool>>, 42 + } 43 + 44 + #[wasm_bindgen(js_class = DirectoryWatcher)] 45 + impl WasmDirectoryWatcher { 46 + /// Stop receiving notifications. Idempotent. 47 + pub async fn close(&self) { 48 + if self.closed.get() { 49 + return; 50 + } 51 + self.closed.set(true); 52 + let mut keeper = self.tree_keeper.lock().await; 53 + keeper.unwatch(self.handle); 54 + } 55 + } 56 + 57 + // --------------------------------------------------------------------------- 58 + // WasmFileManagerHandle::watchDirectory 59 + // --------------------------------------------------------------------------- 60 + 61 + #[wasm_bindgen(js_class = FileManager)] 62 + impl WasmFileManagerHandle { 63 + /// Subscribe to live changes for a specific directory. 64 + /// 65 + /// Fires the callback once with the current snapshot on registration 66 + /// (if the context has already been loaded), and again on every SSE 67 + /// event that affects the directory. 68 + /// 69 + /// The callback receives `DirectoryTreeSnapshot | null`. A `null` 70 + /// snapshot means the watched directory has been deleted and the 71 + /// watcher has been auto-closed — no need to call `.close()` in that 72 + /// case. 73 + /// 74 + /// Returns a `DirectoryWatcher` handle. Call `.close()` to unsubscribe 75 + /// (typically from a React useEffect cleanup). 76 + #[wasm_bindgen(js_name = watchDirectory)] 77 + pub async fn watch_directory( 78 + &self, 79 + directory_uri: String, 80 + callback: js_sys::Function, 81 + ) -> Result<WasmDirectoryWatcher, JsError> { 82 + // Ensure the tree is loaded + installed before registering. 83 + self.ensure_tree_installed().await?; 84 + 85 + let mut keeper = self.tree_keeper.lock().await; 86 + let cb = js_watcher_callback(callback); 87 + 88 + // Pick scope based on the FileManager's context. 89 + let handle = match self.context.as_ref() { 90 + Some(opake_core::manager::FileContext::Cabinet(_)) => { 91 + keeper.watch_cabinet(directory_uri, cb) 92 + } 93 + Some(opake_core::manager::FileContext::Workspace(ws)) => { 94 + keeper.watch_workspace(ws.uri.clone(), directory_uri, cb) 95 + } 96 + None => return Err(JsError::new("FileManager context not available")), 97 + }; 98 + 99 + Ok(WasmDirectoryWatcher { 100 + tree_keeper: Rc::clone(&self.tree_keeper), 101 + handle, 102 + closed: Rc::new(std::cell::Cell::new(false)), 103 + }) 104 + } 105 + 106 + /// Internal: load the tree via the existing FileManager::load_tree 107 + /// path and install it in the TreeKeeper if not already present. 108 + /// This bridges the "rebuild on every load" model with the new 109 + /// "persistent tree" model until FileManager is fully migrated. 110 + async fn ensure_tree_installed(&self) -> Result<(), JsError> { 111 + // Fast path: already installed? Check without holding the opake lock. 112 + { 113 + let keeper = self.tree_keeper.lock().await; 114 + let already = match self.context.as_ref() { 115 + Some(opake_core::manager::FileContext::Cabinet(_)) => { 116 + keeper.cabinet_tree().is_some() 117 + } 118 + Some(opake_core::manager::FileContext::Workspace(ws)) => { 119 + keeper.workspace_tree(&ws.uri).is_some() 120 + } 121 + None => return Err(JsError::new("FileManager context not available")), 122 + }; 123 + if already { 124 + return Ok(()); 125 + } 126 + } 127 + 128 + // Slow path: load the tree via the existing FileManager path, then install. 129 + let (tree, scope) = { 130 + let mut guard = self.opake.lock().await; 131 + let opake = guard 132 + .as_mut() 133 + .ok_or_else(|| JsError::new("Opake context already consumed"))?; 134 + 135 + let context = self 136 + .context 137 + .as_ref() 138 + .ok_or_else(|| JsError::new("FileManager context not available"))?; 139 + 140 + let mut mgr = opake.file_manager(context); 141 + let tree = mgr.load_tree().await.map_err(wasm_err)?; 142 + 143 + // Extract the scope info before dropping mgr + guard. 144 + let scope = match context { 145 + opake_core::manager::FileContext::Cabinet(_) => TreeInstall::Cabinet, 146 + opake_core::manager::FileContext::Workspace(ws) => { 147 + TreeInstall::Workspace(ws.uri.clone(), ws.key.clone()) 148 + } 149 + }; 150 + (tree, scope) 151 + }; 152 + 153 + // Now install. We need the cabinet's private key for direct wrapping. 154 + match scope { 155 + TreeInstall::Cabinet => { 156 + let guard = self.opake.lock().await; 157 + let opake = guard 158 + .as_ref() 159 + .ok_or_else(|| JsError::new("Opake context already consumed"))?; 160 + let identity = opake 161 + .identity() 162 + .ok_or_else(|| JsError::new("no identity"))?; 163 + let private_key = identity.private_key_bytes().map_err(wasm_err)?; 164 + drop(guard); 165 + 166 + let mut keeper = self.tree_keeper.lock().await; 167 + keeper.install_cabinet_tree(tree, private_key); 168 + } 169 + TreeInstall::Workspace(uri, key) => { 170 + let mut keeper = self.tree_keeper.lock().await; 171 + keeper.install_workspace_tree(uri, tree, key); 172 + } 173 + } 174 + 175 + Ok(()) 176 + } 177 + } 178 + 179 + enum TreeInstall { 180 + Cabinet, 181 + Workspace(String, opake_core::crypto::ContentKey), 182 + } 183 + 184 + // --------------------------------------------------------------------------- 185 + // WasmOpakeHandle::startSseConsumer / stopSseConsumer 186 + // --------------------------------------------------------------------------- 187 + 188 + #[wasm_bindgen(js_class = OpakeContext)] 189 + impl WasmOpakeHandle { 190 + /// Start the SSE event consumer. Spawns a background task that 191 + /// connects to the appview's `/api/events` endpoint, pulls events, 192 + /// and dispatches them to the shared TreeKeeper. 193 + /// 194 + /// `appview_url` is optional: if omitted, the URL is resolved from 195 + /// the Opake instance's stored config (loaded during `init`). Pass 196 + /// an explicit value as a fallback for Opake instances whose config 197 + /// doesn't include an appview URL. 198 + /// 199 + /// Idempotent: subsequent calls are no-ops while an existing 200 + /// consumer is running. React StrictMode's double-mount is thus 201 + /// harmless — only one consumer task exists per OpakeContext. 202 + #[wasm_bindgen(js_name = startSseConsumer)] 203 + pub async fn start_sse_consumer(&self, appview_url: Option<String>) -> Result<(), JsError> { 204 + // Resolve the URL BEFORE flipping the started flag — if no URL 205 + // is available anywhere, we want to fail loudly without leaving 206 + // the flag in a broken state. 207 + let resolved_url = { 208 + let guard = self.inner.lock().await; 209 + let opake = guard 210 + .as_ref() 211 + .ok_or_else(|| JsError::new("Opake context already consumed"))?; 212 + opake 213 + .resolve_appview_url(appview_url.as_deref()) 214 + .map_err(wasm_err)? 215 + }; 216 + 217 + if self.sse_started.get() { 218 + log::debug!("[sse] consumer already running, ignoring startSseConsumer"); 219 + return Ok(()); 220 + } 221 + self.sse_started.set(true); 222 + 223 + let opake_rc = Rc::clone(&self.inner); 224 + let tree_keeper_rc = Rc::clone(&self.tree_keeper); 225 + let started_flag = Rc::clone(&self.sse_started); 226 + 227 + let token_fetcher = make_token_fetcher(Rc::clone(&opake_rc), resolved_url.clone()); 228 + let sleep_fn: SleepFn = Box::new(|d| Box::pin(wasm_sleep(d))); 229 + let jitter_fn: JitterRng = Box::new(|| js_sys::Math::random()); 230 + 231 + let mut consumer = SseConsumer::new( 232 + WasmSseTransport::new(), 233 + resolved_url, 234 + token_fetcher, 235 + sleep_fn, 236 + jitter_fn, 237 + ); 238 + 239 + wasm_bindgen_futures::spawn_local(async move { 240 + loop { 241 + let event = match consumer.next_event().await { 242 + Ok(e) => e, 243 + Err(e) => { 244 + log::warn!("[sse] consumer terminated: {e}"); 245 + break; 246 + } 247 + }; 248 + 249 + // `stop_sse_consumer` signals termination by clearing 250 + // the started flag. Check after every await so we 251 + // don't apply one last event after the owning 252 + // component unmounted. 253 + if !started_flag.get() { 254 + log::debug!("[sse] consumer stopped, exiting"); 255 + break; 256 + } 257 + 258 + if event.is_proposal() { 259 + if let Some(keyring_uri) = event.keyring_uri() { 260 + schedule_proposal_sync(Rc::clone(&opake_rc), keyring_uri.to_string()); 261 + } else { 262 + // Unroutable proposal — in practice a 263 + // `documentUpdate` (the lexicon has no 264 + // `keyring` field). The appview routes it 265 + // to the author's personal topic, so the 266 + // workspace owner never sees it and the web 267 + // client has no polling fallback to fill 268 + // the gap. Tracked in the cleanup sweep. 269 + log::debug!( 270 + "[sse] unroutable proposal event (no keyring_uri): {:?}", 271 + event 272 + ); 273 + } 274 + } else { 275 + let mut keeper = tree_keeper_rc.lock().await; 276 + if let Err(e) = keeper.apply_event(&event) { 277 + log::warn!("[sse] tree_keeper apply failed: {e}"); 278 + } 279 + } 280 + } 281 + // Task exited — clear the flag in case we broke on a 282 + // transport error rather than an explicit stop, so a 283 + // future start spawns a fresh consumer. 284 + started_flag.set(false); 285 + drop(opake_rc); 286 + }); 287 + 288 + Ok(()) 289 + } 290 + 291 + /// Stop the SSE consumer and wipe decrypted tree state from memory. 292 + /// 293 + /// Synchronous from JS: React `useEffect` cleanup is sync, so the 294 + /// TreeKeeper drain (which needs an async lock) is fire-and-forget 295 + /// on the event loop. Setting `sse_started = false` is enough on 296 + /// its own to terminate the consumer loop on its next event — 297 + /// `uninstall_all` is what zeroes any cached `ContentKey`s. 298 + #[wasm_bindgen(js_name = stopSseConsumer)] 299 + pub fn stop_sse_consumer(&self) { 300 + self.sse_started.set(false); 301 + PROPOSAL_DEBOUNCE_GENERATIONS.with(|state| state.borrow_mut().clear()); 302 + 303 + let tree_keeper = Rc::clone(&self.tree_keeper); 304 + wasm_bindgen_futures::spawn_local(async move { 305 + let mut keeper = tree_keeper.lock().await; 306 + keeper.uninstall_all(); 307 + log::debug!("[sse] tree_keeper drained on stopSseConsumer"); 308 + }); 309 + } 310 + } 311 + 312 + // --------------------------------------------------------------------------- 313 + // Internal helpers 314 + // --------------------------------------------------------------------------- 315 + 316 + /// Build a token fetcher closure that uses the shared Opake to request 317 + /// a fresh SSE token on every connect attempt. 318 + fn make_token_fetcher(opake_rc: Rc<Mutex<Option<WasmOpake>>>, appview_url: String) -> TokenFetcher { 319 + Box::new(move || { 320 + let opake_rc = Rc::clone(&opake_rc); 321 + let appview_url = appview_url.clone(); 322 + Box::pin(async move { 323 + let guard = opake_rc.lock().await; 324 + let opake = guard 325 + .as_ref() 326 + .ok_or_else(|| opake_core::error::Error::Sse("opake consumed".into()))?; 327 + let did = opake.did().to_string(); 328 + let identity = opake 329 + .identity() 330 + .ok_or_else(|| opake_core::error::Error::Sse("no identity".into()))?; 331 + // Ed25519 signing key — used for appview auth signatures. 332 + let signing_key = identity 333 + .signing_key_bytes() 334 + .map_err(|e| opake_core::error::Error::Sse(format!("{e}")))? 335 + .ok_or_else(|| { 336 + opake_core::error::Error::Sse("identity has no signing key".into()) 337 + })?; 338 + let transport = opake_core::client::WasmTransport::new(); 339 + request_sse_token(&transport, &appview_url, &did, &signing_key).await 340 + }) 341 + }) 342 + } 343 + 344 + // Per-keyring debounce state. Increments on every `schedule_proposal_sync` 345 + // call; the delayed task re-checks the generation before firing so a 346 + // burst of events for the same keyring collapses into a single sync. 347 + // `stop_sse_consumer` clears the map on teardown and the owning task 348 + // clears its own entry on successful fire, so long-running sessions 349 + // that touch many workspaces don't leak URIs indefinitely. 350 + thread_local! { 351 + static PROPOSAL_DEBOUNCE_GENERATIONS: RefCell<HashMap<String, u64>> = 352 + RefCell::new(HashMap::new()); 353 + } 354 + 355 + /// Coalesce window for proposal events targeting the same workspace. 356 + /// Long enough to collapse a flurry of three-event record-echoes, short 357 + /// enough that collaborative edits still feel live. 358 + const PROPOSAL_DEBOUNCE_WINDOW: Duration = Duration::from_millis(2_000); 359 + 360 + /// Schedule a debounced workspace sync. Fire-and-forget: returns before 361 + /// the task spawns so the SSE consumer loop keeps pulling events. 362 + fn schedule_proposal_sync(opake_rc: Rc<Mutex<Option<WasmOpake>>>, keyring_uri: String) { 363 + let generation = PROPOSAL_DEBOUNCE_GENERATIONS.with(|state| { 364 + let mut state = state.borrow_mut(); 365 + let entry = state.entry(keyring_uri.clone()).or_insert(0); 366 + *entry += 1; 367 + *entry 368 + }); 369 + 370 + wasm_bindgen_futures::spawn_local(async move { 371 + wasm_sleep(PROPOSAL_DEBOUNCE_WINDOW).await; 372 + 373 + // If another event bumped the generation for this keyring while 374 + // we were sleeping, that newer task will run the sync — stand 375 + // down and leave its counter in place for it to clean up. 376 + let still_current = PROPOSAL_DEBOUNCE_GENERATIONS.with(|state| { 377 + let state = state.borrow(); 378 + state.get(&keyring_uri).copied() == Some(generation) 379 + }); 380 + if !still_current { 381 + log::debug!("[sse] proposal sync superseded for {keyring_uri}"); 382 + return; 383 + } 384 + 385 + // We're the authoritative task for this generation — remove the 386 + // entry before firing so the map stays bounded. A later event 387 + // can freely re-register; it'll start the counter back at 1. 388 + PROPOSAL_DEBOUNCE_GENERATIONS.with(|state| { 389 + state.borrow_mut().remove(&keyring_uri); 390 + }); 391 + 392 + dispatch_proposal_sync(&opake_rc, &keyring_uri).await; 393 + }); 394 + } 395 + 396 + /// Acquire the Opake lock and call `sync_workspace_by_uri`. Called only 397 + /// from the debounced scheduler — never from the consumer loop directly. 398 + async fn dispatch_proposal_sync(opake_rc: &Rc<Mutex<Option<WasmOpake>>>, keyring_uri: &str) { 399 + let mut guard = opake_rc.lock().await; 400 + let Some(opake) = guard.as_mut() else { 401 + log::warn!("[sse] proposal sync: opake unavailable"); 402 + return; 403 + }; 404 + match opake.sync_workspace_by_uri(keyring_uri).await { 405 + Ok(Some(result)) => { 406 + if result.proposals_applied > 0 { 407 + log::info!( 408 + "[sse] applied {} proposals on {}", 409 + result.proposals_applied, 410 + keyring_uri 411 + ); 412 + } 413 + } 414 + Ok(None) => { 415 + // Not a member of this keyring — silently drop. 416 + } 417 + Err(e) => { 418 + log::warn!("[sse] proposal sync failed for {keyring_uri}: {e}"); 419 + } 420 + } 421 + } 422 + 423 + /// Promise-based sleep using JS `setTimeout`. Works in any context with 424 + /// a global `setTimeout` (Window, Worker). 425 + async fn wasm_sleep(duration: Duration) { 426 + let ms = duration.as_millis() as i32; 427 + let promise = js_sys::Promise::new(&mut |resolve, _reject| { 428 + // Use global setTimeout — works in both Window and Worker. 429 + let global = js_sys::global(); 430 + let set_timeout = js_sys::Reflect::get(&global, &JsValue::from_str("setTimeout")) 431 + .ok() 432 + .and_then(|v| v.dyn_into::<js_sys::Function>().ok()); 433 + if let Some(f) = set_timeout { 434 + let _ = f.call2(&global, &resolve, &JsValue::from_f64(ms as f64)); 435 + } 436 + }); 437 + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; 438 + } 439 + 440 + /// Wrap a JS function as a `WatcherCallback` that builds a snapshot on 441 + /// each notification and serializes it to a JS object. 442 + fn js_watcher_callback(callback: js_sys::Function) -> WatcherCallback { 443 + Box::new(move |tree: Option<&DirectoryTree>| { 444 + let snapshot_js = match tree { 445 + Some(t) => { 446 + let snapshot = build_snapshot(t); 447 + let serializer = 448 + serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); 449 + match snapshot.serialize(&serializer) { 450 + Ok(v) => v, 451 + Err(e) => { 452 + log::warn!("[sse] snapshot serialize failed: {e}"); 453 + return; 454 + } 455 + } 456 + } 457 + None => JsValue::NULL, 458 + }; 459 + // Invoke the JS callback. Errors propagate as JS exceptions into 460 + // the WASM task — log but don't panic (one broken React component 461 + // shouldn't crash the event loop). 462 + if let Err(e) = callback.call1(&JsValue::NULL, &snapshot_js) { 463 + log::warn!("[sse] watcher callback threw: {e:?}"); 464 + } 465 + }) 466 + }
+1
crates/opake-wasm/src/wasm_util.rs
··· 75 75 Error::Serialization(_) => "Serialization", 76 76 Error::Mnemonic(_) => "Mnemonic", 77 77 Error::Storage(_) => "Storage", 78 + Error::Sse(_) => "Sse", 78 79 }; 79 80 JsError::new(&format!("{kind}: {e}")) 80 81 }
+155
eslint.config.base.ts
··· 1 + // Shared ESLint flat-config base for every TypeScript workspace in the repo. 2 + // 3 + // Each package's `eslint.config.ts` imports `makeBaseConfig` and passes its 4 + // own `tsconfigRootDir`, then layers package-specific overrides on top. 5 + // This keeps rule drift between SDK, daemon, react, and web to a minimum 6 + // — if a rule is worth enforcing in one, it's worth enforcing in all. 7 + // 8 + // The base intentionally includes `eslint-plugin-react-hooks` — it's a 9 + // no-op on files that don't use hooks, and shipping it everywhere means 10 + // a package that later adds a hook gets the rules for free. 11 + // 12 + // Packages that render JSX for end users (apps/web) layer jsx-a11y and 13 + // Tailwind sort rules on top separately — they'd be noisy dead weight on 14 + // the hooks-only @opake/react package. 15 + 16 + import tseslint from "typescript-eslint"; 17 + import reactHooks from "eslint-plugin-react-hooks"; 18 + import functional from "eslint-plugin-functional"; 19 + import security from "eslint-plugin-security"; 20 + import noSecrets from "eslint-plugin-no-secrets"; 21 + import sonarjs from "eslint-plugin-sonarjs"; 22 + import prettier from "eslint-config-prettier"; 23 + 24 + export interface BaseConfigOptions { 25 + /** 26 + * Absolute path to the package directory that contains `tsconfig.json`. 27 + * Pass `import.meta.dirname` from the package's `eslint.config.ts`. 28 + */ 29 + readonly tsconfigRootDir: string; 30 + } 31 + 32 + /** 33 + * Build the shared ESLint flat-config array. Callers spread the result 34 + * and append package-specific overrides (route globs, test globs, etc.). 35 + */ 36 + export function makeBaseConfig(options: BaseConfigOptions) { 37 + return tseslint.config( 38 + // ------------------------------------------------------------------------- 39 + // Global ignores (per-package configs can add more) 40 + // ------------------------------------------------------------------------- 41 + { 42 + ignores: ["dist/**", "node_modules/**", "**/*.d.ts"], 43 + }, 44 + 45 + // ------------------------------------------------------------------------- 46 + // TypeScript — strict + type-checked (includes eslint:recommended) 47 + // ------------------------------------------------------------------------- 48 + ...tseslint.configs.strictTypeChecked, 49 + ...tseslint.configs.stylisticTypeChecked, 50 + { 51 + languageOptions: { 52 + parserOptions: { 53 + projectService: true, 54 + tsconfigRootDir: options.tsconfigRootDir, 55 + }, 56 + }, 57 + }, 58 + 59 + // ------------------------------------------------------------------------- 60 + // React hooks — no-op on non-hook files, catches real bugs on hooks 61 + // ------------------------------------------------------------------------- 62 + reactHooks.configs.flat.recommended, 63 + 64 + // ------------------------------------------------------------------------- 65 + // Functional — immutability enforcement 66 + // ------------------------------------------------------------------------- 67 + { 68 + plugins: { functional: functional }, 69 + rules: { 70 + "functional/immutable-data": "error", 71 + "functional/no-let": "error", 72 + "functional/no-loop-statements": "error", 73 + "functional/prefer-immutable-types": [ 74 + "error", 75 + { 76 + enforcement: "None", 77 + ignoreInferredTypes: true, 78 + parameters: { enforcement: "None" }, 79 + returnTypes: { enforcement: "None" }, 80 + variables: { enforcement: "ReadonlyShallow" }, 81 + }, 82 + ], 83 + }, 84 + }, 85 + 86 + // ------------------------------------------------------------------------- 87 + // Security 88 + // ------------------------------------------------------------------------- 89 + security.configs.recommended, 90 + sonarjs.configs.recommended, 91 + { 92 + plugins: { "no-secrets": noSecrets }, 93 + rules: { 94 + "no-secrets/no-secrets": ["error", { tolerance: 5.5 }], 95 + }, 96 + }, 97 + 98 + // ------------------------------------------------------------------------- 99 + // Prettier — MUST be last (disables conflicting format rules) 100 + // ------------------------------------------------------------------------- 101 + prettier, 102 + 103 + // ------------------------------------------------------------------------- 104 + // Shared rule tuning 105 + // ------------------------------------------------------------------------- 106 + { 107 + rules: { 108 + // Fire-and-forget promises with `void store.boot()` are idiomatic 109 + "@typescript-eslint/no-confusing-void-expression": "off", 110 + // Console is acceptable — we ship to dev tooling, not production-silent 111 + "no-console": "off", 112 + // Numbers in template literals are safe and common 113 + "@typescript-eslint/restrict-template-expressions": [ 114 + "error", 115 + { allowNumber: true }, 116 + ], 117 + // Optional chains are safer than non-null assertions 118 + "@typescript-eslint/non-nullable-type-assertion-style": "off", 119 + // Persistent false positive with zod `.then(schema.parse)` and 120 + // similar fp-style method references. The SDK uses this pattern 121 + // everywhere and it works because zod binds `parse` to the schema. 122 + "@typescript-eslint/unbound-method": "off", 123 + // `type` vs `interface` is a style preference. The SDK uses 124 + // `type` throughout for readonly-friendly records — don't fight it. 125 + "@typescript-eslint/consistent-type-definitions": "off", 126 + 127 + // --- sonarjs rules that overlap with typescript-eslint --- 128 + "sonarjs/no-unused-vars": "off", 129 + "sonarjs/no-dead-store": "off", 130 + "sonarjs/deprecation": "off", 131 + // TODOs are intentional markers referencing issue numbers 132 + "sonarjs/todo-tag": "off", 133 + 134 + // High false-positive rate with bracket notation on typed objects 135 + "security/detect-object-injection": "off", 136 + 137 + // --- sonarjs rules that don't suit React / TS codebases --- 138 + "sonarjs/no-nested-template-literals": "off", 139 + "sonarjs/no-nested-functions": "off", 140 + "sonarjs/no-nested-conditional": "off", 141 + 142 + // Allow local object construction patterns and browser-API mutation 143 + "functional/immutable-data": [ 144 + "error", 145 + { 146 + ignoreClasses: true, 147 + ignoreImmediateMutation: true, 148 + ignoreNonConstDeclarations: true, 149 + ignoreAccessorPattern: ["window.**", "document.**", "**.current"], 150 + }, 151 + ], 152 + }, 153 + }, 154 + ); 155 + }
+19 -1
package.json
··· 1 1 { 2 2 "private": true, 3 - "workspaces": ["apps/web", "packages/*", "tests"] 3 + "workspaces": ["apps/web", "packages/*", "tests"], 4 + "scripts": { 5 + "lint": "bun run --filter '@opake/sdk' --filter '@opake/daemon' --filter '@opake/react' --filter opake-web lint", 6 + "format": "bun run --filter '@opake/sdk' --filter '@opake/daemon' --filter '@opake/react' --filter opake-web format", 7 + "format:check": "bun run --filter '@opake/sdk' --filter '@opake/daemon' --filter '@opake/react' --filter opake-web format:check" 8 + }, 9 + "devDependencies": { 10 + "eslint": "^10.0.3", 11 + "eslint-config-prettier": "^10.1.8", 12 + "eslint-plugin-functional": "^9.0.4", 13 + "eslint-plugin-jsx-a11y": "^6.10.2", 14 + "eslint-plugin-no-secrets": "^2.3.3", 15 + "eslint-plugin-react-hooks": "^7.0.1", 16 + "eslint-plugin-security": "^4.0.0", 17 + "eslint-plugin-sonarjs": "^4.0.1", 18 + "prettier": "^3.8.1", 19 + "typescript": "^5.7.0", 20 + "typescript-eslint": "^8.56.1" 21 + } 4 22 }
+38
packages/opake-daemon/eslint.config.ts
··· 1 + import tseslint from "typescript-eslint"; 2 + import { makeBaseConfig } from "../../eslint.config.base.ts"; 3 + 4 + export default tseslint.config( 5 + ...makeBaseConfig({ tsconfigRootDir: import.meta.dirname }), 6 + 7 + // --------------------------------------------------------------------------- 8 + // Overrides: scheduler (timer-handle accumulation + imperative lifecycle) 9 + // 10 + // `setInterval` returns an opaque handle we have to track in a list 11 + // so `stop()` can clear each one. That's fundamentally mutable state 12 + // — the functional rules don't fit the platform-API shape here. 13 + // --------------------------------------------------------------------------- 14 + { 15 + files: ["src/scheduler.ts"], 16 + rules: { 17 + "functional/no-let": "off", 18 + "functional/no-loop-statements": "off", 19 + "functional/immutable-data": "off", 20 + "functional/prefer-immutable-types": "off", 21 + }, 22 + }, 23 + 24 + // --------------------------------------------------------------------------- 25 + // Overrides: test files 26 + // --------------------------------------------------------------------------- 27 + { 28 + files: ["src/__tests__/**/*.ts", "src/**/*.test.ts"], 29 + rules: { 30 + "functional/no-let": "off", 31 + "functional/immutable-data": "off", 32 + "@typescript-eslint/no-unsafe-assignment": "off", 33 + "@typescript-eslint/no-unsafe-member-access": "off", 34 + "@typescript-eslint/no-unsafe-call": "off", 35 + "@typescript-eslint/no-non-null-assertion": "off", 36 + }, 37 + }, 38 + );
+5 -1
packages/opake-daemon/package.json
··· 18 18 ], 19 19 "scripts": { 20 20 "build": "tsup", 21 - "test": "vitest run" 21 + "test": "vitest run", 22 + "lint": "eslint src/", 23 + "lint:fix": "eslint src/ --fix", 24 + "format": "prettier --write 'src/**/*.ts'", 25 + "format:check": "prettier --check 'src/**/*.ts'" 22 26 }, 23 27 "dependencies": { 24 28 "@opake/sdk": "workspace:*"
+1 -2
packages/opake-daemon/src/index.ts
··· 1 1 // @opake/daemon — background task scheduling for browser environments 2 2 3 3 export { startDaemon, type DaemonHandle } from "./scheduler"; 4 - export type { DaemonOptions, SSEConfig, TaskDef, TaskRecord, TaskStatus, TaskStore } from "./types"; 5 - export type { SSEConsumerHandle } from "./sse-consumer"; 4 + export type { DaemonOptions, TaskDef, TaskRecord, TaskStatus, TaskStore } from "./types";
+17 -46
packages/opake-daemon/src/scheduler.ts
··· 1 1 // Daemon scheduler — Web Locks leader election + setInterval orchestration. 2 2 // 3 - // When `options.sse` is provided, SSE-driven proposal sync replaces the 4 - // `directory-sync` timer. Other tasks (pair-cleanup, grant-healing, 5 - // share-retry) always run on intervals. 3 + // Runs whichever tasks have handlers in `runTasks` (pair-cleanup, 4 + // grant-healing, share-retry). Task definitions without a matching 5 + // handler are silently skipped — see `tasks.ts` and the `directory-sync` 6 + // note in `crates/opake-core/src/daemon.rs` for why `directory-sync` 7 + // isn't wired here. Live tree updates come from the WASM SSE consumer, 8 + // not this scheduler. 6 9 // 7 10 // Returns a DaemonHandle that the caller uses to stop the daemon. 8 11 // No module-level state — multiple handles can coexist (though only one 9 12 // leader runs per Web Locks scope). 10 13 11 14 import type { Opake } from "@opake/sdk"; 12 - import type { DaemonOptions, SSEConfig, TaskDef, TaskStore } from "./types"; 15 + import type { DaemonOptions, TaskDef, TaskStore } from "./types"; 13 16 import { runTasks } from "./tasks"; 14 - import { startSSEConsumer, type SSEConsumerHandle } from "./sse-consumer"; 15 17 16 18 const DEFAULT_INITIAL_DELAY_MS = 5_000; 17 19 const DEFAULT_PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days 18 - // Reduced interval for directory-sync when SSE is active. SSE handles most 19 - // sync, but document_update proposals can't route to workspace topics (the 20 - // lexicon has no keyring field) so this timer catches what SSE misses. 21 - const DIRECTORY_SYNC_FALLBACK_MS = 60_000; 22 20 23 21 /** Handle returned by `startDaemon` — call `stop()` to shut down. */ 24 22 export interface DaemonHandle { 25 - /** Stop all intervals, SSE subscription, and release the leader lock. */ 23 + /** Stop all intervals and release the leader lock. */ 26 24 stop(): void; 27 25 } 28 26 ··· 33 31 * background tasks. Schedules all tasks from the core registry at their 34 32 * configured intervals. 35 33 * 36 - * When `options.sse` is provided, SSE-driven proposal sync replaces the 37 - * normal `directory-sync` interval — proposal events trigger immediate 38 - * targeted syncs, and the `directory-sync` timer downgrades to a 39 - * low-frequency fallback for events SSE can't route. 34 + * For live tree updates, pair this with `opake.startSseConsumer(appviewUrl)` 35 + * and `fileManager.watchDirectory(uri, handler)` — the WASM-side consumer 36 + * handles SSE events directly and patches trees in place. 40 37 * 41 38 * @returns A handle to stop the daemon. 42 39 * ··· 46 43 * import { startDaemon } from "@opake/daemon"; 47 44 * 48 45 * const opake = await Opake.init(); 46 + * await opake.startSseConsumer(); 49 47 * const daemon = startDaemon(opake, taskDefs, taskStore, { 50 - * sse: { 51 - * appviewUrl: "https://appview.opake.app", 52 - * onRecordChanged: () => reloadCurrentView(), 53 - * }, 54 - * onWorkspaceUpdated: (uris) => reloadWorkspace(uris), 48 + * onSessionExpired: () => redirectToLogin(), 55 49 * }); 56 50 * 57 51 * // Later: 58 52 * daemon.stop(); 53 + * opake.stopSseConsumer(); 59 54 * ``` 60 55 */ 61 56 export function startDaemon( ··· 65 60 options?: DaemonOptions, 66 61 ): DaemonHandle { 67 62 const intervalIds: ReturnType<typeof setInterval>[] = []; 68 - // eslint-disable-next-line functional/no-let -- mutable ref for cleanup 69 63 let releaseLock: (() => void) | null = null; 70 - // eslint-disable-next-line functional/no-let -- mutable ref for cleanup 71 - let sseConsumer: SSEConsumerHandle | null = null; 72 - 73 - const sse: SSEConfig | undefined = options?.sse; 74 64 75 65 function stop(): void { 76 - sseConsumer?.close(); 77 - sseConsumer = null; 78 66 for (const id of intervalIds) { 79 67 clearInterval(id); 80 68 } ··· 93 81 const handler = handlers[task.name]; 94 82 if (!handler) continue; 95 83 96 - // When SSE is active, directory-sync becomes a low-frequency fallback 97 - // (60s) instead of the normal interval. SSE handles most proposal sync, 98 - // but document_update events can't be workspace-routed (no keyring_uri 99 - // in the lexicon) so the timer catches what SSE misses. 100 - const intervalMs = (sse && task.name === "directory-sync") 101 - ? DIRECTORY_SYNC_FALLBACK_MS 102 - : task.intervalSeconds * 1000; 103 - 84 + const intervalMs = task.intervalSeconds * 1000; 104 85 setTimeout(() => void handler(), initialDelay); 105 86 intervalIds.push(setInterval(() => void handler(), intervalMs)); 106 87 } 107 - 108 - if (sse) { 109 - sseConsumer = startSSEConsumer(opake, taskStore, options ?? {}, sse); 110 - } 111 88 } 112 89 113 90 const hasWebLocks = typeof navigator !== "undefined" && "locks" in navigator; 114 91 115 92 if (hasWebLocks) { 116 93 void navigator.locks.request("opake-daemon-leader", async () => { 117 - await pruneOldTasks( 118 - taskStore, 119 - options?.pruneAgeMs ?? DEFAULT_PRUNE_AGE_MS, 120 - ); 94 + await pruneOldTasks(taskStore, options?.pruneAgeMs ?? DEFAULT_PRUNE_AGE_MS); 121 95 scheduleAll(); 122 96 await new Promise<void>((resolve) => { 123 97 releaseLock = resolve; ··· 131 105 return { stop }; 132 106 } 133 107 134 - async function pruneOldTasks( 135 - taskStore: TaskStore, 136 - pruneAgeMs: number, 137 - ): Promise<void> { 108 + async function pruneOldTasks(taskStore: TaskStore, pruneAgeMs: number): Promise<void> { 138 109 const all = await taskStore.loadTasks(); 139 110 const cutoff = new Date(Date.now() - pruneAgeMs).toISOString(); 140 111 const stale = all.filter((t) => t.updatedAt < cutoff);
-177
packages/opake-daemon/src/sse-consumer.ts
··· 1 - // SSE-driven proposal sync. Reacts to appview events instead of polling, 2 - // debouncing per-keyring so rapid proposal bursts collapse into one sync. 3 - // Events from our own DID within a short window after a local write are 4 - // suppressed to avoid redundant reloads. 5 - 6 - import type { Opake, EventStream, SSEKeyring } from "@opake/sdk"; 7 - import type { DaemonOptions, SSEConfig, TaskStore } from "./types"; 8 - import { persistSyncResult, handleDaemonError } from "./tasks"; 9 - 10 - const PROPOSAL_DEBOUNCE_MS = 2_000; 11 - const RECORD_CHANGED_DEBOUNCE_MS = 500; 12 - const SELF_EVENT_SUPPRESS_MS = 3_000; 13 - 14 - /** Shape common to all record events — what we need for self-event filtering. */ 15 - interface RecordEvent { 16 - readonly owner_did: string; 17 - } 18 - 19 - /** Shape common to all proposal events — what we need for routing. */ 20 - interface ProposalEvent { 21 - readonly author_did: string; 22 - readonly keyring_uri?: string | null; 23 - } 24 - 25 - export interface SSEConsumerHandle { 26 - close(): void; 27 - } 28 - 29 - /** 30 - * Subscribe to SSE and sync workspaces when proposal events arrive. 31 - * 32 - * Record events fire `sse.onRecordChanged` (unless suppressed as self-events). 33 - * Proposal events trigger debounced per-workspace sync, which fires 34 - * `options.onWorkspaceUpdated` on proposal application. 35 - */ 36 - export function startSSEConsumer( 37 - opake: Opake, 38 - taskStore: TaskStore, 39 - options: DaemonOptions, 40 - sse: SSEConfig, 41 - ): SSEConsumerHandle { 42 - const proposalTimers = new Map<string, ReturnType<typeof setTimeout>>(); 43 - // Reserved key for full-sync debounce — cannot collide with a real AT URI 44 - // since those always start with "at://". 45 - const FULL_SYNC_KEY = "__full__"; 46 - 47 - // eslint-disable-next-line functional/no-let -- mutable ref for cleanup 48 - let stream: EventStream | null = null; 49 - // eslint-disable-next-line functional/no-let -- mutable ref for debounce 50 - let recordChangedTimer: ReturnType<typeof setTimeout> | null = null; 51 - 52 - // DID resolved once at startup. The daemon is torn down on account switch, 53 - // so staleness isn't a concern. 54 - const selfDid = opake.getDid(); 55 - 56 - function isSelfEvent(did: string | null | undefined): boolean { 57 - if (!selfDid || !did || did !== selfDid) return false; 58 - return (Date.now() - opake.lastWriteAt) < SELF_EVENT_SUPPRESS_MS; 59 - } 60 - 61 - /** Leading-edge debounce: schedule one reload per burst window. */ 62 - function notifyRecordChanged(did?: string | null): void { 63 - if (isSelfEvent(did)) return; 64 - if (recordChangedTimer) return; 65 - recordChangedTimer = setTimeout(() => { 66 - recordChangedTimer = null; 67 - sse.onRecordChanged?.(); 68 - }, RECORD_CHANGED_DEBOUNCE_MS); 69 - } 70 - 71 - /** Trailing-edge debounce per keyring. Rapid bursts collapse into one sync. */ 72 - function debouncedSync(keyringUri: string | null): void { 73 - const key = keyringUri ?? FULL_SYNC_KEY; 74 - const existing = proposalTimers.get(key); 75 - if (existing) clearTimeout(existing); 76 - 77 - proposalTimers.set( 78 - key, 79 - setTimeout(() => { 80 - proposalTimers.delete(key); 81 - void (keyringUri ? syncSingleWorkspace(keyringUri) : syncAllWorkspaces()); 82 - }, PROPOSAL_DEBOUNCE_MS), 83 - ); 84 - } 85 - 86 - async function syncSingleWorkspace(keyringUri: string): Promise<void> { 87 - try { 88 - const result = await opake.syncWorkspaceByUri(keyringUri); 89 - if (result && (result.proposalsApplied > 0 || result.error)) { 90 - await persistSyncResult(taskStore, result); 91 - if (result.proposalsApplied > 0) { 92 - options.onWorkspaceUpdated?.([keyringUri]); 93 - } 94 - } 95 - } catch (err) { 96 - handleDaemonError(err, options); 97 - } 98 - } 99 - 100 - async function syncAllWorkspaces(): Promise<void> { 101 - try { 102 - const results = await opake.syncOwnedWorkspacesDetailed(); 103 - const updated = results.filter((r) => r.proposalsApplied > 0); 104 - if (updated.length > 0) { 105 - await Promise.all(updated.map((r) => persistSyncResult(taskStore, r))); 106 - options.onWorkspaceUpdated?.(updated.map((r) => r.keyringUri)); 107 - } 108 - } catch (err) { 109 - handleDaemonError(err, options); 110 - } 111 - } 112 - 113 - // Generic handlers — all record events share the same "notify on non-self" 114 - // behavior; all proposal events share the same self-filter + per-keyring debounce. 115 - 116 - const handleRecordEvent = (data: RecordEvent): void => notifyRecordChanged(data.owner_did); 117 - const handleDelete = (): void => notifyRecordChanged(); 118 - 119 - function handleProposal(data: ProposalEvent): void { 120 - if (isSelfEvent(data.author_did)) return; 121 - debouncedSync(data.keyring_uri ?? null); 122 - } 123 - 124 - function handleKeyringUpsert(data: SSEKeyring): void { 125 - if (isSelfEvent(data.owner_did) || !data.uri) return; 126 - // Two paths: direct mutation (add member, rename) → sidebar needs refresh 127 - // immediately; proposal application → debouncedSync fires onWorkspaceUpdated 128 - // again later. Both call onWorkspaceUpdated. The host app's loadWorkspaces 129 - // dedupes in-flight calls and skips no-op updates, so the duplicate is free. 130 - options.onWorkspaceUpdated?.([data.uri]); 131 - debouncedSync(data.uri); 132 - } 133 - 134 - try { 135 - stream = opake.subscribe( 136 - { 137 - onDirectoryUpsert: handleRecordEvent, 138 - onDirectoryDelete: handleDelete, 139 - onDocumentUpsert: handleRecordEvent, 140 - onDocumentDelete: handleDelete, 141 - onKeyringUpsert: handleKeyringUpsert, 142 - onKeyringDelete: handleDelete, 143 - onGrantUpsert: handleRecordEvent, 144 - onGrantDelete: handleDelete, 145 - onDirectoryUpdateUpsert: handleProposal, 146 - onDirectoryUpdateDelete: handleDelete, 147 - onKeyringUpdateUpsert: handleProposal, 148 - onKeyringUpdateDelete: handleDelete, 149 - onDocumentUpdateUpsert: handleProposal, 150 - onDocumentUpdateDelete: handleDelete, 151 - onReconnect: () => { 152 - // syncAllWorkspaces fires onWorkspaceUpdated, which the host app 153 - // routes back to reloadCurrentDirectory. No separate notify needed. 154 - void syncAllWorkspaces(); 155 - }, 156 - }, 157 - sse.appviewUrl, 158 - ); 159 - } catch { 160 - // SSE connection failure is non-fatal — timer fallback still runs 161 - } 162 - 163 - function close(): void { 164 - stream?.close(); 165 - stream = null; 166 - if (recordChangedTimer) { 167 - clearTimeout(recordChangedTimer); 168 - recordChangedTimer = null; 169 - } 170 - for (const timer of proposalTimers.values()) { 171 - clearTimeout(timer); 172 - } 173 - proposalTimers.clear(); 174 - } 175 - 176 - return { close }; 177 - }
+39 -56
packages/opake-daemon/src/tasks.ts
··· 3 3 // Each handler calls operations on the Opake instance and reports results 4 4 // via the TaskStore. The handlers don't know about scheduling — they're 5 5 // called by the scheduler at the configured intervals. 6 + // 7 + // NOTE: `directory-sync` is intentionally NOT handled here. Web clients 8 + // drive proposal application via SSE events (see `sse_wasm.rs` 9 + // `dispatch_proposal_sync`) — the `directory-sync` TaskDef still exists 10 + // in the core registry for the native CLI daemon, but the web scheduler 11 + // silently skips tasks without handlers. 6 12 7 - import type { Opake, WorkspaceSyncResult } from "@opake/sdk"; 8 13 import { OpakeError } from "@opake/sdk"; 14 + import type { Opake } from "@opake/sdk"; 9 15 import type { DaemonOptions, TaskRecord, TaskStore } from "./types"; 10 16 11 17 type Handler = () => Promise<void>; ··· 18 24 ): Readonly<Record<string, Handler>> { 19 25 return { 20 26 "pair-cleanup": () => 21 - tracked(taskStore, "pair-cleanup", { type: "pairCleanup", deleted: 0 }, async () => { 22 - const deleted = await opake.cleanupExpiredPairRequests(); 23 - return { didWork: deleted > 0, kind: { type: "pairCleanup", deleted } }; 24 - }, options), 27 + tracked( 28 + taskStore, 29 + "pair-cleanup", 30 + { type: "pairCleanup", deleted: 0 }, 31 + async () => { 32 + const deleted = await opake.cleanupExpiredPairRequests(); 33 + return { didWork: deleted > 0, kind: { type: "pairCleanup", deleted } }; 34 + }, 35 + options, 36 + ), 25 37 26 38 "grant-healing": () => 27 - tracked(taskStore, "grant-healing", { type: "grantHealing", healed: 0 }, async () => { 28 - const healed = await opake.healStaleGrants(); 29 - return { didWork: healed > 0, kind: { type: "grantHealing", healed } }; 30 - }, options), 39 + tracked( 40 + taskStore, 41 + "grant-healing", 42 + { type: "grantHealing", healed: 0 }, 43 + async () => { 44 + const healed = await opake.healStaleGrants(); 45 + return { didWork: healed > 0, kind: { type: "grantHealing", healed } }; 46 + }, 47 + options, 48 + ), 31 49 32 50 "share-retry": () => 33 - tracked(taskStore, "share-retry", { type: "shareRetry", retried: 0 }, async () => { 34 - const result = await opake.retryPendingShares(); 35 - const retried = result.completed ?? 0; 36 - return { didWork: retried > 0, kind: { type: "shareRetry", retried } }; 37 - }, options), 38 - 39 - "directory-sync": () => 40 - tracked(taskStore, "directory-sync", { type: "proposalSync", proposalsApplied: 0 }, async () => { 41 - const results = await opake.syncOwnedWorkspacesDetailed(); 42 - 43 - const totalApplied = results.reduce( 44 - (sum: number, r: WorkspaceSyncResult) => sum + r.proposalsApplied, 45 - 0, 46 - ); 47 - 48 - await Promise.all( 49 - results 50 - .filter((r: WorkspaceSyncResult) => r.proposalsApplied > 0 || r.error) 51 - .map((r: WorkspaceSyncResult) => persistSyncResult(taskStore, r)), 52 - ); 53 - 54 - if (totalApplied > 0 && options?.onWorkspaceUpdated) { 55 - const updatedUris = results 56 - .filter((r: WorkspaceSyncResult) => r.proposalsApplied > 0) 57 - .map((r: WorkspaceSyncResult) => r.keyringUri); 58 - options.onWorkspaceUpdated(updatedUris); 59 - } 60 - 61 - return { 62 - didWork: totalApplied > 0, 63 - kind: { type: "proposalSync", proposalsApplied: totalApplied }, 64 - }; 65 - }, options), 51 + tracked( 52 + taskStore, 53 + "share-retry", 54 + { type: "shareRetry", retried: 0 }, 55 + async () => { 56 + const result = await opake.retryPendingShares(); 57 + const retried = result.completed; 58 + return { didWork: retried > 0, kind: { type: "shareRetry", retried } }; 59 + }, 60 + options, 61 + ), 66 62 }; 67 63 } 68 64 ··· 102 98 * handled (caller should return early), false if it should be persisted or 103 99 * propagated. Auth errors fire `onSessionExpired` and are considered handled. 104 100 */ 105 - export function handleDaemonError(err: unknown, options?: DaemonOptions): boolean { 101 + function handleDaemonError(err: unknown, options?: DaemonOptions): boolean { 106 102 if (err instanceof OpakeError && err.kind === "Auth") { 107 103 options?.onSessionExpired?.(); 108 104 return true; 109 105 } 110 106 return false; 111 - } 112 - 113 - /** Persist a single workspace sync result as a task record. */ 114 - export async function persistSyncResult( 115 - taskStore: TaskStore, 116 - result: WorkspaceSyncResult, 117 - ): Promise<void> { 118 - await persistTask( 119 - taskStore, 120 - `sync-${result.keyringUri}`, 121 - { type: "proposalSync", keyringUri: result.keyringUri, proposalsApplied: result.proposalsApplied }, 122 - result.error ? { failed: result.error } : "completed", 123 - ); 124 107 } 125 108 126 109 async function persistTask(
+8 -32
packages/opake-daemon/src/types.ts
··· 21 21 } 22 22 23 23 /** 24 - * SSE-specific daemon configuration. Presence of this field switches the 25 - * daemon into event-driven mode: `directory-sync` becomes a low-frequency 26 - * fallback timer while SSE events drive targeted per-workspace sync. 27 - * Absence keeps the daemon in classic timer-polling mode. 24 + * Configuration for the daemon scheduler. 25 + * 26 + * The web daemon runs only the maintenance tasks that don't benefit 27 + * from SSE: pair-cleanup, grant-healing, share-retry. The core 28 + * `directory-sync` TaskDef is skipped here because web clients drive 29 + * proposal application via SSE events (`opake.startSseConsumer`). 30 + * The native CLI daemon still polls `directory-sync` until it grows 31 + * its own SSE consumer. 28 32 */ 29 - export interface SSEConfig { 30 - /** Appview base URL for the SSE stream + token exchange. */ 31 - readonly appviewUrl: string; 32 - 33 - /** 34 - * Called when any SSE event arrives that affects directory/document 35 - * state (excluding self-events). Unlike `onWorkspaceUpdated`, this fires 36 - * for every non-self record mutation so the host app can reload the 37 - * current view. 38 - */ 39 - readonly onRecordChanged?: () => void; 40 - } 41 - 42 - /** Configuration for the daemon scheduler. */ 43 33 export interface DaemonOptions { 44 34 /** 45 35 * Delay before the first task execution (ms). Gives the app time to ··· 53 43 * @default 604800000 (7 days) 54 44 */ 55 45 readonly pruneAgeMs?: number; 56 - 57 - /** 58 - * SSE streaming config. When present, the daemon subscribes to the 59 - * appview's event stream and reacts to proposal events with targeted 60 - * per-workspace sync. When absent, all sync happens via interval polling. 61 - */ 62 - readonly sse?: SSEConfig; 63 - 64 - /** 65 - * Called when a workspace's directory tree is updated by proposal 66 - * application. The daemon can't update UI state directly — this 67 - * callback lets the host app trigger a reload. 68 - */ 69 - readonly onWorkspaceUpdated?: (keyringUris: readonly string[]) => void; 70 46 71 47 /** Called when the daemon detects an expired session. */ 72 48 readonly onSessionExpired?: () => void;
+73
packages/opake-react/eslint.config.ts
··· 1 + import tseslint from "typescript-eslint"; 2 + import { makeBaseConfig } from "../../eslint.config.base.ts"; 3 + 4 + export default tseslint.config( 5 + ...makeBaseConfig({ tsconfigRootDir: import.meta.dirname }), 6 + 7 + // --------------------------------------------------------------------------- 8 + // Overrides: FileManagerCache — refcounted lifecycle, inherently mutable 9 + // 10 + // The refcount, the Map of in-flight promises, and the for-loop in 11 + // `disposeAll` are all part of managing shared resource lifetime. A 12 + // fully-functional version would need a completely different shape 13 + // (streams or signals) that doesn't fit the rest of the SDK. 14 + // --------------------------------------------------------------------------- 15 + { 16 + files: ["src/file-manager-cache.ts"], 17 + rules: { 18 + "functional/no-let": "off", 19 + "functional/no-loop-statements": "off", 20 + "functional/immutable-data": "off", 21 + "functional/prefer-immutable-types": "off", 22 + // `type CacheKey = string` is a documentation alias, not redundant 23 + "sonarjs/redundant-type-aliases": "off", 24 + }, 25 + }, 26 + 27 + // --------------------------------------------------------------------------- 28 + // Overrides: hooks — React effect cleanup is fundamentally imperative 29 + // 30 + // `let cancelled = false` + `cancelled = true` in the effect return 31 + // is the idiomatic pattern for cancelling in-flight async work on 32 + // unmount. The functional rules don't fit; AbortController is more 33 + // verbose for no real win. 34 + // --------------------------------------------------------------------------- 35 + { 36 + files: ["src/hooks/**/*.ts", "src/hooks/**/*.tsx"], 37 + rules: { 38 + "functional/no-let": "off", 39 + "functional/immutable-data": "off", 40 + "functional/prefer-immutable-types": "off", 41 + }, 42 + }, 43 + 44 + // --------------------------------------------------------------------------- 45 + // Overrides: test files 46 + // 47 + // React 19's `react-hooks/refs` and `react-hooks/set-state-in-effect` 48 + // rules are correct for production code but test capture patterns 49 + // (module-level `let cache` + Probe components) intentionally violate 50 + // them. Mock builders use empty `async () => {}` as vi.fn default 51 + // implementations; `require-await` doesn't make sense there either. 52 + // --------------------------------------------------------------------------- 53 + { 54 + files: ["src/__tests__/**/*.ts", "src/__tests__/**/*.tsx"], 55 + rules: { 56 + "functional/no-let": "off", 57 + "functional/no-loop-statements": "off", 58 + "functional/immutable-data": "off", 59 + "functional/prefer-immutable-types": "off", 60 + "@typescript-eslint/no-unsafe-assignment": "off", 61 + "@typescript-eslint/no-unsafe-member-access": "off", 62 + "@typescript-eslint/no-unsafe-call": "off", 63 + "@typescript-eslint/no-non-null-assertion": "off", 64 + "@typescript-eslint/require-await": "off", 65 + "@typescript-eslint/no-empty-function": "off", 66 + "sonarjs/void-use": "off", 67 + "react-hooks/refs": "off", 68 + "react-hooks/set-state-in-effect": "off", 69 + "react-hooks/immutability": "off", 70 + "react-hooks/globals": "off", 71 + }, 72 + }, 73 + );
+11 -2
packages/opake-react/package.json
··· 18 18 ], 19 19 "scripts": { 20 20 "build": "tsup", 21 - "test": "vitest run" 21 + "test": "vitest run", 22 + "lint": "eslint src/", 23 + "lint:fix": "eslint src/ --fix", 24 + "format": "prettier --write 'src/**/*.{ts,tsx}'", 25 + "format:check": "prettier --check 'src/**/*.{ts,tsx}'" 22 26 }, 23 27 "peerDependencies": { 24 28 "@opake/sdk": "workspace:*", ··· 28 32 }, 29 33 "devDependencies": { 30 34 "@tanstack/react-query": "^5.0.0", 35 + "@testing-library/dom": "^10.4.1", 36 + "@testing-library/react": "^16.3.2", 31 37 "@types/react": "^19.0.0", 32 - "react": "^19.0.0", 38 + "@types/react-dom": "^19.2.3", 39 + "jsdom": "^29.0.2", 40 + "react": "19.2.5", 41 + "react-dom": "^19.2.5", 33 42 "tsup": "^8.0.0", 34 43 "typescript": "^5.7.0", 35 44 "vitest": "^3.0.0"
+177
packages/opake-react/src/__tests__/file-manager-cache.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import { FileManagerCache } from "../file-manager-cache"; 3 + import { asOpake, createMockOpake } from "./mock-opake"; 4 + 5 + describe("FileManagerCache", () => { 6 + it("acquires a cabinet FileManager on first call", async () => { 7 + const mock = createMockOpake(); 8 + const cache = new FileManagerCache(asOpake(mock)); 9 + 10 + const fm = await cache.acquire(null); 11 + 12 + expect(fm).toBeDefined(); 13 + expect(mock.cabinet).toHaveBeenCalledTimes(1); 14 + expect(cache.refcountOf(null)).toBe(1); 15 + }); 16 + 17 + it("reuses the same FileManager on subsequent acquires", async () => { 18 + const mock = createMockOpake(); 19 + const cache = new FileManagerCache(asOpake(mock)); 20 + 21 + const first = await cache.acquire(null); 22 + const second = await cache.acquire(null); 23 + 24 + expect(first).toBe(second); 25 + expect(mock.cabinet).toHaveBeenCalledTimes(1); 26 + expect(cache.refcountOf(null)).toBe(2); 27 + }); 28 + 29 + it("shares one in-flight promise across concurrent acquires", async () => { 30 + const mock = createMockOpake(); 31 + const cache = new FileManagerCache(asOpake(mock)); 32 + 33 + const [a, b, c] = await Promise.all([ 34 + cache.acquire(null), 35 + cache.acquire(null), 36 + cache.acquire(null), 37 + ]); 38 + 39 + expect(a).toBe(b); 40 + expect(b).toBe(c); 41 + expect(mock.cabinet).toHaveBeenCalledTimes(1); 42 + expect(cache.refcountOf(null)).toBe(3); 43 + }); 44 + 45 + it("decrements refcount on release but keeps the entry until it reaches zero", async () => { 46 + const mock = createMockOpake(); 47 + const cache = new FileManagerCache(asOpake(mock)); 48 + 49 + await cache.acquire(null); 50 + await cache.acquire(null); 51 + cache.release(null); 52 + 53 + expect(cache.refcountOf(null)).toBe(1); 54 + expect(cache.has(null)).toBe(true); 55 + expect(mock.lastCabinetFm!.isDisposed()).toBe(false); 56 + }); 57 + 58 + it("disposes the FileManager when refcount hits zero", async () => { 59 + const mock = createMockOpake(); 60 + const cache = new FileManagerCache(asOpake(mock)); 61 + 62 + await cache.acquire(null); 63 + cache.release(null); 64 + 65 + expect(cache.refcountOf(null)).toBe(0); 66 + expect(cache.has(null)).toBe(false); 67 + expect(mock.lastCabinetFm!.isDisposed()).toBe(true); 68 + }); 69 + 70 + it("treats cabinet and workspace as independent cache keys", async () => { 71 + const mock = createMockOpake(); 72 + const cache = new FileManagerCache(asOpake(mock)); 73 + 74 + await cache.acquire(null); 75 + await cache.acquire("at://workspace/kr1"); 76 + 77 + expect(cache.refcountOf(null)).toBe(1); 78 + expect(cache.refcountOf("at://workspace/kr1")).toBe(1); 79 + expect(mock.cabinet).toHaveBeenCalledTimes(1); 80 + expect(mock.workspace).toHaveBeenCalledWith("at://workspace/kr1"); 81 + }); 82 + 83 + it("keeps workspace entries independent of each other", async () => { 84 + const mock = createMockOpake(); 85 + const cache = new FileManagerCache(asOpake(mock)); 86 + 87 + const fmA = await cache.acquire("at://ws/a"); 88 + const fmB = await cache.acquire("at://ws/b"); 89 + 90 + expect(fmA).not.toBe(fmB); 91 + expect(mock.workspace).toHaveBeenCalledTimes(2); 92 + }); 93 + 94 + it("drops the cache entry on construction error so retries work", async () => { 95 + const mock = createMockOpake(); 96 + mock.cabinet.mockRejectedValueOnce(new Error("boom")); 97 + const cache = new FileManagerCache(asOpake(mock)); 98 + 99 + await expect(cache.acquire(null)).rejects.toThrow("boom"); 100 + // After the error propagates, the entry should be gone so a 101 + // retry constructs fresh. 102 + expect(cache.has(null)).toBe(false); 103 + 104 + // Next acquire succeeds (mock's default implementation). 105 + const fm = await cache.acquire(null); 106 + expect(fm).toBeDefined(); 107 + expect(mock.cabinet).toHaveBeenCalledTimes(2); 108 + }); 109 + 110 + it("disposes already-resolved FileManagers on disposeAll", async () => { 111 + const mock = createMockOpake(); 112 + const cache = new FileManagerCache(asOpake(mock)); 113 + 114 + await cache.acquire(null); 115 + await cache.acquire("at://ws/a"); 116 + await cache.acquire("at://ws/b"); 117 + 118 + const cabinetFm = mock.lastCabinetFm!; 119 + const wsFmA = mock.workspaceFms.get("at://ws/a")!; 120 + const wsFmB = mock.workspaceFms.get("at://ws/b")!; 121 + 122 + cache.disposeAll(); 123 + 124 + expect(cabinetFm.isDisposed()).toBe(true); 125 + expect(wsFmA.isDisposed()).toBe(true); 126 + expect(wsFmB.isDisposed()).toBe(true); 127 + expect(cache.has(null)).toBe(false); 128 + expect(cache.has("at://ws/a")).toBe(false); 129 + }); 130 + 131 + it("release without acquire is a no-op", async () => { 132 + const mock = createMockOpake(); 133 + const cache = new FileManagerCache(asOpake(mock)); 134 + 135 + // Should not throw 136 + cache.release(null); 137 + cache.release("at://nonexistent"); 138 + 139 + expect(cache.refcountOf(null)).toBe(0); 140 + }); 141 + 142 + it("disposes promise-resolved FileManager even if released mid-flight", async () => { 143 + // Simulate: acquire starts the promise, release is called before it 144 + // resolves, then the promise resolves. Expected: cache entry is 145 + // already gone, so the resolved FM is disposed immediately. 146 + const mock = createMockOpake(); 147 + 148 + let resolveCabinet: ((fm: ReturnType<typeof createMockOpake>["lastCabinetFm"]) => void) | null = 149 + null; 150 + mock.cabinet.mockImplementationOnce( 151 + () => 152 + new Promise((resolve) => { 153 + resolveCabinet = (fm) => resolve(fm as NonNullable<typeof fm>); 154 + }), 155 + ); 156 + 157 + const cache = new FileManagerCache(asOpake(mock)); 158 + 159 + const acquirePromise = cache.acquire(null); 160 + cache.release(null); // release before resolve 161 + 162 + // Now resolve the construction 163 + const { createMockFileManager } = await import("./mock-opake"); 164 + const fm = createMockFileManager(); 165 + resolveCabinet!(fm); 166 + 167 + await acquirePromise; 168 + 169 + // Wait a microtask for the then-handler to run 170 + await new Promise((r) => setTimeout(r, 0)); 171 + 172 + expect(fm.isDisposed()).toBe(true); 173 + }); 174 + }); 175 + 176 + // Suppress unused import warning — vi is needed for the eager-release test 177 + void vi;
+154
packages/opake-react/src/__tests__/mock-opake.ts
··· 1 + // Hand-rolled mock of the Opake + FileManager surface that `@opake/react` 2 + // touches. We don't spin up real WASM for hook tests — it's too slow 3 + // and the hooks only care about a small handful of methods. 4 + // 5 + // Each mock returns vi.fn()-backed method shims so tests can assert 6 + // call counts, arguments, and throw behaviors. 7 + 8 + import { vi, type Mock } from "vitest"; 9 + import type { DirectoryTreeSnapshot, DirectoryWatcher, FileManager, Opake } from "@opake/sdk"; 10 + 11 + /** A minimal Opake shim — only the methods the React hooks call. */ 12 + export interface MockOpake { 13 + cabinet: Mock<() => Promise<MockFileManager>>; 14 + workspace: Mock<(keyringUri: string) => Promise<MockFileManager>>; 15 + startSseConsumer: Mock<(appviewUrl?: string) => Promise<void>>; 16 + stopSseConsumer: Mock<() => void>; 17 + /** Inspect the last FileManager handed out (for cabinet). */ 18 + lastCabinetFm: MockFileManager | null; 19 + /** Inspect last FM per workspace keyring. */ 20 + workspaceFms: Map<string, MockFileManager>; 21 + } 22 + 23 + /** A minimal FileManager shim. */ 24 + export interface MockFileManager { 25 + loadTree: Mock<() => Promise<DirectoryTreeSnapshot>>; 26 + watchDirectory: Mock< 27 + ( 28 + directoryUri: string, 29 + handler: (snapshot: DirectoryTreeSnapshot | null) => void, 30 + ) => MockDirectoryWatcher 31 + >; 32 + dispose: Mock<() => void>; 33 + /** Fire the most recently installed watcher with a new snapshot. */ 34 + emit: (snapshot: DirectoryTreeSnapshot | null) => void; 35 + /** The active watcher handler, if any. */ 36 + activeHandler: ((s: DirectoryTreeSnapshot | null) => void) | null; 37 + /** Target URI of the active watcher, if any. */ 38 + activeWatchedUri: string | null; 39 + /** Was dispose() called at least once? */ 40 + readonly isDisposed: () => boolean; 41 + } 42 + 43 + export interface MockDirectoryWatcher { 44 + close: Mock<() => void>; 45 + readonly isClosed: () => boolean; 46 + } 47 + 48 + /** 49 + * Build a fresh mock FileManager with the given initial tree. 50 + * 51 + * `initialTree` is what `loadTree()` resolves to. Defaults to an empty 52 + * cabinet with `rootUri = "at://did:plc:test/app.opake.directory/self"`. 53 + * 54 + * The returned mock remembers the most recent watcher it handed out 55 + * via `activeHandler`; tests call `fm.emit(snapshot)` to simulate an 56 + * SSE-driven update. 57 + */ 58 + export function createMockFileManager(initialTree?: DirectoryTreeSnapshot): MockFileManager { 59 + const tree: DirectoryTreeSnapshot = 60 + initialTree ?? 61 + ({ 62 + rootUri: "at://did:plc:test/app.opake.directory/self", 63 + directories: { 64 + "at://did:plc:test/app.opake.directory/self": { 65 + name: "/", 66 + entries: [], 67 + parentUri: null, 68 + }, 69 + }, 70 + } as DirectoryTreeSnapshot); 71 + 72 + let disposed = false; 73 + const state: MockFileManager = { 74 + loadTree: vi.fn(async () => tree), 75 + watchDirectory: vi.fn(), 76 + dispose: vi.fn(() => { 77 + disposed = true; 78 + }), 79 + activeHandler: null, 80 + activeWatchedUri: null, 81 + emit: (snapshot) => { 82 + if (state.activeHandler) state.activeHandler(snapshot); 83 + }, 84 + isDisposed: () => disposed, 85 + }; 86 + 87 + // Wire watchDirectory after `state` exists so the mock can store 88 + // the handler on the same object. 89 + state.watchDirectory.mockImplementation((directoryUri, handler) => { 90 + state.activeHandler = handler; 91 + state.activeWatchedUri = directoryUri; 92 + let closed = false; 93 + const watcher: MockDirectoryWatcher = { 94 + close: vi.fn(() => { 95 + closed = true; 96 + if (state.activeHandler === handler) { 97 + state.activeHandler = null; 98 + state.activeWatchedUri = null; 99 + } 100 + }), 101 + isClosed: () => closed, 102 + }; 103 + return watcher; 104 + }); 105 + 106 + return state; 107 + } 108 + 109 + /** 110 + * Build a fresh mock Opake that vends new FileManagers on each call. 111 + * 112 + * The returned object is NOT a full `Opake` — it only implements the 113 + * methods `@opake/react` calls. Tests that need a full instance should 114 + * cast via `as unknown as Opake` at the provider boundary. 115 + */ 116 + export function createMockOpake(): MockOpake { 117 + const mock: MockOpake = { 118 + cabinet: vi.fn(), 119 + workspace: vi.fn(), 120 + startSseConsumer: vi.fn(async () => {}), 121 + stopSseConsumer: vi.fn(), 122 + lastCabinetFm: null, 123 + workspaceFms: new Map(), 124 + }; 125 + 126 + mock.cabinet.mockImplementation(async () => { 127 + const fm = createMockFileManager(); 128 + mock.lastCabinetFm = fm; 129 + return fm; 130 + }); 131 + 132 + mock.workspace.mockImplementation(async (keyringUri: string) => { 133 + const fm = createMockFileManager(); 134 + mock.workspaceFms.set(keyringUri, fm); 135 + return fm; 136 + }); 137 + 138 + return mock; 139 + } 140 + 141 + /** Cast helper — narrows our mock to the real Opake interface. */ 142 + export function asOpake(mock: MockOpake): Opake { 143 + return mock as unknown as Opake; 144 + } 145 + 146 + /** Cast helper — narrows a mock FileManager to the real interface. */ 147 + export function asFileManager(mock: MockFileManager): FileManager { 148 + return mock as unknown as FileManager; 149 + } 150 + 151 + /** Cast helper — narrows a mock watcher to the real interface. */ 152 + export function asDirectoryWatcher(mock: MockDirectoryWatcher): DirectoryWatcher { 153 + return mock as unknown as DirectoryWatcher; 154 + }
+182
packages/opake-react/src/__tests__/provider.test.tsx
··· 1 + import { act, render } from "@testing-library/react"; 2 + import { StrictMode } from "react"; 3 + import { describe, expect, it, vi } from "vitest"; 4 + import { OpakeProvider, useOpake, useFileManagerCache } from "../provider"; 5 + import { asOpake, createMockOpake } from "./mock-opake"; 6 + 7 + async function flush() { 8 + await act(async () => { 9 + await new Promise((r) => setTimeout(r, 0)); 10 + }); 11 + } 12 + 13 + function Reader({ onMount }: { onMount: (opake: ReturnType<typeof useOpake>) => void }) { 14 + const opake = useOpake(); 15 + onMount(opake); 16 + return null; 17 + } 18 + 19 + describe("OpakeProvider", () => { 20 + it("exposes the Opake instance via useOpake", () => { 21 + const mock = createMockOpake(); 22 + const spy = vi.fn(); 23 + 24 + render( 25 + <OpakeProvider opake={asOpake(mock)} disableSseAutoStart> 26 + <Reader onMount={spy} /> 27 + </OpakeProvider>, 28 + ); 29 + 30 + expect(spy).toHaveBeenCalled(); 31 + // Reference equality — the context should pass through the same object 32 + const firstCall = spy.mock.calls[0]; 33 + expect(firstCall).toBeDefined(); 34 + expect(firstCall![0]).toBe(mock); 35 + }); 36 + 37 + it("auto-starts the SSE consumer on mount", async () => { 38 + const mock = createMockOpake(); 39 + 40 + render( 41 + <OpakeProvider opake={asOpake(mock)}> 42 + <div /> 43 + </OpakeProvider>, 44 + ); 45 + await flush(); 46 + 47 + expect(mock.startSseConsumer).toHaveBeenCalledTimes(1); 48 + // Called with no arguments — WASM resolves the URL from stored config 49 + expect(mock.startSseConsumer.mock.calls[0]).toEqual([]); 50 + }); 51 + 52 + it("stops the SSE consumer on unmount to wipe TreeKeeper state", async () => { 53 + const mock = createMockOpake(); 54 + 55 + const { unmount } = render( 56 + <OpakeProvider opake={asOpake(mock)}> 57 + <div /> 58 + </OpakeProvider>, 59 + ); 60 + await flush(); 61 + 62 + expect(mock.stopSseConsumer).not.toHaveBeenCalled(); 63 + 64 + unmount(); 65 + await flush(); 66 + 67 + // Cleanup should have run stopSseConsumer exactly once so that the 68 + // WASM TreeKeeper zeroes cached ContentKeys / decrypted metadata 69 + // before the next Opake instance takes over. 70 + expect(mock.stopSseConsumer).toHaveBeenCalledTimes(1); 71 + }); 72 + 73 + it("does NOT stop the SSE consumer on unmount when auto-start is disabled", async () => { 74 + const mock = createMockOpake(); 75 + 76 + const { unmount } = render( 77 + <OpakeProvider opake={asOpake(mock)} disableSseAutoStart> 78 + <div /> 79 + </OpakeProvider>, 80 + ); 81 + await flush(); 82 + 83 + unmount(); 84 + await flush(); 85 + 86 + // With auto-start off, the effect skipped entirely and there's no 87 + // cleanup path — stopSseConsumer should not be called either. 88 + expect(mock.stopSseConsumer).not.toHaveBeenCalled(); 89 + }); 90 + 91 + it("skips SSE auto-start when disableSseAutoStart is set", async () => { 92 + const mock = createMockOpake(); 93 + 94 + render( 95 + <OpakeProvider opake={asOpake(mock)} disableSseAutoStart> 96 + <div /> 97 + </OpakeProvider>, 98 + ); 99 + await flush(); 100 + 101 + expect(mock.startSseConsumer).not.toHaveBeenCalled(); 102 + }); 103 + 104 + it("survives StrictMode double-mount without crashing", async () => { 105 + const mock = createMockOpake(); 106 + 107 + render( 108 + <StrictMode> 109 + <OpakeProvider opake={asOpake(mock)}> 110 + <div /> 111 + </OpakeProvider> 112 + </StrictMode>, 113 + ); 114 + await flush(); 115 + 116 + // StrictMode runs the effect twice. WASM-side idempotency handles 117 + // the actual double-start; at the JS level we just verify both 118 + // calls happen (the mock accepts them) and nothing throws. 119 + expect(mock.startSseConsumer.mock.calls.length).toBeGreaterThanOrEqual(1); 120 + }); 121 + 122 + it("exposes a FileManagerCache via useFileManagerCache", () => { 123 + const mock = createMockOpake(); 124 + 125 + let cache: ReturnType<typeof useFileManagerCache> | null = null; 126 + 127 + function Probe() { 128 + cache = useFileManagerCache(); 129 + return null; 130 + } 131 + 132 + render( 133 + <OpakeProvider opake={asOpake(mock)} disableSseAutoStart> 134 + <Probe /> 135 + </OpakeProvider>, 136 + ); 137 + 138 + expect(cache).not.toBeNull(); 139 + expect(typeof cache!.acquire).toBe("function"); 140 + expect(typeof cache!.release).toBe("function"); 141 + }); 142 + 143 + it("throws if useOpake is called outside an OpakeProvider", () => { 144 + function BareConsumer() { 145 + useOpake(); 146 + return null; 147 + } 148 + 149 + // React logs the error; suppress for cleaner test output 150 + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 151 + expect(() => render(<BareConsumer />)).toThrow(/useOpake must be used within an OpakeProvider/); 152 + errorSpy.mockRestore(); 153 + }); 154 + 155 + it("disposes the FileManagerCache on unmount", async () => { 156 + const mock = createMockOpake(); 157 + 158 + let cache: ReturnType<typeof useFileManagerCache> | null = null; 159 + 160 + function Probe() { 161 + cache = useFileManagerCache(); 162 + return null; 163 + } 164 + 165 + const { unmount } = render( 166 + <OpakeProvider opake={asOpake(mock)} disableSseAutoStart> 167 + <Probe /> 168 + </OpakeProvider>, 169 + ); 170 + await flush(); 171 + 172 + // Acquire something so there's state to dispose 173 + const fm = await cache!.acquire(null); 174 + expect(fm).toBeDefined(); 175 + 176 + unmount(); 177 + await flush(); 178 + 179 + // Cache should be wiped; direct check via has() 180 + expect(cache!.has(null)).toBe(false); 181 + }); 182 + });
+3
packages/opake-react/src/__tests__/setup.ts
··· 1 + // Vitest setup. Imports `@testing-library/jest-dom`-style matchers if 2 + // we need them later; for now it just ensures JSDOM globals are present 3 + // and silences the React 19 act() warnings.
+210
packages/opake-react/src/__tests__/use-directory.test.tsx
··· 1 + import { act, render } from "@testing-library/react"; 2 + import { type ReactNode } from "react"; 3 + import { describe, expect, it } from "vitest"; 4 + import type { DirectoryTreeSnapshot } from "@opake/sdk"; 5 + import { OpakeProvider } from "../provider"; 6 + import { useDirectory } from "../hooks/use-directory"; 7 + import { asOpake, createMockOpake, type MockOpake } from "./mock-opake"; 8 + 9 + function wrap(opake: MockOpake, children: ReactNode): ReactNode { 10 + return ( 11 + <OpakeProvider opake={asOpake(opake)} disableSseAutoStart> 12 + {children} 13 + </OpakeProvider> 14 + ); 15 + } 16 + 17 + interface Capture { 18 + snapshot: DirectoryTreeSnapshot | null; 19 + isReady: boolean; 20 + error: Error | null; 21 + resolvedDirectoryUri: string | null; 22 + } 23 + 24 + function emptyCapture(): Capture { 25 + return { snapshot: null, isReady: false, error: null, resolvedDirectoryUri: null }; 26 + } 27 + 28 + function Probe({ 29 + keyringUri, 30 + directoryUri, 31 + capture, 32 + }: { 33 + keyringUri: string | null; 34 + directoryUri: string | null; 35 + capture: { current: Capture }; 36 + }) { 37 + const result = useDirectory(keyringUri, directoryUri); 38 + capture.current = { ...result }; 39 + return null; 40 + } 41 + 42 + async function flush() { 43 + await act(async () => { 44 + await new Promise((r) => setTimeout(r, 0)); 45 + }); 46 + } 47 + 48 + const ROOT_URI = "at://did:plc:test/app.opake.directory/self"; 49 + 50 + describe("useDirectory", () => { 51 + it("loads tree, resolves root, installs watcher, delivers first snapshot", async () => { 52 + const mock = createMockOpake(); 53 + const capture = { current: emptyCapture() }; 54 + 55 + render(wrap(mock, <Probe keyringUri={null} directoryUri={null} capture={capture} />)); 56 + await flush(); 57 + 58 + // FileManager acquired + loadTree called 59 + expect(mock.cabinet).toHaveBeenCalledTimes(1); 60 + const fm = mock.lastCabinetFm!; 61 + expect(fm.loadTree).toHaveBeenCalledTimes(1); 62 + 63 + // Watcher installed on the root URI resolved from the tree 64 + expect(fm.watchDirectory).toHaveBeenCalledTimes(1); 65 + expect(fm.activeWatchedUri).toBe(ROOT_URI); 66 + 67 + // Fire the watcher with the current state 68 + act(() => { 69 + fm.emit({ 70 + rootUri: ROOT_URI, 71 + directories: { 72 + [ROOT_URI]: { name: "/", entries: [], parentUri: null }, 73 + }, 74 + } as DirectoryTreeSnapshot); 75 + }); 76 + 77 + expect(capture.current.isReady).toBe(true); 78 + expect(capture.current.snapshot?.rootUri).toBe(ROOT_URI); 79 + expect(capture.current.resolvedDirectoryUri).toBe(ROOT_URI); 80 + }); 81 + 82 + it("uses the explicit directoryUri when provided and skips loadTree", async () => { 83 + const mock = createMockOpake(); 84 + const capture = { current: emptyCapture() }; 85 + const childUri = "at://did:plc:test/app.opake.directory/photos"; 86 + 87 + render(wrap(mock, <Probe keyringUri={null} directoryUri={childUri} capture={capture} />)); 88 + await flush(); 89 + 90 + const fm = mock.lastCabinetFm!; 91 + expect(fm.loadTree).not.toHaveBeenCalled(); 92 + expect(fm.activeWatchedUri).toBe(childUri); 93 + expect(capture.current.resolvedDirectoryUri).toBe(childUri); 94 + }); 95 + 96 + it("updates the snapshot when the watcher fires again", async () => { 97 + const mock = createMockOpake(); 98 + const capture = { current: emptyCapture() }; 99 + 100 + render(wrap(mock, <Probe keyringUri={null} directoryUri={null} capture={capture} />)); 101 + await flush(); 102 + 103 + const fm = mock.lastCabinetFm!; 104 + const first = { 105 + rootUri: ROOT_URI, 106 + directories: { [ROOT_URI]: { name: "/", entries: [], parentUri: null } }, 107 + } as DirectoryTreeSnapshot; 108 + act(() => fm.emit(first)); 109 + expect(capture.current.snapshot).toBe(first); 110 + 111 + const second = { 112 + rootUri: ROOT_URI, 113 + directories: { 114 + [ROOT_URI]: { 115 + name: "/", 116 + entries: [{ uri: "at://new/doc", type: "document" as const }], 117 + parentUri: null, 118 + }, 119 + }, 120 + } as DirectoryTreeSnapshot; 121 + act(() => fm.emit(second)); 122 + expect(capture.current.snapshot).toBe(second); 123 + }); 124 + 125 + it("surfaces deletion by passing null through to state", async () => { 126 + const mock = createMockOpake(); 127 + const capture = { current: emptyCapture() }; 128 + 129 + render(wrap(mock, <Probe keyringUri={null} directoryUri={null} capture={capture} />)); 130 + await flush(); 131 + 132 + const fm = mock.lastCabinetFm!; 133 + act(() => 134 + fm.emit({ 135 + rootUri: ROOT_URI, 136 + directories: { [ROOT_URI]: { name: "/", entries: [], parentUri: null } }, 137 + } as DirectoryTreeSnapshot), 138 + ); 139 + expect(capture.current.isReady).toBe(true); 140 + 141 + act(() => fm.emit(null)); 142 + expect(capture.current.snapshot).toBeNull(); 143 + expect(capture.current.isReady).toBe(false); 144 + }); 145 + 146 + it("closes watcher on unmount", async () => { 147 + const mock = createMockOpake(); 148 + const capture = { current: emptyCapture() }; 149 + 150 + const { unmount } = render( 151 + wrap(mock, <Probe keyringUri={null} directoryUri={null} capture={capture} />), 152 + ); 153 + await flush(); 154 + 155 + const fm = mock.lastCabinetFm!; 156 + expect(fm.activeWatchedUri).toBe(ROOT_URI); 157 + 158 + unmount(); 159 + await flush(); 160 + 161 + // Watcher was closed → activeHandler cleared 162 + expect(fm.activeHandler).toBeNull(); 163 + expect(fm.isDisposed()).toBe(true); 164 + }); 165 + 166 + it("handles empty tree (no rootUri) by returning the empty snapshot", async () => { 167 + const mock = createMockOpake(); 168 + // Override loadTree to return a tree without a root 169 + mock.cabinet.mockImplementationOnce(async () => { 170 + const { createMockFileManager } = await import("./mock-opake"); 171 + const fm = createMockFileManager({ 172 + rootUri: null, 173 + directories: {}, 174 + } as unknown as DirectoryTreeSnapshot); 175 + mock.lastCabinetFm = fm; 176 + return fm; 177 + }); 178 + 179 + const capture = { current: emptyCapture() }; 180 + render(wrap(mock, <Probe keyringUri={null} directoryUri={null} capture={capture} />)); 181 + await flush(); 182 + 183 + const fm = mock.lastCabinetFm!; 184 + expect(fm.watchDirectory).not.toHaveBeenCalled(); 185 + expect(capture.current.snapshot).not.toBeNull(); 186 + expect(capture.current.snapshot?.rootUri).toBeNull(); 187 + expect(capture.current.resolvedDirectoryUri).toBeNull(); 188 + }); 189 + 190 + it("switches watcher when directoryUri prop changes", async () => { 191 + const mock = createMockOpake(); 192 + const capture = { current: emptyCapture() }; 193 + const uriA = "at://did:plc:test/app.opake.directory/a"; 194 + const uriB = "at://did:plc:test/app.opake.directory/b"; 195 + 196 + const { rerender } = render( 197 + wrap(mock, <Probe keyringUri={null} directoryUri={uriA} capture={capture} />), 198 + ); 199 + await flush(); 200 + 201 + const fm = mock.lastCabinetFm!; 202 + expect(fm.activeWatchedUri).toBe(uriA); 203 + 204 + rerender(wrap(mock, <Probe keyringUri={null} directoryUri={uriB} capture={capture} />)); 205 + await flush(); 206 + 207 + expect(fm.activeWatchedUri).toBe(uriB); 208 + expect(fm.watchDirectory).toHaveBeenCalledTimes(2); 209 + }); 210 + });
+128
packages/opake-react/src/__tests__/use-file-manager.test.tsx
··· 1 + import { act, render } from "@testing-library/react"; 2 + import { StrictMode, type ReactNode } from "react"; 3 + import { describe, expect, it } from "vitest"; 4 + import { OpakeProvider } from "../provider"; 5 + import { useFileManager } from "../hooks/use-file-manager"; 6 + import { asOpake, createMockOpake, type MockOpake } from "./mock-opake"; 7 + 8 + /** Wrap children in an OpakeProvider with SSE auto-start disabled. */ 9 + function wrap(opake: MockOpake, children: ReactNode): ReactNode { 10 + return ( 11 + <OpakeProvider opake={asOpake(opake)} disableSseAutoStart> 12 + {children} 13 + </OpakeProvider> 14 + ); 15 + } 16 + 17 + /** A tiny component that captures the latest useFileManager result 18 + * via a mutable ref for assertion. */ 19 + interface Capture { 20 + fileManager: ReturnType<typeof useFileManager>["fileManager"]; 21 + isReady: boolean; 22 + error: Error | null; 23 + } 24 + 25 + function Probe({ 26 + keyringUri, 27 + capture, 28 + }: { 29 + keyringUri: string | null; 30 + capture: { current: Capture }; 31 + }) { 32 + const result = useFileManager(keyringUri); 33 + capture.current = { 34 + fileManager: result.fileManager, 35 + isReady: result.isReady, 36 + error: result.error, 37 + }; 38 + return null; 39 + } 40 + 41 + async function flushMicrotasks() { 42 + await act(async () => { 43 + await new Promise((r) => setTimeout(r, 0)); 44 + }); 45 + } 46 + 47 + describe("useFileManager", () => { 48 + it("acquires a cabinet FileManager on mount", async () => { 49 + const mock = createMockOpake(); 50 + const capture = { current: { fileManager: null, isReady: false, error: null } as Capture }; 51 + 52 + render(wrap(mock, <Probe keyringUri={null} capture={capture} />)); 53 + await flushMicrotasks(); 54 + 55 + expect(mock.cabinet).toHaveBeenCalledTimes(1); 56 + expect(capture.current.isReady).toBe(true); 57 + expect(capture.current.fileManager).not.toBeNull(); 58 + }); 59 + 60 + it("releases and disposes on unmount", async () => { 61 + const mock = createMockOpake(); 62 + const capture = { current: { fileManager: null, isReady: false, error: null } as Capture }; 63 + 64 + const { unmount } = render(wrap(mock, <Probe keyringUri={null} capture={capture} />)); 65 + await flushMicrotasks(); 66 + 67 + const cabinetFm = mock.lastCabinetFm; 68 + expect(cabinetFm).not.toBeNull(); 69 + expect(cabinetFm!.isDisposed()).toBe(false); 70 + 71 + unmount(); 72 + await flushMicrotasks(); 73 + 74 + expect(cabinetFm!.isDisposed()).toBe(true); 75 + }); 76 + 77 + it("handles StrictMode double-mount with balanced acquire/release", async () => { 78 + const mock = createMockOpake(); 79 + const capture = { current: { fileManager: null, isReady: false, error: null } as Capture }; 80 + 81 + const { unmount } = render( 82 + <StrictMode>{wrap(mock, <Probe keyringUri={null} capture={capture} />)}</StrictMode>, 83 + ); 84 + await flushMicrotasks(); 85 + 86 + // StrictMode mounts, unmounts, remounts in dev. The cache should 87 + // keep the FM alive through the intermediate unmount because 88 + // acquire/release refcounts balance. 89 + expect(capture.current.isReady).toBe(true); 90 + const fm = mock.lastCabinetFm!; 91 + expect(fm.isDisposed()).toBe(false); 92 + 93 + // Real unmount disposes. 94 + unmount(); 95 + await flushMicrotasks(); 96 + expect(fm.isDisposed()).toBe(true); 97 + }); 98 + 99 + it("releases old and acquires new when keyringUri changes", async () => { 100 + const mock = createMockOpake(); 101 + const capture = { current: { fileManager: null, isReady: false, error: null } as Capture }; 102 + 103 + const { rerender } = render(wrap(mock, <Probe keyringUri={null} capture={capture} />)); 104 + await flushMicrotasks(); 105 + const cabinetFm = mock.lastCabinetFm!; 106 + 107 + rerender(wrap(mock, <Probe keyringUri="at://ws/a" capture={capture} />)); 108 + await flushMicrotasks(); 109 + 110 + expect(cabinetFm.isDisposed()).toBe(true); 111 + expect(mock.workspace).toHaveBeenCalledWith("at://ws/a"); 112 + expect(mock.workspaceFms.get("at://ws/a")).toBeDefined(); 113 + expect(capture.current.fileManager).toBe(mock.workspaceFms.get("at://ws/a")); 114 + }); 115 + 116 + it("surfaces construction errors via the error field", async () => { 117 + const mock = createMockOpake(); 118 + mock.cabinet.mockRejectedValueOnce(new Error("boom")); 119 + const capture = { current: { fileManager: null, isReady: false, error: null } as Capture }; 120 + 121 + render(wrap(mock, <Probe keyringUri={null} capture={capture} />)); 122 + await flushMicrotasks(); 123 + 124 + expect(capture.current.error).toBeInstanceOf(Error); 125 + expect(capture.current.error?.message).toBe("boom"); 126 + expect(capture.current.isReady).toBe(false); 127 + }); 128 + });
+146
packages/opake-react/src/file-manager-cache.ts
··· 1 + // Refcounted cache of FileManager instances, keyed by context. 2 + // 3 + // The existing `withFileManager` helper in `use-tree-mutation.ts` is 4 + // designed for short-lived mutations: create, call, dispose. That 5 + // pattern can't support subscription hooks, which need a long-lived 6 + // FileManager to hang a `watchDirectory` handle off. This cache 7 + // decouples FileManager lifetime from any single hook call — multiple 8 + // hooks watching the same context share one FileManager, and disposal 9 + // only happens when the last reference drops. 10 + // 11 + // Cache keys: 12 + // "cabinet" → the user's personal cabinet 13 + // `workspace:${uri}` → a specific workspace by keyring URI 14 + // 15 + // Lifecycle contract: 16 + // 1. `acquire(keyringUri)` returns a Promise resolving to a ready 17 + // FileManager. First call creates it via `opake.cabinet()` or 18 + // `opake.workspace(keyringUri)`; subsequent calls return the 19 + // same instance. Concurrent calls share the in-flight promise, 20 + // so we never accidentally create two FileManagers for one key. 21 + // 2. `release(keyringUri)` decrements the refcount. When it hits 22 + // zero, the FileManager is disposed. 23 + // 3. If construction fails, the cache entry is removed so the next 24 + // acquire retries. 25 + // 26 + // This cache is not thread-safe — WASM is single-threaded and React 27 + // state updates are sequenced through the event loop, so there's no 28 + // actual concurrency to worry about. 29 + 30 + import type { FileManager, Opake } from "@opake/sdk"; 31 + 32 + type CacheKey = string; 33 + 34 + interface CacheEntry { 35 + /** Resolves to the FileManager once construction completes. */ 36 + readonly promise: Promise<FileManager>; 37 + /** Mutable refcount — how many live `acquire` calls haven't been released yet. */ 38 + refcount: number; 39 + /** The resolved FileManager, or null until the promise settles. */ 40 + fileManager: FileManager | null; 41 + } 42 + 43 + function keyFor(keyringUri: string | null): CacheKey { 44 + return keyringUri ? `workspace:${keyringUri}` : "cabinet"; 45 + } 46 + 47 + export class FileManagerCache { 48 + private readonly entries = new Map<CacheKey, CacheEntry>(); 49 + 50 + constructor(private readonly opake: Opake) {} 51 + 52 + /** 53 + * Acquire a FileManager for the given context. Increments the 54 + * refcount; caller MUST call `release` exactly once when done. 55 + */ 56 + acquire(keyringUri: string | null): Promise<FileManager> { 57 + const key = keyFor(keyringUri); 58 + const existing = this.entries.get(key); 59 + if (existing) { 60 + existing.refcount += 1; 61 + return existing.promise; 62 + } 63 + 64 + const promise = this.construct(keyringUri); 65 + const entry: CacheEntry = { 66 + promise, 67 + refcount: 1, 68 + fileManager: null, 69 + }; 70 + this.entries.set(key, entry); 71 + 72 + // Track resolution into the entry so we can dispose later. 73 + promise.then( 74 + (fm) => { 75 + // If the entry was already removed (constructor error path or 76 + // aggressive release), dispose immediately to avoid leaks. 77 + const current = this.entries.get(key); 78 + if (current !== entry) { 79 + fm.dispose(); 80 + return; 81 + } 82 + entry.fileManager = fm; 83 + }, 84 + () => { 85 + // Construction failed — drop the cache entry so a subsequent 86 + // acquire can retry with a fresh attempt. 87 + if (this.entries.get(key) === entry) { 88 + this.entries.delete(key); 89 + } 90 + }, 91 + ); 92 + 93 + return promise; 94 + } 95 + 96 + /** 97 + * Release a previously-acquired FileManager. Decrements the refcount. 98 + * When the refcount reaches zero, the FileManager is disposed. 99 + * 100 + * Calling release without a matching acquire is a no-op. 101 + */ 102 + release(keyringUri: string | null): void { 103 + const key = keyFor(keyringUri); 104 + const entry = this.entries.get(key); 105 + if (!entry) return; 106 + 107 + entry.refcount--; 108 + if (entry.refcount > 0) return; 109 + 110 + this.entries.delete(key); 111 + // If the FileManager already resolved, dispose immediately. 112 + // If it's still pending, the .then handler above will see the 113 + // entry is gone and dispose on resolution. 114 + if (entry.fileManager) { 115 + entry.fileManager.dispose(); 116 + } 117 + } 118 + 119 + /** 120 + * Dispose all cached FileManagers. Called on provider unmount to 121 + * ensure no stragglers leak. After this, subsequent `acquire` calls 122 + * will construct fresh instances. 123 + */ 124 + disposeAll(): void { 125 + for (const entry of this.entries.values()) { 126 + if (entry.fileManager) { 127 + entry.fileManager.dispose(); 128 + } 129 + } 130 + this.entries.clear(); 131 + } 132 + 133 + /** Test helper: current refcount for a key, or 0 if not cached. */ 134 + refcountOf(keyringUri: string | null): number { 135 + return this.entries.get(keyFor(keyringUri))?.refcount ?? 0; 136 + } 137 + 138 + /** Test helper: whether a key currently has a cache entry. */ 139 + has(keyringUri: string | null): boolean { 140 + return this.entries.has(keyFor(keyringUri)); 141 + } 142 + 143 + private async construct(keyringUri: string | null): Promise<FileManager> { 144 + return keyringUri ? this.opake.workspace(keyringUri) : this.opake.cabinet(); 145 + } 146 + }
+20 -23
packages/opake-react/src/hooks/use-daemon.ts
··· 1 - import { useEffect, useRef } from "react"; 2 - import { useQueryClient } from "@tanstack/react-query"; 1 + "use client"; 2 + 3 + import { useEffect } from "react"; 3 4 import type { DaemonOptions, TaskDef, TaskStore } from "@opake/daemon"; 4 5 import { startDaemon } from "@opake/daemon"; 5 6 import { useOpake } from "../provider"; 6 - import { opakeKeys } from "../keys"; 7 7 8 - interface UseDaemonOptions extends Omit<DaemonOptions, "onWorkspaceUpdated"> { 8 + interface UseDaemonOptions extends DaemonOptions { 9 9 readonly taskDefs: readonly TaskDef[]; 10 10 readonly taskStore: TaskStore; 11 - readonly onWorkspaceUpdated?: (keyringUris: readonly string[]) => void; 12 11 } 13 12 14 13 /** 15 - * Start the background daemon and integrate with React Query. 14 + * Start the background daemon (pair-cleanup, grant-healing, share-retry). 16 15 * 17 - * Automatically invalidates workspace tree queries when the daemon 18 - * applies proposals. Stops the daemon on unmount. 16 + * The daemon is pure maintenance polling in the React package — live 17 + * tree updates come from the SSE consumer via `useDirectory`, not 18 + * from daemon task callbacks. If you need react-query cache 19 + * invalidation on live updates, migrate to `useDirectory` which 20 + * subscribes to `FileManager.watchDirectory` and receives fresh 21 + * snapshots as they arrive from SSE. 19 22 * 20 23 * @example 21 24 * ```tsx 22 - * useDaemon({ taskDefs, taskStore }); 25 + * import { Opake } from "@opake/sdk"; 26 + * useDaemon({ 27 + * taskDefs: await Opake.taskDefs(), 28 + * taskStore, 29 + * onSessionExpired: () => logout(), 30 + * }); 23 31 * ``` 24 32 */ 25 33 export function useDaemon(options: UseDaemonOptions): void { 26 34 const opake = useOpake(); 27 - const queryClient = useQueryClient(); 28 - 29 - // Ref for callbacks — avoids restarting daemon when callbacks change 30 - const callbacksRef = useRef(options); 31 - callbacksRef.current = options; 32 35 33 36 useEffect(() => { 34 37 const handle = startDaemon(opake, options.taskDefs, options.taskStore, { 35 38 initialDelayMs: options.initialDelayMs, 36 39 pruneAgeMs: options.pruneAgeMs, 37 - onSessionExpired: () => callbacksRef.current.onSessionExpired?.(), 38 - onWorkspaceUpdated: (uris) => { 39 - for (const uri of uris) { 40 - void queryClient.invalidateQueries({ queryKey: opakeKeys.workspaceTree(uri) }); 41 - } 42 - void queryClient.invalidateQueries({ queryKey: opakeKeys.workspaces() }); 43 - callbacksRef.current.onWorkspaceUpdated?.(uris); 44 - }, 40 + onSessionExpired: options.onSessionExpired, 45 41 }); 46 42 47 43 return () => handle.stop(); 48 - }, [opake, options.taskDefs, options.taskStore, queryClient]); 44 + // eslint-disable-next-line react-hooks/exhaustive-deps -- callbacks are stable via closure 45 + }, [opake, options.taskDefs, options.taskStore]); 49 46 }
+1 -4
packages/opake-react/src/hooks/use-directory-metadata.ts
··· 21 21 * console.log(doc?.name, doc?.mimeType, doc?.size); 22 22 * ``` 23 23 */ 24 - export function useDirectoryMetadata( 25 - keyringUri: string | null, 26 - directoryUri: string | null, 27 - ) { 24 + export function useDirectoryMetadata(keyringUri: string | null, directoryUri: string | null) { 28 25 const opake = useOpake(); 29 26 30 27 return useQuery<Readonly<Record<string, DocumentMetadata>>>({
+83
packages/opake-react/src/hooks/use-directory-mutations.ts
··· 1 + import type { MutationResult, UploadResult } from "@opake/sdk"; 2 + import { useTreeMutation } from "./use-tree-mutation"; 3 + 4 + interface CreateDirectoryInput { 5 + readonly name: string; 6 + readonly parentUri?: string; 7 + } 8 + 9 + interface RenameDirectoryInput { 10 + readonly directoryUri: string; 11 + readonly newName: string; 12 + } 13 + 14 + interface DeleteDirectoryInput { 15 + readonly directoryUri: string; 16 + } 17 + 18 + /** 19 + * Create a new directory. 20 + * 21 + * Optimistic: the directory appears immediately in the parent. 22 + */ 23 + export function useCreateDirectory(keyringUri: string | null) { 24 + return useTreeMutation<CreateDirectoryInput, UploadResult>({ 25 + keyringUri, 26 + mutationFn: (fm, input) => fm.createDirectory(input.name, input.parentUri), 27 + optimisticUpdate: (snapshot, input) => { 28 + if (!input.parentUri) return snapshot; 29 + const parent = snapshot.directories[input.parentUri]; 30 + if (!parent) return snapshot; 31 + 32 + const placeholderUri = `pending-dir:${input.name}:${Date.now()}`; 33 + return { 34 + ...snapshot, 35 + directories: { 36 + ...snapshot.directories, 37 + [input.parentUri]: { 38 + ...parent, 39 + entries: [...parent.entries, { uri: placeholderUri, type: "directory" as const }], 40 + }, 41 + [placeholderUri]: { name: input.name, entries: [], parentUri: input.parentUri }, 42 + }, 43 + }; 44 + }, 45 + }); 46 + } 47 + 48 + /** 49 + * Rename a directory. 50 + * 51 + * Optimistic: the name changes immediately in the tree. 52 + */ 53 + export function useRenameDirectory(keyringUri: string | null) { 54 + return useTreeMutation<RenameDirectoryInput, MutationResult>({ 55 + keyringUri, 56 + mutationFn: (fm, input) => fm.renameDirectory(input.directoryUri, input.newName), 57 + optimisticUpdate: (snapshot, input) => { 58 + const dir = snapshot.directories[input.directoryUri]; 59 + if (!dir) return snapshot; 60 + 61 + return { 62 + ...snapshot, 63 + directories: { 64 + ...snapshot.directories, 65 + [input.directoryUri]: { ...dir, name: input.newName }, 66 + }, 67 + }; 68 + }, 69 + }); 70 + } 71 + 72 + /** 73 + * Recursively delete a directory and all its contents. 74 + */ 75 + export function useDeleteDirectory(keyringUri: string | null) { 76 + return useTreeMutation< 77 + DeleteDirectoryInput, 78 + { documentsDeleted: number; directoriesDeleted: number } 79 + >({ 80 + keyringUri, 81 + mutationFn: (fm, input) => fm.deleteRecursive(input.directoryUri), 82 + }); 83 + }
+168 -67
packages/opake-react/src/hooks/use-directory.ts
··· 1 - import type { MutationResult, UploadResult } from "@opake/sdk"; 2 - import { useTreeMutation } from "./use-tree-mutation"; 1 + "use client"; 3 2 4 - interface CreateDirectoryInput { 5 - readonly name: string; 6 - readonly parentUri?: string; 7 - } 3 + // useDirectory — subscription-based tree hook backed by 4 + // `FileManager.watchDirectory`. The recommended replacement for 5 + // `useTree` in components that want live updates as SSE events arrive. 6 + // 7 + // Lifecycle on mount (or deps change): 8 + // 1. Acquire a FileManager via useFileManager (shared via provider cache) 9 + // 2. Resolve the target URI: 10 + // - If `directoryUri` is non-null, use it 11 + // - Otherwise `loadTree()` to discover the root 12 + // 3. Install a watcher via `fm.watchDirectory(target, handler)` 13 + // 4. Handler fires once eagerly with the current state, then again 14 + // on every SSE event that affects the tree 15 + // 5. `null` from the handler = directory deleted → update state and 16 + // let the watcher auto-close 17 + // 18 + // Lifecycle on unmount: 19 + // 1. Mark the effect cancelled (so in-flight loadTree won't set state) 20 + // 2. Close the watcher if one was installed 21 + // 3. useFileManager's own cleanup releases the FileManager 22 + // 23 + // Tree idempotency handles self-echoes: a local mutation's SSE echo 24 + // arrives as the same tree state the mutation already wrote, and the 25 + // WASM TreeKeeper dedupes at the record layer. 8 26 9 - interface RenameDirectoryInput { 10 - readonly directoryUri: string; 11 - readonly newName: string; 12 - } 27 + import { useEffect, useState } from "react"; 28 + import type { DirectoryTreeSnapshot, DirectoryWatcher, FileManager } from "@opake/sdk"; 29 + import { useFileManager } from "./use-file-manager"; 13 30 14 - interface DeleteDirectoryInput { 15 - readonly directoryUri: string; 31 + interface UseDirectoryResult { 32 + /** Latest snapshot. null until the first watcher fire. */ 33 + readonly snapshot: DirectoryTreeSnapshot | null; 34 + /** true once we have a snapshot. */ 35 + readonly isReady: boolean; 36 + /** Non-null if initial loadTree or watcher installation failed. */ 37 + readonly error: Error | null; 38 + /** 39 + * The resolved directory URI actually being watched. May be the 40 + * root URI (if `directoryUri` was passed as null), or null if the 41 + * tree has no root yet (empty cabinet/workspace). 42 + */ 43 + readonly resolvedDirectoryUri: string | null; 16 44 } 17 45 18 46 /** 19 - * Create a new directory. 47 + * Subscribe to live directory tree updates for a cabinet or workspace. 48 + * 49 + * Pass `keyringUri = null` for the cabinet, or a workspace keyring 50 + * URI. Pass `directoryUri = null` to watch the root directory of the 51 + * context; otherwise pass a specific directory at-uri. 20 52 * 21 - * Optimistic: the directory appears immediately in the parent. 53 + * The returned snapshot reflects the latest state received from the 54 + * WASM TreeKeeper, which applies SSE events from the appview as they 55 + * arrive. No manual refetch or cache invalidation needed — remote 56 + * changes appear automatically within a firehose round-trip 57 + * (typically <1s). 58 + * 59 + * Requires an `OpakeProvider` ancestor with `disableSseAutoStart` 60 + * unset (the default), OR an explicit `useSseConsumer()` call 61 + * somewhere higher in the tree. Without an active SSE consumer the 62 + * hook will still load the initial tree, but won't receive live 63 + * updates. 64 + * 65 + * @example 66 + * ```tsx 67 + * function CabinetView() { 68 + * const { snapshot, isReady, error } = useDirectory(null, null); 69 + * if (error) return <ErrorBox error={error} />; 70 + * if (!isReady) return <Spinner />; 71 + * return <DirectoryTreeView snapshot={snapshot!} />; 72 + * } 73 + * ``` 22 74 */ 23 - export function useCreateDirectory(keyringUri: string | null) { 24 - return useTreeMutation<CreateDirectoryInput, UploadResult>({ 25 - keyringUri, 26 - mutationFn: (fm, input) => fm.createDirectory(input.name, input.parentUri), 27 - optimisticUpdate: (snapshot, input) => { 28 - if (!input.parentUri) return snapshot; 29 - const parent = snapshot.directories[input.parentUri]; 30 - if (!parent) return snapshot; 75 + // State commits are keyed by `(fileManager, directoryUri)` so we can 76 + // distinguish a stale commit (from a previous load) from the current 77 + // target during render. Eagerly nulling state in the effect would 78 + // trigger `react-hooks/set-state-in-effect`; the derive-on-match 79 + // pattern dodges that while preserving the same "loading" semantics. 80 + interface Commit { 81 + readonly fileManager: FileManager; 82 + readonly directoryUri: string | null; 83 + readonly snapshot: DirectoryTreeSnapshot | null; 84 + readonly resolvedDirectoryUri: string | null; 85 + readonly error: Error | null; 86 + } 87 + 88 + export function useDirectory( 89 + keyringUri: string | null, 90 + directoryUri: string | null, 91 + ): UseDirectoryResult { 92 + const { fileManager, isReady: fmReady } = useFileManager(keyringUri); 93 + const [commit, setCommit] = useState<Commit | null>(null); 94 + 95 + useEffect(() => { 96 + if (!fmReady || !fileManager) return; 97 + 98 + const fm = fileManager; 99 + // Object-wrapped so ESLint's flow analysis treats it as 100 + // potentially-mutated across an `await` boundary. A plain 101 + // `let cancelled` produces "value is always falsy" false 102 + // positives in the async resumption paths below. 103 + const state = { cancelled: false, watcher: null as DirectoryWatcher | null }; 104 + 105 + const installWatcher = (target: string): void => { 106 + // Commit the resolved URI immediately so consumers can render 107 + // loading states with the correct target. The snapshot stays 108 + // null until the watcher fires. 109 + setCommit({ 110 + fileManager: fm, 111 + directoryUri, 112 + snapshot: null, 113 + resolvedDirectoryUri: target, 114 + error: null, 115 + }); 116 + state.watcher = fm.watchDirectory(target, (snap) => { 117 + // Watcher fires with null when the watched directory is 118 + // deleted; the WASM side auto-closes the watcher after 119 + // that call, so no need to call .close() from here. 120 + setCommit({ 121 + fileManager: fm, 122 + directoryUri, 123 + snapshot: snap, 124 + resolvedDirectoryUri: target, 125 + error: null, 126 + }); 127 + }); 128 + }; 31 129 32 - const placeholderUri = `pending-dir:${input.name}:${Date.now()}`; 33 - return { 34 - ...snapshot, 35 - directories: { 36 - ...snapshot.directories, 37 - [input.parentUri]: { 38 - ...parent, 39 - entries: [...parent.entries, { uri: placeholderUri, type: "directory" as const }], 40 - }, 41 - [placeholderUri]: { name: input.name, entries: [], parentUri: input.parentUri }, 42 - }, 43 - }; 44 - }, 45 - }); 46 - } 130 + void (async () => { 131 + try { 132 + if (directoryUri !== null) { 133 + installWatcher(directoryUri); 134 + return; 135 + } 47 136 48 - /** 49 - * Rename a directory. 50 - * 51 - * Optimistic: the name changes immediately in the tree. 52 - */ 53 - export function useRenameDirectory(keyringUri: string | null) { 54 - return useTreeMutation<RenameDirectoryInput, MutationResult>({ 55 - keyringUri, 56 - mutationFn: (fm, input) => fm.renameDirectory(input.directoryUri, input.newName), 57 - optimisticUpdate: (snapshot, input) => { 58 - const dir = snapshot.directories[input.directoryUri]; 59 - if (!dir) return snapshot; 137 + const tree = await fm.loadTree(); 138 + if (state.cancelled) return; 139 + if (tree.rootUri === null) { 140 + // No root yet (empty cabinet/workspace). Commit the empty 141 + // tree snapshot so the UI can render "no root, create one". 142 + setCommit({ 143 + fileManager: fm, 144 + directoryUri, 145 + snapshot: tree, 146 + resolvedDirectoryUri: null, 147 + error: null, 148 + }); 149 + return; 150 + } 151 + installWatcher(tree.rootUri); 152 + } catch (err) { 153 + if (state.cancelled) return; 154 + setCommit({ 155 + fileManager: fm, 156 + directoryUri, 157 + snapshot: null, 158 + resolvedDirectoryUri: null, 159 + error: err as Error, 160 + }); 161 + } 162 + })(); 60 163 61 - return { 62 - ...snapshot, 63 - directories: { 64 - ...snapshot.directories, 65 - [input.directoryUri]: { ...dir, name: input.newName }, 66 - }, 67 - }; 68 - }, 69 - }); 70 - } 164 + return () => { 165 + state.cancelled = true; 166 + state.watcher?.close(); 167 + }; 168 + }, [fileManager, fmReady, directoryUri]); 71 169 72 - /** 73 - * Recursively delete a directory and all its contents. 74 - */ 75 - export function useDeleteDirectory(keyringUri: string | null) { 76 - return useTreeMutation<DeleteDirectoryInput, { documentsDeleted: number; directoriesDeleted: number }>({ 77 - keyringUri, 78 - mutationFn: (fm, input) => fm.deleteRecursive(input.directoryUri), 79 - }); 170 + // Only honor a commit whose keys match the current render's props. 171 + const current = 172 + commit !== null && commit.fileManager === fileManager && commit.directoryUri === directoryUri 173 + ? commit 174 + : null; 175 + return { 176 + snapshot: current?.snapshot ?? null, 177 + isReady: current?.snapshot != null, 178 + error: current?.error ?? null, 179 + resolvedDirectoryUri: current?.resolvedDirectoryUri ?? null, 180 + }; 80 181 }
+88
packages/opake-react/src/hooks/use-file-manager.ts
··· 1 + "use client"; 2 + 3 + // useFileManager — acquire a shared FileManager via the provider's 4 + // FileManagerCache. This is the primitive that long-lived subscription 5 + // hooks (like useDirectory) build on: it gives them a FileManager that 6 + // outlives their own mount scope and is shared with any other hook 7 + // watching the same context. 8 + // 9 + // Lifecycle: 10 + // Mount → cache.acquire(keyringUri) → Promise resolves → state set 11 + // Unmount → cache.release(keyringUri) 12 + // Prop change → release old, acquire new 13 + // 14 + // StrictMode double-mounts are correct because acquire increments 15 + // refcount and release decrements it; the refcount is balanced at steady 16 + // state regardless of how many effects fired. 17 + 18 + import { useEffect, useState } from "react"; 19 + import type { FileManager } from "@opake/sdk"; 20 + import { useFileManagerCache } from "../provider"; 21 + 22 + interface UseFileManagerResult { 23 + /** The resolved FileManager, or null while the promise is in-flight. */ 24 + readonly fileManager: FileManager | null; 25 + /** True once the FileManager is available. */ 26 + readonly isReady: boolean; 27 + /** Non-null if acquisition failed. */ 28 + readonly error: Error | null; 29 + } 30 + 31 + /** 32 + * Acquire a shared FileManager for a cabinet or workspace context. 33 + * 34 + * Pass `null` for the cabinet, or a workspace keyring URI. The hook 35 + * holds a reference for its lifetime; multiple mounted hooks for the 36 + * same context share one underlying FileManager. 37 + * 38 + * @example 39 + * ```tsx 40 + * const { fileManager, isReady } = useFileManager(null); // cabinet 41 + * if (!isReady) return <Spinner />; 42 + * // fileManager is safe to use 43 + * ``` 44 + */ 45 + // State is keyed by the `keyringUri` that produced it. When the prop 46 + // changes, the commit is "stale" until the new promise resolves — we 47 + // detect that by comparing `state.key` to the current prop during render 48 + // rather than eagerly nulling state inside the effect (which would 49 + // trigger `react-hooks/set-state-in-effect` and cascade-render). 50 + interface Commit { 51 + readonly key: string | null; 52 + readonly fileManager: FileManager | null; 53 + readonly error: Error | null; 54 + } 55 + 56 + export function useFileManager(keyringUri: string | null): UseFileManagerResult { 57 + const cache = useFileManagerCache(); 58 + const [commit, setCommit] = useState<Commit | null>(null); 59 + 60 + useEffect(() => { 61 + let cancelled = false; 62 + 63 + cache.acquire(keyringUri).then( 64 + (fm) => { 65 + if (!cancelled) setCommit({ key: keyringUri, fileManager: fm, error: null }); 66 + }, 67 + (err: unknown) => { 68 + if (!cancelled) { 69 + setCommit({ key: keyringUri, fileManager: null, error: err as Error }); 70 + } 71 + }, 72 + ); 73 + 74 + return () => { 75 + cancelled = true; 76 + cache.release(keyringUri); 77 + }; 78 + }, [cache, keyringUri]); 79 + 80 + // Derive: only honor commits whose key matches the current prop. 81 + // Otherwise we're mid-transition and callers should see "loading". 82 + const current = commit?.key === keyringUri ? commit : null; 83 + return { 84 + fileManager: current?.fileManager ?? null, 85 + isReady: current?.fileManager != null, 86 + error: current?.error ?? null, 87 + }; 88 + }
+53
packages/opake-react/src/hooks/use-sse-consumer.ts
··· 1 + "use client"; 2 + 3 + // useSseConsumer — imperative SSE consumer start. 4 + // 5 + // Alternative to the Provider's auto-start. Useful when you want to 6 + // gate the consumer on a runtime condition (authenticated && online, 7 + // feature flag, etc.) rather than starting unconditionally at 8 + // provider mount. 9 + // 10 + // Idempotent: calling this while another start is already in flight 11 + // is safe — the WASM-side `sse_started` flag prevents double-spawn. 12 + // So calling it alongside the Provider's auto-start is a no-op on 13 + // the second call. 14 + 15 + import { useEffect } from "react"; 16 + import { useOpake } from "../provider"; 17 + 18 + /** 19 + * Start the WASM SSE consumer imperatively. 20 + * 21 + * Omit `appviewUrl` to use the URL stored on the Opake instance from 22 + * config (recommended). Pass an explicit value to override for 23 + * instances without stored config. 24 + * 25 + * The Provider auto-starts the consumer unless `disableSseAutoStart` 26 + * is set, so in most apps you don't need this hook at all. Use it 27 + * when you want explicit control over WHEN the consumer starts 28 + * (e.g., only after the user has granted camera permissions, or 29 + * only when a feature flag is enabled). 30 + * 31 + * @example 32 + * ```tsx 33 + * function Gate() { 34 + * const isAuthenticated = useAuth(); 35 + * useSseConsumer(isAuthenticated ? undefined : null); 36 + * return <Outlet />; 37 + * } 38 + * ``` 39 + */ 40 + export function useSseConsumer(appviewUrl?: string | null): void { 41 + const opake = useOpake(); 42 + 43 + useEffect(() => { 44 + // Skip when explicitly nulled — lets callers opt out conditionally 45 + // (e.g., `useSseConsumer(isAuthenticated ? undefined : null)`) 46 + // without breaking the rules of hooks. 47 + if (appviewUrl === null) return; 48 + 49 + void opake.startSseConsumer(appviewUrl).catch((err: unknown) => { 50 + console.warn("[opake-react] startSseConsumer failed:", err); 51 + }); 52 + }, [opake, appviewUrl]); 53 + }
+9 -2
packages/opake-react/src/hooks/use-tree-mutation.ts
··· 30 30 * Optimistic tree update — return the updated snapshot. 31 31 * Return the original to skip optimistic update. 32 32 */ 33 - readonly optimisticUpdate?: (snapshot: DirectoryTreeSnapshot, input: TInput) => DirectoryTreeSnapshot; 33 + readonly optimisticUpdate?: ( 34 + snapshot: DirectoryTreeSnapshot, 35 + input: TInput, 36 + ) => DirectoryTreeSnapshot; 34 37 } 35 38 36 39 /** ··· 50 53 51 54 onMutate: options.optimisticUpdate 52 55 ? async (input) => { 56 + // Narrow the optimisticUpdate callback once so the closure 57 + // below isn't fighting the "options might have changed" 58 + // widening. This also avoids the non-null assertion. 59 + const apply = options.optimisticUpdate; 53 60 await queryClient.cancelQueries({ queryKey: key }); 54 61 const previous = queryClient.getQueryData<DirectoryTreeSnapshot>(key); 55 62 56 63 if (previous) { 57 64 queryClient.setQueryData<DirectoryTreeSnapshot>(key, (old) => 58 - old ? options.optimisticUpdate!(old, input) : old, 65 + old ? apply(old, input) : old, 59 66 ); 60 67 } 61 68
+11 -5
packages/opake-react/src/hooks/use-tree.ts
··· 5 5 import { withFileManager } from "./use-tree-mutation"; 6 6 7 7 /** 8 - * Load a directory tree (cabinet or workspace). 8 + * Load a directory tree (cabinet or workspace) as a one-shot query. 9 9 * 10 - * Read-only — loads from cache + AppView sync, no PDS writes. 11 - * Pass null for cabinet, or a workspace keyring URI. 10 + * Read-only — loads from cache + AppView sync, no PDS writes. Uses 11 + * `keepPreviousData` so navigation between directories doesn't flash 12 + * a loading state when refetching. 12 13 * 13 - * Uses `keepPreviousData` so navigation between directories doesn't 14 - * flash a loading state when refetching. 14 + * @deprecated Prefer `useDirectory(keyringUri, directoryUri)` for 15 + * subscription-based reads. `useTree` is query-cache-based and only 16 + * refreshes when a local mutation invalidates the cache — remote 17 + * changes from other clients never appear unless the consumer 18 + * manually invalidates. `useDirectory` subscribes via 19 + * `FileManager.watchDirectory` so SSE-driven updates surface 20 + * automatically. 15 21 * 16 22 * @param keyringUri - Workspace keyring URI, or null for cabinet. 17 23 *
+14 -2
packages/opake-react/src/index.ts
··· 6 6 // Shared helpers (for custom hooks) 7 7 export { withFileManager, treeKeyFor, useTreeMutation } from "./hooks/use-tree-mutation"; 8 8 9 - // Query hooks 9 + // Subscription hooks (SSE-driven, live updates) — preferred for reads 10 + export { useFileManager } from "./hooks/use-file-manager"; 11 + export { useDirectory } from "./hooks/use-directory"; 12 + export { useSseConsumer } from "./hooks/use-sse-consumer"; 13 + 14 + // Query hooks (react-query cache) — kept for mutation invalidation 15 + // paths and workspace list. New consumers should prefer `useDirectory` 16 + // for directory reads. 17 + // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional re-export for legacy consumers 10 18 export { useTree } from "./hooks/use-tree"; 11 19 export { useWorkspaces } from "./hooks/use-workspaces"; 12 20 export { useDirectoryMetadata } from "./hooks/use-directory-metadata"; ··· 16 24 export { useUpload } from "./hooks/use-upload"; 17 25 export { useDelete } from "./hooks/use-delete"; 18 26 export { useMove } from "./hooks/use-move"; 19 - export { useCreateDirectory, useRenameDirectory, useDeleteDirectory } from "./hooks/use-directory"; 27 + export { 28 + useCreateDirectory, 29 + useRenameDirectory, 30 + useDeleteDirectory, 31 + } from "./hooks/use-directory-mutations"; 20 32 export { useCreateWorkspace } from "./hooks/use-create-workspace"; 21 33 22 34 // Daemon integration
+88 -12
packages/opake-react/src/provider.tsx
··· 1 - // OpakeProvider — React context for the Opake instance. 1 + "use client"; 2 + 3 + // OpakeProvider — React context for the Opake instance, a shared 4 + // FileManager cache, and automatic SSE consumer startup. 5 + // 6 + // Components access: 7 + // - The raw Opake instance via `useOpake()` (existing API) 8 + // - A refcounted FileManager cache via `useFileManagerCache()` (internal) 9 + // 10 + // On mount, the provider calls `opake.startSseConsumer()` unless 11 + // `disableSseAutoStart` is set. This uses the appview URL already 12 + // stored on the Opake instance (from config). No `appviewUrl` prop 13 + // required — matches how `requestSseToken`, `listWorkspaces`, etc. 14 + // resolve the URL internally. 2 15 // 3 - // Wraps the app with an Opake instance and optionally a QueryClient. 4 - // Components access the instance via useOpake(). 16 + // FileManagerCache lifetime is tied to the provider: unmounting the 17 + // provider disposes all cached FileManagers. A new Opake instance 18 + // passed as a prop creates a fresh cache. 5 19 6 - import { createContext, useContext, useRef, type ReactNode } from "react"; 20 + import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"; 7 21 import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 8 22 import type { Opake } from "@opake/sdk"; 23 + import { FileManagerCache } from "./file-manager-cache"; 9 24 10 25 const OpakeContext = createContext<Opake | null>(null); 26 + const FileManagerCacheContext = createContext<FileManagerCache | null>(null); 11 27 12 28 /** 13 29 * Access the Opake instance from context. ··· 28 44 return opake; 29 45 } 30 46 47 + /** 48 + * Access the shared FileManagerCache. Internal — consumers should use 49 + * `useFileManager(keyringUri)` instead, which handles acquire/release 50 + * lifecycle for you. 51 + * 52 + * @internal 53 + */ 54 + export function useFileManagerCache(): FileManagerCache { 55 + const cache = useContext(FileManagerCacheContext); 56 + if (!cache) { 57 + throw new Error("useFileManagerCache must be used within an OpakeProvider"); 58 + } 59 + return cache; 60 + } 61 + 31 62 interface OpakeProviderProps { 32 63 /** An initialized Opake instance (or Comlink proxy to one in a worker). */ 33 64 readonly opake: Opake; 65 + /** 66 + * Disable automatic SSE consumer start. Default false: the provider 67 + * calls `opake.startSseConsumer()` on mount, which uses the appview 68 + * URL already stored on the Opake instance from `Opake.init()`. Set 69 + * true for tests, or for consumers that want explicit control via 70 + * `useSseConsumer` or a manual `opake.startSseConsumer()` call. 71 + */ 72 + readonly disableSseAutoStart?: boolean; 34 73 /** Optional QueryClient — one is created if not provided. */ 35 74 readonly queryClient?: QueryClient; 36 75 readonly children: ReactNode; ··· 48 87 } 49 88 50 89 /** 51 - * Provide an Opake instance and QueryClient to the React tree. 90 + * Provide an Opake instance, QueryClient, and FileManagerCache to the 91 + * React tree. Auto-starts the WASM SSE consumer on mount. 52 92 * 53 93 * @example 54 94 * ```tsx ··· 66 106 * } 67 107 * ``` 68 108 */ 69 - export function OpakeProvider({ opake, queryClient, children }: OpakeProviderProps) { 70 - const defaultClient = useRef<QueryClient | null>(null); 71 - if (!queryClient && !defaultClient.current) { 72 - defaultClient.current = createDefaultQueryClient(); 73 - } 109 + export function OpakeProvider({ 110 + opake, 111 + disableSseAutoStart, 112 + queryClient, 113 + children, 114 + }: OpakeProviderProps) { 115 + // `useMemo` is the canonical "lazy init per component instance" pattern 116 + // in React 19 — `useRef` with a render-time assignment reads a ref during 117 + // render, which react-hooks/refs rightly forbids. 118 + const activeClient = useMemo(() => queryClient ?? createDefaultQueryClient(), [queryClient]); 119 + 120 + // FileManagerCache is tied to the current opake instance. If the 121 + // consumer swaps Opake instances (account switch), we build a fresh 122 + // cache and let the old one GC naturally. 123 + const cache = useMemo(() => new FileManagerCache(opake), [opake]); 124 + 125 + // Dispose cached FileManagers when the cache is replaced or the 126 + // provider unmounts. 127 + useEffect(() => { 128 + return () => { 129 + cache.disposeAll(); 130 + }; 131 + }, [cache]); 132 + 133 + // Auto-start the WASM SSE consumer and stop it on unmount so 134 + // `TreeKeeper::uninstall_all` runs — otherwise a previous user's 135 + // `ContentKey`s and decrypted directory names linger across an 136 + // account switch. 137 + useEffect(() => { 138 + if (disableSseAutoStart) return; 139 + void opake.startSseConsumer().catch((err: unknown) => { 140 + console.warn("[opake-react] startSseConsumer failed:", err); 141 + }); 142 + return () => { 143 + try { 144 + opake.stopSseConsumer(); 145 + } catch (err) { 146 + console.warn("[opake-react] stopSseConsumer failed:", err); 147 + } 148 + }; 149 + }, [opake, disableSseAutoStart]); 74 150 75 151 return ( 76 - <QueryClientProvider client={queryClient ?? defaultClient.current!}> 152 + <QueryClientProvider client={activeClient}> 77 153 <OpakeContext value={opake}> 78 - {children} 154 + <FileManagerCacheContext value={cache}>{children}</FileManagerCacheContext> 79 155 </OpakeContext> 80 156 </QueryClientProvider> 81 157 );
+10
packages/opake-react/vitest.config.ts
··· 1 + import { defineConfig } from "vitest/config"; 2 + 3 + export default defineConfig({ 4 + test: { 5 + environment: "jsdom", 6 + globals: false, 7 + include: ["src/**/*.{test,spec}.{ts,tsx}"], 8 + setupFiles: ["./src/__tests__/setup.ts"], 9 + }, 10 + });
+109
packages/opake-sdk/eslint.config.ts
··· 1 + import tseslint from "typescript-eslint"; 2 + import { makeBaseConfig } from "../../eslint.config.base.ts"; 3 + 4 + export default tseslint.config( 5 + ...makeBaseConfig({ tsconfigRootDir: import.meta.dirname }), 6 + 7 + // --------------------------------------------------------------------------- 8 + // Additional ignores — SDK pins a generated WASM bundle 9 + // --------------------------------------------------------------------------- 10 + { 11 + ignores: ["wasm/**"], 12 + }, 13 + 14 + // --------------------------------------------------------------------------- 15 + // Overrides: Storage adapters 16 + // 17 + // The Storage trait requires `Promise`-returning methods, but the 18 + // MemoryStorage adapter resolves synchronously and the IndexedDB 19 + // adapter resolves synchronously in most Dexie paths. Silencing 20 + // `require-await` here matches what the adapters have to look like. 21 + // Loops + mutation are needed for the bulk put/collection helpers. 22 + // --------------------------------------------------------------------------- 23 + { 24 + files: ["src/storage/**/*.ts"], 25 + rules: { 26 + "@typescript-eslint/require-await": "off", 27 + "functional/no-let": "off", 28 + "functional/no-loop-statements": "off", 29 + "functional/immutable-data": "off", 30 + "functional/prefer-immutable-types": "off", 31 + }, 32 + }, 33 + 34 + // --------------------------------------------------------------------------- 35 + // Overrides: WASM module binding 36 + // 37 + // Lazy-init caches the decoded WASM module on first import. That's a 38 + // module-level `let` — there's no reactive alternative at this layer. 39 + // --------------------------------------------------------------------------- 40 + { 41 + files: ["src/wasm.ts"], 42 + rules: { 43 + "functional/no-let": "off", 44 + "functional/prefer-immutable-types": "off", 45 + }, 46 + }, 47 + 48 + // --------------------------------------------------------------------------- 49 + // Overrides: Opake + errors (TC39 decorator target erasure) 50 + // 51 + // `withTokenGuard` and `wrapWasmErrors` are TC39 decorators. The 52 + // method-decorator signature types `target` as `any` by spec — 53 + // there's no narrower type we could use without giving up the 54 + // decorator pattern entirely. The `_target` / `_context` naming 55 + // signals intent; we still need to read the underscore-prefixed 56 + // params via `.call` to forward the call. 57 + // 58 + // Opake holds mutable private state (`ctx`, `refreshPromise`) as 59 + // part of its lifecycle — destroy() nulls ctx, the refresh 60 + // single-flight gate flips. Class fields can't be readonly here. 61 + // --------------------------------------------------------------------------- 62 + { 63 + files: ["src/opake.ts", "src/errors.ts"], 64 + rules: { 65 + "@typescript-eslint/no-explicit-any": "off", 66 + "@typescript-eslint/no-unused-vars": [ 67 + "error", 68 + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 69 + ], 70 + "@typescript-eslint/no-unsafe-call": "off", 71 + "@typescript-eslint/no-unsafe-member-access": "off", 72 + "@typescript-eslint/no-unsafe-assignment": "off", 73 + "functional/no-let": "off", 74 + "functional/prefer-immutable-types": "off", 75 + }, 76 + }, 77 + 78 + // --------------------------------------------------------------------------- 79 + // Overrides: file-manager + pairing (byte manipulation + iteration) 80 + // 81 + // FileManager iterates result arrays and builds decrypted records 82 + // with imperative loops — the functional patterns don't help here. 83 + // Pairing has base64 decoding via a classic `for` loop. 84 + // --------------------------------------------------------------------------- 85 + { 86 + files: ["src/file-manager.ts", "src/pairing.ts"], 87 + rules: { 88 + "functional/no-let": "off", 89 + "functional/no-loop-statements": "off", 90 + "functional/immutable-data": "off", 91 + "functional/prefer-immutable-types": "off", 92 + }, 93 + }, 94 + 95 + // --------------------------------------------------------------------------- 96 + // Overrides: test files 97 + // --------------------------------------------------------------------------- 98 + { 99 + files: ["src/__tests__/**/*.ts", "src/**/*.test.ts"], 100 + rules: { 101 + "functional/no-let": "off", 102 + "functional/immutable-data": "off", 103 + "@typescript-eslint/no-unsafe-assignment": "off", 104 + "@typescript-eslint/no-unsafe-member-access": "off", 105 + "@typescript-eslint/no-unsafe-call": "off", 106 + "@typescript-eslint/no-non-null-assertion": "off", 107 + }, 108 + }, 109 + );
+5 -1
packages/opake-sdk/package.json
··· 25 25 "scripts": { 26 26 "build": "tsup", 27 27 "test": "vitest run", 28 - "typedoc": "typedoc" 28 + "typedoc": "typedoc", 29 + "lint": "eslint src/", 30 + "lint:fix": "eslint src/ --fix", 31 + "format": "prettier --write 'src/**/*.ts'", 32 + "format:check": "prettier --check 'src/**/*.ts'" 29 33 }, 30 34 "peerDependencies": { 31 35 "dexie": "^4.0.0"
+1 -3
packages/opake-sdk/src/auth.ts
··· 44 44 * - CLI: print URL, start localhost server, wait for callback 45 45 * - Electron: open BrowserWindow, intercept redirect 46 46 */ 47 - readonly authorize: ( 48 - authUrl: string, 49 - ) => Promise<{ code: string; state: string }>; 47 + readonly authorize: (authUrl: string) => Promise<{ code: string; state: string }>; 50 48 /** Abort signal for timeout/cancellation. */ 51 49 readonly signal?: AbortSignal; 52 50 }
+4 -2
packages/opake-sdk/src/errors.ts
··· 68 68 * Falls back to `Unknown` if the format doesn't match. 69 69 */ 70 70 /** Decorator: catch WASM errors and rethrow as typed OpakeError. Works on sync and async methods. */ 71 - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TC39 decorator type erasure 72 71 export function wrapWasmErrors(_target: any, _context: ClassMethodDecoratorContext) { 73 72 return function (this: unknown, ...args: any[]): any { 74 73 try { 75 74 const result = _target.call(this, ...args); 76 - if (result instanceof Promise) return result.catch((e: unknown) => { throw parseWasmError(e); }); 75 + if (result instanceof Promise) 76 + return result.catch((e: unknown) => { 77 + throw parseWasmError(e); 78 + }); 77 79 return result; 78 80 } catch (e) { 79 81 throw parseWasmError(e);
-279
packages/opake-sdk/src/event-stream.ts
··· 1 - // Real-time event streaming from the Opake appview via Server-Sent Events. 2 - // 3 - // Opens an SSE connection authenticated via a short-lived token (obtained 4 - // from the appview's Ed25519-signed token endpoint). Auto-reconnects with 5 - // exponential backoff; fires `onReconnect` so the consumer can full-sync 6 - // to cover the gap. 7 - 8 - import { z } from "zod"; 9 - 10 - // --------------------------------------------------------------------------- 11 - // SSE event schemas (validate appview payloads at the boundary) 12 - // --------------------------------------------------------------------------- 13 - 14 - export const sseDirectorySchema = z.object({ 15 - directory_uri: z.string(), 16 - owner_did: z.string(), 17 - entries: z.array(z.unknown()).default([]), 18 - encrypted_metadata: z.unknown().nullish(), 19 - key_wrapping: z.unknown().nullish(), 20 - keyring_uri: z.string().nullish(), 21 - deleted_at: z.string().nullish(), 22 - indexed_at: z.string().nullish(), 23 - }); 24 - 25 - export const sseDocumentSchema = z.object({ 26 - document_uri: z.string(), 27 - owner_did: z.string(), 28 - encrypted_metadata: z.unknown().nullish(), 29 - encryption: z.unknown().nullish(), 30 - blob_ref: z.unknown().nullish(), 31 - keyring_uri: z.string().nullish(), 32 - rotation: z.number().nullish(), 33 - deleted_at: z.string().nullish(), 34 - indexed_at: z.string().nullish(), 35 - }); 36 - 37 - export const sseKeyringSchema = z.object({ 38 - uri: z.string(), 39 - owner_did: z.string(), 40 - rotation: z.number().nullish(), 41 - member_entries: z.array(z.unknown()).default([]), 42 - encrypted_metadata: z.unknown().nullish(), 43 - created_at: z.string().nullish(), 44 - indexed_at: z.string().nullish(), 45 - }); 46 - 47 - export const sseGrantSchema = z.object({ 48 - uri: z.string(), 49 - owner_did: z.string(), 50 - recipient_did: z.string().nullish(), 51 - document_uri: z.string(), 52 - created_at: z.string().nullish(), 53 - }); 54 - 55 - export const sseDeleteSchema = z.object({ 56 - uri: z.string().optional(), 57 - directory_uri: z.string().optional(), 58 - document_uri: z.string().optional(), 59 - }); 60 - 61 - // Proposal schemas — workspace change proposals pending owner approval. 62 - // All proposals carry the same identity fields (uri + author + keyring); 63 - // individual schemas extend the base with their type-specific payload. 64 - 65 - const sseProposalBaseSchema = z.object({ 66 - uri: z.string(), 67 - author_did: z.string(), 68 - keyring_uri: z.string().nullish(), 69 - }); 70 - 71 - export const sseDirectoryUpdateSchema = sseProposalBaseSchema.extend({ 72 - action_type: z.string(), 73 - directory_uri: z.string().nullish(), 74 - entry_uri: z.string().nullish(), 75 - encrypted_metadata: z.unknown().nullish(), 76 - source_directory_uri: z.string().nullish(), 77 - target_directory_uri: z.string().nullish(), 78 - parent_directory_uri: z.string().nullish(), 79 - }); 80 - 81 - export const sseKeyringUpdateSchema = sseProposalBaseSchema.extend({ 82 - action_type: z.string(), 83 - member_did: z.string().nullish(), 84 - member_public_key: z.string().nullish(), 85 - role: z.string().nullish(), 86 - encrypted_metadata: z.unknown().nullish(), 87 - }); 88 - 89 - export const sseDocumentUpdateSchema = sseProposalBaseSchema.extend({ 90 - document_uri: z.string(), 91 - supersedes_uri: z.string().nullish(), 92 - }); 93 - 94 - // --------------------------------------------------------------------------- 95 - // Types 96 - // --------------------------------------------------------------------------- 97 - 98 - export type SSEDirectory = z.output<typeof sseDirectorySchema>; 99 - export type SSEDocument = z.output<typeof sseDocumentSchema>; 100 - export type SSEKeyring = z.output<typeof sseKeyringSchema>; 101 - export type SSEGrant = z.output<typeof sseGrantSchema>; 102 - export type SSEDelete = z.output<typeof sseDeleteSchema>; 103 - export type SSEDirectoryUpdate = z.output<typeof sseDirectoryUpdateSchema>; 104 - export type SSEKeyringUpdate = z.output<typeof sseKeyringUpdateSchema>; 105 - export type SSEDocumentUpdate = z.output<typeof sseDocumentUpdateSchema>; 106 - 107 - /** Handlers for SSE events. All optional — subscribe to what you need. */ 108 - export interface EventStreamHandlers { 109 - readonly onDirectoryUpsert?: (data: SSEDirectory) => void; 110 - readonly onDirectoryDelete?: (data: SSEDelete) => void; 111 - readonly onDocumentUpsert?: (data: SSEDocument) => void; 112 - readonly onDocumentDelete?: (data: SSEDelete) => void; 113 - readonly onKeyringUpsert?: (data: SSEKeyring) => void; 114 - readonly onKeyringDelete?: (data: SSEDelete) => void; 115 - readonly onGrantUpsert?: (data: SSEGrant) => void; 116 - readonly onGrantDelete?: (data: SSEDelete) => void; 117 - readonly onDirectoryUpdateUpsert?: (data: SSEDirectoryUpdate) => void; 118 - readonly onDirectoryUpdateDelete?: (data: SSEDelete) => void; 119 - readonly onKeyringUpdateUpsert?: (data: SSEKeyringUpdate) => void; 120 - readonly onKeyringUpdateDelete?: (data: SSEDelete) => void; 121 - readonly onDocumentUpdateUpsert?: (data: SSEDocumentUpdate) => void; 122 - readonly onDocumentUpdateDelete?: (data: SSEDelete) => void; 123 - /** Fired on reconnect — consumer should perform a full sync to cover the gap. */ 124 - readonly onReconnect?: () => void; 125 - readonly onError?: (error: Error) => void; 126 - readonly onOpen?: () => void; 127 - } 128 - 129 - /** Configuration for an EventStream. */ 130 - export interface EventStreamOptions { 131 - /** Appview base URL (e.g., "https://appview.opake.app"). */ 132 - readonly appviewUrl: string; 133 - /** Async function that returns a fresh single-use SSE token. Called on every connect/reconnect. */ 134 - readonly getToken: () => Promise<string>; 135 - /** Event handlers. */ 136 - readonly handlers: EventStreamHandlers; 137 - /** Max reconnect delay in ms (default: 30000). */ 138 - readonly maxReconnectDelay?: number; 139 - } 140 - 141 - // --------------------------------------------------------------------------- 142 - // EventStream 143 - // --------------------------------------------------------------------------- 144 - 145 - /** 146 - * Real-time event stream from the Opake appview. 147 - * 148 - * Connects via Server-Sent Events, authenticated with a short-lived token. 149 - * Auto-reconnects with exponential backoff on disconnect. 150 - * 151 - * @example 152 - * ```typescript 153 - * const stream = new EventStream({ 154 - * appviewUrl: "http://localhost:6100", 155 - * getToken: () => opake.requestSseToken(), 156 - * handlers: { 157 - * onDirectoryUpsert: (dir) => console.log("directory changed", dir), 158 - * onReconnect: () => store.fullSync(), 159 - * }, 160 - * }); 161 - * await stream.connect(); 162 - * // later: 163 - * stream.close(); 164 - * ``` 165 - */ 166 - export class EventStream { 167 - private eventSource: EventSource | null = null; 168 - private readonly appviewUrl: string; 169 - private readonly getToken: () => Promise<string>; 170 - private readonly handlers: EventStreamHandlers; 171 - private readonly maxReconnectDelay: number; 172 - private reconnectDelay = 1000; 173 - private reconnectTimer: ReturnType<typeof setTimeout> | null = null; 174 - private closed = false; 175 - private wasConnected = false; 176 - 177 - constructor(options: EventStreamOptions) { 178 - this.appviewUrl = options.appviewUrl; 179 - this.getToken = options.getToken; 180 - this.handlers = options.handlers; 181 - this.maxReconnectDelay = options.maxReconnectDelay ?? 30_000; 182 - } 183 - 184 - /** Open the SSE connection. Obtains a fresh token first. */ 185 - async connect(): Promise<void> { 186 - if (this.closed) return; 187 - 188 - try { 189 - const token = await this.getToken(); 190 - if (this.closed) return; // re-check after async gap (StrictMode cleanup race) 191 - 192 - const url = `${this.appviewUrl}/api/events?token=${encodeURIComponent(token)}`; 193 - const es = new EventSource(url); 194 - this.eventSource = es; 195 - 196 - es.onopen = () => { 197 - this.reconnectDelay = 1000; 198 - this.wasConnected = true; 199 - this.handlers.onOpen?.(); 200 - }; 201 - 202 - es.onerror = () => { 203 - es.close(); 204 - this.eventSource = null; 205 - this.scheduleReconnect(); 206 - }; 207 - 208 - // Register typed event listeners 209 - this.on(es, "directory:upsert", sseDirectorySchema, this.handlers.onDirectoryUpsert); 210 - this.on(es, "directory:delete", sseDeleteSchema, this.handlers.onDirectoryDelete); 211 - this.on(es, "document:upsert", sseDocumentSchema, this.handlers.onDocumentUpsert); 212 - this.on(es, "document:delete", sseDeleteSchema, this.handlers.onDocumentDelete); 213 - this.on(es, "keyring:upsert", sseKeyringSchema, this.handlers.onKeyringUpsert); 214 - this.on(es, "keyring:delete", sseDeleteSchema, this.handlers.onKeyringDelete); 215 - this.on(es, "grant:upsert", sseGrantSchema, this.handlers.onGrantUpsert); 216 - this.on(es, "grant:delete", sseDeleteSchema, this.handlers.onGrantDelete); 217 - // Proposal events (workspace change proposals pending owner approval) 218 - this.on(es, "directory_update:upsert", sseDirectoryUpdateSchema, this.handlers.onDirectoryUpdateUpsert); 219 - this.on(es, "directory_update:delete", sseDeleteSchema, this.handlers.onDirectoryUpdateDelete); 220 - this.on(es, "keyring_update:upsert", sseKeyringUpdateSchema, this.handlers.onKeyringUpdateUpsert); 221 - this.on(es, "keyring_update:delete", sseDeleteSchema, this.handlers.onKeyringUpdateDelete); 222 - this.on(es, "document_update:upsert", sseDocumentUpdateSchema, this.handlers.onDocumentUpdateUpsert); 223 - this.on(es, "document_update:delete", sseDeleteSchema, this.handlers.onDocumentUpdateDelete); 224 - } catch (e) { 225 - this.handlers.onError?.(e instanceof Error ? e : new Error(String(e))); 226 - this.scheduleReconnect(); 227 - } 228 - } 229 - 230 - /** Close the connection and stop reconnecting. */ 231 - close(): void { 232 - this.closed = true; 233 - if (this.reconnectTimer) { 234 - clearTimeout(this.reconnectTimer); 235 - this.reconnectTimer = null; 236 - } 237 - this.eventSource?.close(); 238 - this.eventSource = null; 239 - } 240 - 241 - /** Whether the connection is currently open. */ 242 - get connected(): boolean { 243 - return this.eventSource?.readyState === EventSource.OPEN; 244 - } 245 - 246 - // -- Internal -- 247 - 248 - private on<T>( 249 - es: EventSource, 250 - eventType: string, 251 - schema: z.ZodType<T>, 252 - handler?: (data: T) => void, 253 - ): void { 254 - if (!handler) return; 255 - es.addEventListener(eventType, ((e: MessageEvent) => { 256 - try { 257 - const parsed = schema.parse(JSON.parse(e.data as string)); 258 - handler(parsed); 259 - } catch (err) { 260 - this.handlers.onError?.( 261 - err instanceof Error ? err : new Error(`Failed to parse ${eventType} event`), 262 - ); 263 - } 264 - }) as EventListener); 265 - } 266 - 267 - private scheduleReconnect(): void { 268 - if (this.closed) return; 269 - // Only fire onReconnect after a previously-successful connection drops — 270 - // not on initial connection failure where there's no gap to sync. 271 - if (this.wasConnected) this.handlers.onReconnect?.(); 272 - // Jitter ±20% to prevent thundering herd on server restart 273 - const jitter = this.reconnectDelay * (0.8 + Math.random() * 0.4); 274 - this.reconnectTimer = setTimeout(() => { 275 - this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); 276 - void this.connect(); 277 - }, jitter); 278 - } 279 - }
+169 -18
packages/opake-sdk/src/file-manager.ts
··· 50 50 ): Promise<unknown>; 51 51 updateContent(documentUri: string, newPlaintext: Uint8Array): Promise<unknown>; 52 52 deleteRecursive(uri: string): Promise<unknown>; 53 - share(documentUri: string, recipientDid: string, recipientPublicKey: Uint8Array, permissions: string, note: string | null): Promise<unknown>; 53 + share( 54 + documentUri: string, 55 + recipientDid: string, 56 + recipientPublicKey: Uint8Array, 57 + permissions: string, 58 + note: string | null, 59 + ): Promise<unknown>; 54 60 revokeShare(grantUri: string): Promise<void>; 55 61 listShares(): Promise<unknown>; 56 62 syncAndApplyProposals(): Promise<number>; 57 63 isOwner(): boolean; 64 + watchDirectory( 65 + directoryUri: string, 66 + callback: (snapshot: unknown) => void, 67 + ): Promise<WasmDirectoryWatcher>; 68 + free(): void; 69 + }; 70 + 71 + /** WASM DirectoryWatcher handle — returned by watchDirectory. */ 72 + type WasmDirectoryWatcher = { 73 + close(): Promise<void>; 58 74 free(): void; 59 75 }; 60 76 61 77 /** 78 + * Handle returned by `FileManager.watchDirectory`. Call `.close()` to 79 + * unsubscribe — typically from a React useEffect cleanup. 80 + */ 81 + export interface DirectoryWatcher { 82 + /** Stop receiving notifications. Idempotent. */ 83 + close(): void; 84 + } 85 + 86 + /** 62 87 * File operations within a cabinet or workspace. 63 88 * 64 89 * Created via `opake.cabinet()` or `opake.workspace()`. Operates ··· 141 166 }, 142 167 ): Promise<UploadResult> { 143 168 return this.requireHandle().upload( 144 - data, filename, mimeType, 145 - options?.description ?? null, options?.tags ? [...options.tags] : null, options?.directoryUri ?? null, 169 + data, 170 + filename, 171 + mimeType, 172 + options?.description ?? null, 173 + options?.tags ? [...options.tags] : null, 174 + options?.directoryUri ?? null, 146 175 ) as Promise<UploadResult>; 147 176 } 148 177 ··· 182 211 */ 183 212 @wrapWasmErrors 184 213 delete(documentUri: string, parentDirectoryUri?: string): Promise<MutationResult> { 185 - return this.requireHandle().delete(documentUri, parentDirectoryUri ?? null) as Promise<MutationResult>; 214 + return this.requireHandle().delete( 215 + documentUri, 216 + parentDirectoryUri ?? null, 217 + ) as Promise<MutationResult>; 186 218 } 187 219 188 220 /** ··· 197 229 */ 198 230 @wrapWasmErrors 199 231 move(entryUri: string, sourceDirUri: string, targetDirUri: string): Promise<MutationResult> { 200 - return this.requireHandle().moveEntry(entryUri, sourceDirUri, targetDirUri) as Promise<MutationResult>; 232 + return this.requireHandle().moveEntry( 233 + entryUri, 234 + sourceDirUri, 235 + targetDirUri, 236 + ) as Promise<MutationResult>; 201 237 } 202 238 203 239 // --------------------------------------------------------------------------- ··· 242 278 for (const entry of parent.entries) { 243 279 if (entry.type === "directory") { 244 280 const dir = tree.directories[entry.uri]; 245 - if (dir && dir.name === name) { 281 + if (dir?.name === name) { 246 282 return { uri: entry.uri, created: false }; 247 283 } 248 284 } ··· 303 339 */ 304 340 @wrapWasmErrors 305 341 loadTree(): Promise<DirectoryTreeSnapshot> { 306 - return (this.requireHandle().loadTree() as Promise<{ snapshot: unknown }>) 307 - // Zod 4 z.record() with .transform() inner schemas loses type info. 308 - // Runtime validation is correct — the cast bridges the inference gap. 309 - .then((r) => directoryTreeSnapshotSchema.parse(r.snapshot) as DirectoryTreeSnapshot); 342 + return ( 343 + (this.requireHandle().loadTree() as Promise<{ snapshot: unknown }>) 344 + // Zod 4 z.record() with .transform() inner schemas loses type info. 345 + // Runtime validation is correct — the cast bridges the inference gap. 346 + .then((r) => directoryTreeSnapshotSchema.parse(r.snapshot) as DirectoryTreeSnapshot) 347 + ); 310 348 } 311 349 312 350 /** ··· 321 359 syncAndLoadTree( 322 360 directoryUri?: string, 323 361 ): Promise<{ snapshot: DirectoryTreeSnapshot; metadata: Record<string, DocumentMetadata> }> { 324 - return this.requireHandle().syncAndLoadTree(directoryUri ?? null) 325 - .then(treeWithMetadataSchema.parse) as Promise<{ snapshot: DirectoryTreeSnapshot; metadata: Record<string, DocumentMetadata> }>; 362 + return this.requireHandle() 363 + .syncAndLoadTree(directoryUri ?? null) 364 + .then(treeWithMetadataSchema.parse) as Promise<{ 365 + snapshot: DirectoryTreeSnapshot; 366 + metadata: Record<string, DocumentMetadata>; 367 + }>; 326 368 } 327 369 328 370 /** ··· 337 379 loadTreeWithMetadata( 338 380 directoryUri?: string, 339 381 ): Promise<{ snapshot: DirectoryTreeSnapshot; metadata: Record<string, DocumentMetadata> }> { 340 - return this.requireHandle().loadTreeWithMetadata(directoryUri ?? null) 341 - .then(treeWithMetadataSchema.parse) as Promise<{ snapshot: DirectoryTreeSnapshot; metadata: Record<string, DocumentMetadata> }>; 382 + return this.requireHandle() 383 + .loadTreeWithMetadata(directoryUri ?? null) 384 + .then(treeWithMetadataSchema.parse) as Promise<{ 385 + snapshot: DirectoryTreeSnapshot; 386 + metadata: Record<string, DocumentMetadata>; 387 + }>; 342 388 } 343 389 344 390 /** ··· 382 428 */ 383 429 @wrapWasmErrors 384 430 deleteRecursive(directoryUri: string): Promise<DeleteRecursiveResult> { 385 - return this.requireHandle().deleteRecursive(directoryUri).then(deleteRecursiveResultSchema.parse); 431 + return this.requireHandle() 432 + .deleteRecursive(directoryUri) 433 + .then(deleteRecursiveResultSchema.parse); 386 434 } 387 435 388 436 // --------------------------------------------------------------------------- ··· 401 449 updates: { filename?: string; description?: string; tags?: readonly string[] }, 402 450 ): Promise<MutationResult> { 403 451 return this.requireHandle().updateMetadata( 404 - documentUri, updates.filename ?? null, updates.tags ? [...updates.tags] : null, updates.description ?? null, 452 + documentUri, 453 + updates.filename ?? null, 454 + updates.tags ? [...updates.tags] : null, 455 + updates.description ?? null, 405 456 ) as Promise<MutationResult>; 406 457 } 407 458 ··· 433 484 */ 434 485 @wrapWasmErrors 435 486 share( 436 - documentUri: string, recipientDid: string, recipientPublicKey: Uint8Array, permissions: string, note?: string, 487 + documentUri: string, 488 + recipientDid: string, 489 + recipientPublicKey: Uint8Array, 490 + permissions: string, 491 + note?: string, 437 492 ): Promise<MutationResult> { 438 - return this.requireHandle().share(documentUri, recipientDid, recipientPublicKey, permissions, note ?? null) as Promise<MutationResult>; 493 + return this.requireHandle().share( 494 + documentUri, 495 + recipientDid, 496 + recipientPublicKey, 497 + permissions, 498 + note ?? null, 499 + ) as Promise<MutationResult>; 439 500 } 440 501 441 502 /** ··· 470 531 @wrapWasmErrors 471 532 syncAndApplyProposals(): Promise<number> { 472 533 return this.requireHandle().syncAndApplyProposals(); 534 + } 535 + 536 + // --------------------------------------------------------------------------- 537 + // Live tree subscriptions (SSE-driven) 538 + // --------------------------------------------------------------------------- 539 + 540 + /** 541 + * Subscribe to live changes for a specific directory. 542 + * 543 + * Fires the handler with the current snapshot once on registration 544 + * (if the tree has been loaded), then again whenever an SSE event 545 + * affects the tree. The handler receives `null` when the watched 546 + * directory is deleted — the watcher auto-closes after that call. 547 + * 548 + * Must be paired with `opake.startSseConsumer(appviewUrl)` to actually 549 + * receive events. Without the consumer, the watcher only fires once 550 + * with the initial snapshot. 551 + * 552 + * @param directoryUri - The AT URI of the directory to watch. 553 + * @param handler - Callback fired with a fresh snapshot per change. 554 + * @returns A watcher handle. Call `.close()` on unmount to unsubscribe. 555 + * 556 + * @example 557 + * ```typescript 558 + * useEffect(() => { 559 + * const watcher = fm.watchDirectory(dirUri, (snapshot) => { 560 + * if (snapshot === null) { 561 + * // directory was deleted — route away 562 + * navigate("/"); 563 + * return; 564 + * } 565 + * setTree(snapshot); 566 + * }); 567 + * return () => watcher.close(); 568 + * }, [dirUri]); 569 + * ``` 570 + */ 571 + watchDirectory( 572 + directoryUri: string, 573 + handler: (snapshot: DirectoryTreeSnapshot | null) => void, 574 + ): DirectoryWatcher { 575 + // The WASM binding is async (awaits the tree keeper mutex), but we 576 + // want to return a synchronous handle so React effects can use it 577 + // directly without an intermediate Promise. Kick off the registration 578 + // eagerly and expose a close() that chains onto the promise. 579 + const adapter = (snapshot: unknown) => { 580 + // WASM calls back with either the serialized snapshot or null. 581 + if (snapshot === null) { 582 + handler(null); 583 + return; 584 + } 585 + try { 586 + // The WASM-side snapshot is pre-serialized; trust the shape but 587 + // skip the full Zod parse for hot-path perf (React will re-render 588 + // regardless). 589 + handler(snapshot as DirectoryTreeSnapshot); 590 + } catch (err) { 591 + // One broken handler shouldn't break the event loop. 592 + console.warn("[opake-sdk] watchDirectory handler threw:", err); 593 + } 594 + }; 595 + 596 + const pending = this.requireHandle().watchDirectory(directoryUri, adapter); 597 + let closed = false; 598 + let wasmWatcher: WasmDirectoryWatcher | null = null; 599 + 600 + pending.then( 601 + (w) => { 602 + if (closed) { 603 + // close() was called before the handle resolved — clean up now. 604 + void w.close(); 605 + return; 606 + } 607 + wasmWatcher = w; 608 + }, 609 + (err: unknown) => { 610 + console.warn("[opake-sdk] watchDirectory registration failed:", err); 611 + }, 612 + ); 613 + 614 + return { 615 + close: () => { 616 + if (closed) return; 617 + closed = true; 618 + if (wasmWatcher) { 619 + void wasmWatcher.close(); 620 + wasmWatcher = null; 621 + } 622 + }, 623 + }; 473 624 } 474 625 475 626 // ---------------------------------------------------------------------------
+4 -15
packages/opake-sdk/src/index.ts
··· 2 2 3 3 // Main entry point 4 4 export { Opake } from "./opake"; 5 - export { FileManager } from "./file-manager"; 5 + export { FileManager, type DirectoryWatcher } from "./file-manager"; 6 6 7 7 // Errors 8 8 export { OpakeError, type OpakeErrorKind } from "./errors"; ··· 55 55 type TaskDef, 56 56 } from "./types"; 57 57 58 - // Real-time event streaming 59 - export { 60 - EventStream, 61 - type EventStreamHandlers, 62 - type EventStreamOptions, 63 - type SSEDirectory, 64 - type SSEDocument, 65 - type SSEKeyring, 66 - type SSEGrant, 67 - type SSEDelete, 68 - type SSEDirectoryUpdate, 69 - type SSEKeyringUpdate, 70 - type SSEDocumentUpdate, 71 - } from "./event-stream"; 58 + // Real-time event streaming is now WASM-owned. Subscribe via 59 + // `opake.startSseConsumer(appviewUrl)` + `fileManager.watchDirectory(uri, handler)` 60 + // which returns a `DirectoryWatcher` handle (exported above).
+169 -135
packages/opake-sdk/src/opake.ts
··· 33 33 import type { LoginOptions, StartLoginOptions, PendingLogin } from "./auth"; 34 34 import { createStorageAdapter } from "./storage-adapter"; 35 35 import { registerCleanup, unregisterCleanup } from "./finalizer"; 36 - import { EventStream, type EventStreamHandlers } from "./event-stream"; 37 36 import { 38 37 createPairRequest as pairingCreate, 39 38 listPairRequests as pairingList, ··· 63 62 * refresh (concurrent callers share the same promise). Eliminates reactive 64 63 * 401 retries and makes concurrent dispatch safe. 65 64 */ 66 - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TC39 decorator type erasure 67 65 function withTokenGuard(_target: any, _context: ClassMethodDecoratorContext) { 68 66 return async function (this: Opake, ...args: any[]): Promise<any> { 69 67 await this.ensureValidToken(); 70 68 return _target.call(this, ...args); 71 69 }; 72 70 } 73 - 74 71 75 72 // --------------------------------------------------------------------------- 76 73 // Opake class ··· 174 171 did: string, 175 172 ): Promise<import("./storage").Identity> { 176 173 const wasm = await initWasm(); 177 - return wasm.deriveIdentityFromMnemonic( 178 - seedPhrase, 179 - did, 180 - ) as import("./storage").Identity; 174 + return wasm.deriveIdentityFromMnemonic(seedPhrase, did) as import("./storage").Identity; 181 175 } 182 176 183 177 /** Generate a fresh random encryption identity. */ 184 - static async generateIdentity( 185 - did: string, 186 - ): Promise<import("./storage").Identity> { 178 + static async generateIdentity(did: string): Promise<import("./storage").Identity> { 187 179 const wasm = await initWasm(); 188 180 return wasm.generateIdentity(did) as import("./storage").Identity; 189 181 } ··· 291 283 ): Promise<{ authUrl: string; pending: PendingLogin }> { 292 284 const wasm = await initWasm(); 293 285 const adapter = createStorageAdapter(options.storage); 294 - const result = await wasm.startOAuthLogin( 295 - handle, 296 - options.redirectUri, 297 - adapter, 298 - ); 286 + const result = await wasm.startOAuthLogin(handle, options.redirectUri, adapter); 299 287 return result as { authUrl: string; pending: PendingLogin }; 300 288 } 301 289 ··· 313 301 ): Promise<void> { 314 302 const wasm = await initWasm(); 315 303 const adapter = createStorageAdapter(options.storage); 316 - await wasm.completeOAuthLogin( 317 - code, 318 - state, 319 - pending, 320 - options.redirectUri, 321 - adapter, 322 - ); 304 + await wasm.completeOAuthLogin(code, state, pending, options.redirectUri, adapter); 323 305 } 324 306 325 307 /** ··· 403 385 } 404 386 405 387 // --------------------------------------------------------------------------- 406 - // Identity 407 - // --------------------------------------------------------------------------- 408 - 409 - /** The authenticated DID, or null if the context is busy/unavailable. */ 410 - getDid(): string | null { 411 - try { 412 - return this.requireContext().getDid(); 413 - } catch { 414 - return null; 415 - } 416 - } 417 - 418 - // --------------------------------------------------------------------------- 419 - // Write tracking (for self-event filtering in SSE consumers) 420 - // --------------------------------------------------------------------------- 421 - 422 - /** 423 - * Timestamp of the most recent local write. SSE consumers read this to 424 - * suppress echo events — if an SSE event from our own DID arrives within 425 - * the suppression window, it's assumed to be our own write echoing back. 426 - * 427 - * Callers should invoke `markWrite()` BEFORE starting a mutation so the 428 - * window opens at the earliest point the echo can arrive. 429 - */ 430 - lastWriteAt = 0; 431 - 432 - markWrite(): void { 433 - this.lastWriteAt = Date.now(); 434 - } 435 - 436 - // --------------------------------------------------------------------------- 437 388 // Token lifecycle (called by @withTokenGuard decorator) 438 389 // --------------------------------------------------------------------------- 439 390 ··· 519 470 * ws.dispose(); 520 471 * ``` 521 472 */ 522 - @wrapWasmErrors @withTokenGuard 473 + @wrapWasmErrors 474 + @withTokenGuard 523 475 async workspace(keyringUri: string): Promise<FileManager> { 524 476 return new FileManager(await this.requireContext().workspaceByUri(keyringUri)); 525 477 } ··· 535 487 @wrapWasmErrors 536 488 async workspaceFromKey(workspace: ResolvedWorkspace): Promise<FileManager> { 537 489 const ctx = this.requireContext(); 538 - return new FileManager(await ctx.workspace(workspace.keyringUri, workspace.ownerDid, workspace.key, BigInt(workspace.rotation))); 490 + return new FileManager( 491 + await ctx.workspace( 492 + workspace.keyringUri, 493 + workspace.ownerDid, 494 + workspace.key, 495 + BigInt(workspace.rotation), 496 + ), 497 + ); 539 498 } 540 499 541 500 // --------------------------------------------------------------------------- ··· 552 511 * const { keyringUri, key } = await opake.createWorkspace("family-photos"); 553 512 * ``` 554 513 */ 555 - @wrapWasmErrors @withTokenGuard 556 - createWorkspace(name: string, description?: string): Promise<{ keyringUri: string; key: Uint8Array }> { 557 - return this.requireContext().createWorkspace(name, description ?? null).then(createWorkspaceResultSchema.parse); 514 + @wrapWasmErrors 515 + @withTokenGuard 516 + createWorkspace( 517 + name: string, 518 + description?: string, 519 + ): Promise<{ keyringUri: string; key: Uint8Array }> { 520 + return this.requireContext() 521 + .createWorkspace(name, description ?? null) 522 + .then(createWorkspaceResultSchema.parse); 558 523 } 559 524 560 525 /** ··· 565 530 * 566 531 * @returns Array of workspace entries with decrypted names and roles. 567 532 */ 568 - @wrapWasmErrors @withTokenGuard 533 + @wrapWasmErrors 534 + @withTokenGuard 569 535 listWorkspaces(appviewUrl?: string): Promise<readonly WorkspaceEntry[]> { 570 - return this.requireContext().listWorkspaces(appviewUrl ?? null).then(listWorkspacesResultSchema.parse); 536 + return this.requireContext() 537 + .listWorkspaces(appviewUrl ?? null) 538 + .then(listWorkspacesResultSchema.parse); 571 539 } 572 540 573 541 /** ··· 576 544 * @param keyringUri - Workspace keyring URI. 577 545 * @returns Array of keyring member records with DIDs and roles. 578 546 */ 579 - @wrapWasmErrors @withTokenGuard 547 + @wrapWasmErrors 548 + @withTokenGuard 580 549 listWorkspaceMembers(keyringUri: string): Promise<readonly WorkspaceMember[]> { 581 - return this.requireContext().listWorkspaceMembers(keyringUri) as Promise<readonly WorkspaceMember[]>; 550 + return this.requireContext().listWorkspaceMembers(keyringUri) as Promise< 551 + readonly WorkspaceMember[] 552 + >; 582 553 } 583 554 584 555 /** 585 556 * Add a member to a workspace. 586 557 */ 587 - @wrapWasmErrors @withTokenGuard 558 + @wrapWasmErrors 559 + @withTokenGuard 588 560 addWorkspaceMember( 589 - keyringUri: string, key: Uint8Array, memberDid: string, memberPublicKey: Uint8Array, role: WorkspaceRole, 561 + keyringUri: string, 562 + key: Uint8Array, 563 + memberDid: string, 564 + memberPublicKey: Uint8Array, 565 + role: WorkspaceRole, 590 566 ): Promise<MutationResult> { 591 - return this.requireContext().addWorkspaceMember(keyringUri, key, memberDid, memberPublicKey, role) as Promise<MutationResult>; 567 + return this.requireContext().addWorkspaceMember( 568 + keyringUri, 569 + key, 570 + memberDid, 571 + memberPublicKey, 572 + role, 573 + ) as Promise<MutationResult>; 592 574 } 593 575 594 576 /** ··· 597 579 * For owners: rotates the group key and returns the new key + rotation. 598 580 * For non-owners: creates a proposal. 599 581 */ 600 - @wrapWasmErrors @withTokenGuard 582 + @wrapWasmErrors 583 + @withTokenGuard 601 584 removeWorkspaceMember( 602 - keyringUri: string, key: Uint8Array, memberDid: string, 585 + keyringUri: string, 586 + key: Uint8Array, 587 + memberDid: string, 603 588 ): Promise<{ key?: Uint8Array; rotation?: number; proposed: boolean }> { 604 - return this.requireContext().removeWorkspaceMember(keyringUri, key, memberDid) as Promise<{ key?: Uint8Array; rotation?: number; proposed: boolean }>; 589 + return this.requireContext().removeWorkspaceMember(keyringUri, key, memberDid) as Promise<{ 590 + key?: Uint8Array; 591 + rotation?: number; 592 + proposed: boolean; 593 + }>; 605 594 } 606 595 607 596 /** Leave a workspace you're a member of. */ 608 - @wrapWasmErrors @withTokenGuard 597 + @wrapWasmErrors 598 + @withTokenGuard 609 599 leaveWorkspace(keyringUri: string): Promise<string> { 610 600 return this.requireContext().leaveWorkspace(keyringUri); 611 601 } 612 602 613 603 /** Update workspace metadata (name, description, icon). */ 614 - @wrapWasmErrors @withTokenGuard 604 + @wrapWasmErrors 605 + @withTokenGuard 615 606 updateWorkspaceMetadata( 616 - keyringUri: string, key: Uint8Array, updates: { name?: string; description?: string; icon?: string }, 607 + keyringUri: string, 608 + key: Uint8Array, 609 + updates: { name?: string; description?: string; icon?: string }, 617 610 ): Promise<MutationResult> { 618 611 return this.requireContext().updateWorkspaceMetadata( 619 - keyringUri, key, updates.name ?? null, updates.description ?? null, updates.icon ?? null, 612 + keyringUri, 613 + key, 614 + updates.name ?? null, 615 + updates.description ?? null, 616 + updates.icon ?? null, 620 617 ) as Promise<MutationResult>; 621 618 } 622 619 623 620 /** Update a workspace member's role. */ 624 - @wrapWasmErrors @withTokenGuard 625 - updateMemberRole(keyringUri: string, memberDid: string, role: WorkspaceRole): Promise<MutationResult> { 626 - return this.requireContext().updateMemberRole(keyringUri, memberDid, role) as Promise<MutationResult>; 621 + @wrapWasmErrors 622 + @withTokenGuard 623 + updateMemberRole( 624 + keyringUri: string, 625 + memberDid: string, 626 + role: WorkspaceRole, 627 + ): Promise<MutationResult> { 628 + return this.requireContext().updateMemberRole( 629 + keyringUri, 630 + memberDid, 631 + role, 632 + ) as Promise<MutationResult>; 627 633 } 628 634 629 635 // --------------------------------------------------------------------------- ··· 635 641 * 636 642 * @param handleOrDid - AT Protocol handle or DID. 637 643 */ 638 - @wrapWasmErrors @withTokenGuard 644 + @wrapWasmErrors 645 + @withTokenGuard 639 646 resolveIdentity(handleOrDid: string): Promise<ResolvedIdentity> { 640 647 return this.requireContext().resolveIdentity(handleOrDid).then(resolvedIdentitySchema.parse); 641 648 } ··· 646 653 * Required before other users can encrypt files for you or add you 647 654 * to workspaces. 648 655 */ 649 - @wrapWasmErrors @withTokenGuard 656 + @wrapWasmErrors 657 + @withTokenGuard 650 658 publishPublicKey(): Promise<string> { 651 659 return this.requireContext().publishPublicKey(); 652 660 } 653 661 654 662 // --------------------------------------------------------------------------- 655 - // Real-time event streaming (SSE) 663 + // Real-time event streaming (WASM-owned SSE consumer) 656 664 // --------------------------------------------------------------------------- 657 665 658 666 /** 659 - * Request a short-lived SSE token from the appview. 667 + * Start the WASM-level SSE consumer. 660 668 * 661 - * The token authenticates the EventSource connection (which cannot 662 - * carry custom headers). Valid for ~60 seconds, single-use. 669 + * Spawns a background task inside WASM that connects to the appview's 670 + * `/api/events` endpoint, pulls events, and applies them to any 671 + * installed directory trees via the Rust-side `TreeKeeper`. Once 672 + * started, `FileManager.watchDirectory` handlers fire automatically 673 + * as events arrive. 674 + * 675 + * Events are parsed and applied entirely in Rust — only serialized 676 + * snapshots cross into JS. Idempotent: safe to call multiple times 677 + * (StrictMode double-mount is handled internally). 678 + * 679 + * `appviewUrl` is optional: if omitted, the URL is resolved from the 680 + * Opake instance's stored config (loaded during `init`). Pass an 681 + * explicit value as a fallback for instances without stored config. 663 682 */ 664 683 @wrapWasmErrors 665 - requestSseToken(appviewUrl?: string): Promise<string> { 666 - return this.requireContext().requestSseToken(appviewUrl ?? null); 684 + startSseConsumer(appviewUrl?: string): Promise<void> { 685 + return this.requireContext().startSseConsumer(appviewUrl ?? null); 667 686 } 668 687 669 688 /** 670 - * Subscribe to real-time events from the appview via SSE. 671 - * 672 - * Opens a persistent connection that receives full indexed records 673 - * as they're processed by the firehose indexer. Auto-reconnects with 674 - * exponential backoff; fires `onReconnect` so the consumer can 675 - * full-sync to cover the gap. 676 - * 677 - * @returns An `EventStream` — call `.close()` to disconnect. 678 - * 679 - * @example 680 - * ```typescript 681 - * const stream = opake.subscribe({ 682 - * onDirectoryUpsert: (dir) => console.log("changed:", dir.directory_uri), 683 - * onReconnect: () => store.fullSync(), 684 - * }); 685 - * // later: 686 - * stream.close(); 687 - * ``` 689 + * Stop the WASM SSE consumer. Clears the internal running flag so a 690 + * subsequent `startSseConsumer` call can spawn a fresh consumer. 688 691 */ 689 - subscribe( 690 - handlers: EventStreamHandlers, 691 - appviewUrl: string, 692 - ): EventStream { 693 - const stream = new EventStream({ 694 - appviewUrl, 695 - getToken: () => this.requestSseToken(appviewUrl), 696 - handlers, 697 - }); 698 - void stream.connect(); 699 - return stream; 692 + stopSseConsumer(): void { 693 + const ctx = this.ctx; 694 + if (ctx) ctx.stopSseConsumer(); 700 695 } 701 696 702 697 // --------------------------------------------------------------------------- ··· 704 699 // --------------------------------------------------------------------------- 705 700 706 701 /** Sync all owned workspaces — apply pending proposals from members. */ 707 - @wrapWasmErrors @withTokenGuard 702 + @wrapWasmErrors 703 + @withTokenGuard 708 704 syncOwnedWorkspaces(): Promise<number> { 709 705 return this.requireContext().syncOwnedWorkspaces(); 710 706 } 711 707 712 708 /** Sync with per-workspace result visibility (daemon use). */ 713 - @wrapWasmErrors @withTokenGuard 709 + @wrapWasmErrors 710 + @withTokenGuard 714 711 syncOwnedWorkspacesDetailed(): Promise<readonly WorkspaceSyncResult[]> { 715 712 return this.requireContext().syncOwnedWorkspacesDetailed().then(syncDetailedResultSchema.parse); 716 713 } 717 714 718 715 /** Sync a single workspace by keyring URI. Returns null if not a member. */ 719 - @wrapWasmErrors @withTokenGuard 716 + @wrapWasmErrors 717 + @withTokenGuard 720 718 syncWorkspaceByUri(keyringUri: string): Promise<WorkspaceSyncResult | null> { 721 719 return this.requireContext().syncWorkspaceByUri(keyringUri).then(syncSingleResultSchema.parse); 722 720 } 723 721 724 722 /** Retry pending shares — resolve recipients and create grants. */ 725 - @wrapWasmErrors @withTokenGuard 726 - retryPendingShares(): Promise<{ checked: number; completed: number; expired: number; still_pending: number; failed: number }> { 723 + @wrapWasmErrors 724 + @withTokenGuard 725 + retryPendingShares(): Promise<{ 726 + checked: number; 727 + completed: number; 728 + expired: number; 729 + still_pending: number; 730 + failed: number; 731 + }> { 727 732 return this.requireContext().retryPendingSharesViaOpake(); 728 733 } 729 734 ··· 732 737 // --------------------------------------------------------------------------- 733 738 734 739 /** Create a pair request (new device). Returns the record URI + ephemeral keypair. */ 735 - @wrapWasmErrors @withTokenGuard 740 + @wrapWasmErrors 741 + @withTokenGuard 736 742 createPairRequest(): Promise<import("./types").PairRequestResult> { 737 743 return pairingCreate(this.requireContext()); 738 744 } 739 745 740 746 /** List pending pair requests on this account. */ 741 - @wrapWasmErrors @withTokenGuard 747 + @wrapWasmErrors 748 + @withTokenGuard 742 749 listPairRequests(): Promise<readonly import("./types").PendingPairRequest[]> { 743 750 return pairingList(this.requireContext()); 744 751 } 745 752 746 753 /** List pair responses on this account. */ 747 - @wrapWasmErrors @withTokenGuard 748 - listPairResponses(): Promise<readonly { uri: string; requestUri: string; value: import("./types").PairResponseRecord }[]> { 754 + @wrapWasmErrors 755 + @withTokenGuard 756 + listPairResponses(): Promise< 757 + readonly { uri: string; requestUri: string; value: import("./types").PairResponseRecord }[] 758 + > { 749 759 return pairingListResponses(this.requireContext()); 750 760 } 751 761 752 762 /** Approve a pair request (existing device). Encrypts and sends the identity. */ 753 - @wrapWasmErrors @withTokenGuard 763 + @wrapWasmErrors 764 + @withTokenGuard 754 765 approvePairRequest(requestUri: string, ephemeralPublicKey: Uint8Array): Promise<void> { 755 766 return pairingApprove(this.requireContext(), requestUri, ephemeralPublicKey); 756 767 } 757 768 758 769 /** Receive a pair response (new device). Decrypts the identity from the approving device. */ 759 - @wrapWasmErrors @withTokenGuard 760 - receivePairResponse(response: import("./types").PairResponseRecord, ephemeralPrivateKey: Uint8Array): Promise<import("./storage").Identity> { 770 + @wrapWasmErrors 771 + @withTokenGuard 772 + receivePairResponse( 773 + response: import("./types").PairResponseRecord, 774 + ephemeralPrivateKey: Uint8Array, 775 + ): Promise<import("./storage").Identity> { 761 776 return pairingReceive(this.requireContext(), response, ephemeralPrivateKey); 762 777 } 763 778 764 779 /** Clean up pair request + response records after successful pairing. */ 765 - @wrapWasmErrors @withTokenGuard 780 + @wrapWasmErrors 781 + @withTokenGuard 766 782 cleanupPairRecords(requestRkey: string, responseRkey: string): Promise<void> { 767 783 return pairingCleanup(this.requireContext(), requestRkey, responseRkey); 768 784 } 769 785 770 786 /** Delete expired pair requests and orphaned responses. */ 771 - @wrapWasmErrors @withTokenGuard 787 + @wrapWasmErrors 788 + @withTokenGuard 772 789 cleanupExpiredPairRequests(): Promise<number> { 773 790 return pairingCleanupExpired(this.requireContext()); 774 791 } 775 792 776 793 /** Delete stale grants whose recipients have no valid public key. */ 777 - @wrapWasmErrors @withTokenGuard 794 + @wrapWasmErrors 795 + @withTokenGuard 778 796 healStaleGrants(): Promise<number> { 779 797 return this.requireContext().healStaleGrants(); 780 798 } ··· 784 802 // --------------------------------------------------------------------------- 785 803 786 804 /** Create a workspace invitation. Returns `{ uri, token }`. */ 787 - @wrapWasmErrors @withTokenGuard 805 + @wrapWasmErrors 806 + @withTokenGuard 788 807 createInvitation(keyringUri: string, role: string): Promise<{ uri: string; token: string }> { 789 - return this.requireContext().createInvitation(keyringUri, role) as Promise<{ uri: string; token: string }>; 808 + return this.requireContext().createInvitation(keyringUri, role) as Promise<{ 809 + uri: string; 810 + token: string; 811 + }>; 790 812 } 791 813 792 814 /** List all invitations on this account. */ 793 - @wrapWasmErrors @withTokenGuard 815 + @wrapWasmErrors 816 + @withTokenGuard 794 817 async listInvitations(): Promise<readonly import("./types").InvitationEntry[]> { 795 - const raw = await this.requireContext().listInvitations() as readonly Record<string, unknown>[]; 818 + const raw = (await this.requireContext().listInvitations()) as readonly Record< 819 + string, 820 + unknown 821 + >[]; 796 822 return raw.map((r) => ({ 797 823 uri: r.uri as string, 798 824 target: r.target as string, ··· 807 833 } 808 834 809 835 /** Revoke (delete) an invitation. */ 810 - @wrapWasmErrors @withTokenGuard 836 + @wrapWasmErrors 837 + @withTokenGuard 811 838 revokeInvitation(invitationUri: string): Promise<void> { 812 839 return this.requireContext().revokeInvitation(invitationUri); 813 840 } ··· 824 851 */ 825 852 static async taskDefs(): Promise<readonly import("./types").TaskDef[]> { 826 853 const wasm = await initWasm(); 827 - const raw = wasm.daemonTaskDefs() as readonly { name: string; interval_seconds: number; description: string }[]; 854 + const raw = wasm.daemonTaskDefs() as readonly { 855 + name: string; 856 + interval_seconds: number; 857 + description: string; 858 + }[]; 828 859 return raw 829 860 .filter((t) => t.name !== "session-refresh") 830 - .map((t) => ({ name: t.name, intervalSeconds: t.interval_seconds, description: t.description })); 861 + .map((t) => ({ 862 + name: t.name, 863 + intervalSeconds: t.interval_seconds, 864 + description: t.description, 865 + })); 831 866 } 832 867 833 868 // --------------------------------------------------------------------------- ··· 854 889 } 855 890 return this.ctx; 856 891 } 857 - 858 892 }
+41 -19
packages/opake-sdk/src/pairing.ts
··· 22 22 } 23 23 24 24 export function listPairRequests(ctx: Ctx): Promise<readonly PendingPairRequest[]> { 25 - return ctx.listPairRequests().then((entries: readonly { 26 - uri: string; 27 - value: { ephemeralKey: { $bytes: string }; createdAt: string }; 28 - }[]) => entries.map((e) => ({ 29 - uri: e.uri, 30 - ephemeralKey: base64ToBytes(e.value.ephemeralKey.$bytes), 31 - createdAt: e.value.createdAt, 32 - }))); 25 + return ctx.listPairRequests().then( 26 + ( 27 + entries: readonly { 28 + uri: string; 29 + value: { ephemeralKey: { $bytes: string }; createdAt: string }; 30 + }[], 31 + ) => 32 + entries.map((e) => ({ 33 + uri: e.uri, 34 + ephemeralKey: base64ToBytes(e.value.ephemeralKey.$bytes), 35 + createdAt: e.value.createdAt, 36 + })), 37 + ); 33 38 } 34 39 35 40 export function listPairResponses( 36 41 ctx: Ctx, 37 42 ): Promise<readonly { uri: string; requestUri: string; value: PairResponseRecord }[]> { 38 - return ctx.listPairResponses().then((entries: readonly { 39 - uri: string; 40 - value: { request: string; [key: string]: unknown }; 41 - }[]) => entries.map((e) => ({ 42 - uri: e.uri, 43 - requestUri: e.value.request, 44 - value: e.value as PairResponseRecord, 45 - }))); 43 + return ctx.listPairResponses().then( 44 + ( 45 + entries: readonly { 46 + uri: string; 47 + value: { request: string; [key: string]: unknown }; 48 + }[], 49 + ) => 50 + entries.map((e) => ({ 51 + uri: e.uri, 52 + requestUri: e.value.request, 53 + value: e.value as PairResponseRecord, 54 + })), 55 + ); 46 56 } 47 57 48 - export function approvePairRequest(ctx: Ctx, requestUri: string, ephemeralPublicKey: Uint8Array): Promise<void> { 58 + export function approvePairRequest( 59 + ctx: Ctx, 60 + requestUri: string, 61 + ephemeralPublicKey: Uint8Array, 62 + ): Promise<void> { 49 63 return ctx.approvePairRequest(requestUri, ephemeralPublicKey); 50 64 } 51 65 52 - export function receivePairResponse(ctx: Ctx, response: PairResponseRecord, ephemeralPrivateKey: Uint8Array): Promise<Identity> { 66 + export function receivePairResponse( 67 + ctx: Ctx, 68 + response: PairResponseRecord, 69 + ephemeralPrivateKey: Uint8Array, 70 + ): Promise<Identity> { 53 71 return ctx.receivePairResponse(response, ephemeralPrivateKey) as Promise<Identity>; 54 72 } 55 73 56 - export function cleanupPairRecords(ctx: Ctx, requestRkey: string, responseRkey: string): Promise<void> { 74 + export function cleanupPairRecords( 75 + ctx: Ctx, 76 + requestRkey: string, 77 + responseRkey: string, 78 + ): Promise<void> { 57 79 return ctx.cleanupPairRecords(requestRkey, responseRkey); 58 80 } 59 81
+4 -5
packages/opake-sdk/src/schemas.ts
··· 185 185 export type DirectoryEntry = z.output<typeof typedEntrySchema>; 186 186 export type DirectoryInfo = z.output<typeof directoryInfoSchema>; 187 187 188 - export const treeWithMetadataSchema = z 189 - .object({ 190 - snapshot: directoryTreeSnapshotSchema, 191 - metadata: z.record(z.string(), documentMetadataSchema).optional().default({}), 192 - }); 188 + export const treeWithMetadataSchema = z.object({ 189 + snapshot: directoryTreeSnapshotSchema, 190 + metadata: z.record(z.string(), documentMetadataSchema).optional().default({}), 191 + }); 193 192 194 193 // --------------------------------------------------------------------------- 195 194 // Pairing
+10 -42
packages/opake-sdk/src/storage-adapter.ts
··· 4 4 // typed interface mirrors the Rust JsStorageAdapter extern — if WASM adds or 5 5 // renames a method, TS compilation will catch the mismatch here. 6 6 7 - import type { 8 - Storage, 9 - Config, 10 - Identity, 11 - Session, 12 - CachedRecord, 13 - CachedCollection, 14 - } from "./storage"; 7 + import type { Storage, Config, Identity, Session, CachedRecord, CachedCollection } from "./storage"; 15 8 16 9 /** 17 10 * Typed interface matching the Rust `JsStorageAdapter` extern in js_storage.rs. ··· 31 24 loadSession(did: string): Promise<Session>; 32 25 saveSession(did: string, session: Session): Promise<void>; 33 26 removeAccount(did: string): Promise<void>; 34 - cacheGetRecord( 35 - did: string, 36 - collection: string, 37 - uri: string, 38 - ): Promise<CachedRecord | null>; 39 - cachePutRecords( 40 - did: string, 41 - collection: string, 42 - records: readonly CachedRecord[], 43 - ): Promise<void>; 44 - cacheRemoveRecord( 45 - did: string, 46 - collection: string, 47 - uri: string, 48 - ): Promise<void>; 49 - cacheGetCollection( 50 - did: string, 51 - collection: string, 52 - ): Promise<CachedCollection | null>; 53 - cachePutCollection( 54 - did: string, 55 - collection: string, 56 - data: CachedCollection, 57 - ): Promise<void>; 58 - cacheInvalidateCollection( 59 - did: string, 60 - collection: string, 61 - ): Promise<void>; 27 + cacheGetRecord(did: string, collection: string, uri: string): Promise<CachedRecord | null>; 28 + cachePutRecords(did: string, collection: string, records: readonly CachedRecord[]): Promise<void>; 29 + cacheRemoveRecord(did: string, collection: string, uri: string): Promise<void>; 30 + cacheGetCollection(did: string, collection: string): Promise<CachedCollection | null>; 31 + cachePutCollection(did: string, collection: string, data: CachedCollection): Promise<void>; 32 + cacheInvalidateCollection(did: string, collection: string): Promise<void>; 62 33 cacheClear(did: string): Promise<void>; 63 34 } 64 35 ··· 74 45 loadSession: (did) => storage.loadSession(did), 75 46 saveSession: (did, session) => storage.saveSession(did, session), 76 47 removeAccount: (did) => storage.removeAccount(did), 77 - cacheGetRecord: (did, collection, uri) => 78 - storage.cacheGetRecord(did, collection, uri), 48 + cacheGetRecord: (did, collection, uri) => storage.cacheGetRecord(did, collection, uri), 79 49 cachePutRecords: (did, collection, records) => 80 50 storage.cachePutRecords(did, collection, records), 81 - cacheRemoveRecord: (did, collection, uri) => 82 - storage.cacheRemoveRecord(did, collection, uri), 83 - cacheGetCollection: (did, collection) => 84 - storage.cacheGetCollection(did, collection), 51 + cacheRemoveRecord: (did, collection, uri) => storage.cacheRemoveRecord(did, collection, uri), 52 + cacheGetCollection: (did, collection) => storage.cacheGetCollection(did, collection), 85 53 cachePutCollection: (did, collection, data) => 86 54 storage.cachePutCollection(did, collection, data), 87 55 cacheInvalidateCollection: (did, collection) =>
+21 -4
packages/opake-sdk/src/storage/indexeddb.ts
··· 143 143 144 144 // -- Cache: record-level -------------------------------------------------- 145 145 146 - async cacheGetRecord<T>(did: string, collection: string, uri: string): Promise<CachedRecord<T> | null> { 146 + async cacheGetRecord<T>( 147 + did: string, 148 + collection: string, 149 + uri: string, 150 + ): Promise<CachedRecord<T> | null> { 147 151 const row = await this.db.cacheRecords.get([did, collection, uri]); 148 152 if (!row) return null; 149 153 return { uri: row.uri, cid: row.cid, value: row.value as T }; ··· 170 174 171 175 // -- Cache: collection-level ---------------------------------------------- 172 176 173 - async cacheGetCollection<T>(did: string, collection: string): Promise<CachedCollection<T> | null> { 177 + async cacheGetCollection<T>( 178 + did: string, 179 + collection: string, 180 + ): Promise<CachedCollection<T> | null> { 174 181 const [meta, rows] = await Promise.all([ 175 182 this.db.cacheMeta.get([did, collection]), 176 183 this.db.cacheRecords.where("[did+collection]").equals([did, collection]).toArray(), ··· 180 187 return { records, fetched_at: meta.fetchedAt }; 181 188 } 182 189 183 - async cachePutCollection<T>(did: string, collection: string, data: CachedCollection<T>): Promise<void> { 190 + async cachePutCollection<T>( 191 + did: string, 192 + collection: string, 193 + data: CachedCollection<T>, 194 + ): Promise<void> { 184 195 await this.db.transaction("rw", [this.db.cacheRecords, this.db.cacheMeta], async () => { 185 196 await this.db.cacheRecords.where("[did+collection]").equals([did, collection]).delete(); 186 197 const rows = data.records.map((r) => ({ ··· 229 240 const key = sanitizeDid(did); 230 241 await this.db.transaction( 231 242 "rw", 232 - [this.db.configs, this.db.identities, this.db.sessions, this.db.cacheRecords, this.db.cacheMeta], 243 + [ 244 + this.db.configs, 245 + this.db.identities, 246 + this.db.sessions, 247 + this.db.cacheRecords, 248 + this.db.cacheMeta, 249 + ], 233 250 async () => { 234 251 await this.db.configs.put({ key: CONFIG_KEY, value: updatedConfig }); 235 252 await this.db.identities.delete(key);
+9 -2
packages/opake-sdk/src/storage/memory.ts
··· 98 98 return `${did}::${collection}`; 99 99 } 100 100 101 - async cacheGetRecord<T>(did: string, collection: string, uri: string): Promise<CachedRecord<T> | null> { 101 + async cacheGetRecord<T>( 102 + did: string, 103 + collection: string, 104 + uri: string, 105 + ): Promise<CachedRecord<T> | null> { 102 106 const records = this.cacheRecords.get(this.cacheKey(did, collection)); 103 107 return (records?.get(uri) as CachedRecord<T> | undefined) ?? null; 104 108 } ··· 125 129 126 130 // -- Cache: collection-level ----------------------------------------------- 127 131 128 - async cacheGetCollection<T>(did: string, collection: string): Promise<CachedCollection<T> | null> { 132 + async cacheGetCollection<T>( 133 + did: string, 134 + collection: string, 135 + ): Promise<CachedCollection<T> | null> { 129 136 const key = this.cacheKey(did, collection); 130 137 const fetchedAt = this.cacheMeta.get(key); 131 138 if (fetchedAt === undefined) return null;