Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Open the dev URL, paste your vault URL, connect.
- Neighborhood graph on each note (via the vault's `near` query)
- Full-vault graph at `/graph` with search and tag filters
- Theme matched to Parachute's visual language — system, light, or dark; toggle in the header
- Offline-capable mutations (plumbing) — create / update / delete / attachment actions issued offline are queued in IndexedDB (with OPFS for blobs when available) and drained when the vault comes back in reach. Conflicts are stashed for human resolution; auth errors halt the drain until you reconnect. UI for the queue ships in a later PR.

## Build from source

Expand Down
10 changes: 9 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@codemirror/view": "^6.41.1",
"@tanstack/react-query": "^5",
"highlight.js": "^11.11.1",
"idb": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-force-graph-2d": "^1.29.1",
Expand All @@ -48,6 +49,7 @@
"@types/react-dom": "^19.1.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-react": "^4.3.0",
"fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
Expand Down
65 changes: 34 additions & 31 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Header } from "@/components/Header";
import { Toaster } from "@/components/Toaster";
import { UpdateBanner } from "@/components/UpdateBanner";
import { QueryProvider } from "@/providers/QueryProvider";
import { SyncProvider } from "@/providers/SyncProvider";
import { BrowserRouter, Route, Routes } from "react-router";
import { AddVault } from "./routes/AddVault";
import { Home } from "./routes/Home";
Expand All @@ -17,37 +18,39 @@ import { Vaults } from "./routes/Vaults";
export function App() {
return (
<QueryProvider>
<BrowserRouter>
<div className="min-h-dvh bg-bg text-fg">
<Toaster />
<UpdateBanner />
<Header />
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/notes" element={<Notes />} />
<Route path="/tags" element={<Tags />} />
<Route path="/new" element={<NoteNew />} />
<Route path="/graph" element={<VaultGraph />} />
<Route path="/notes/:id" element={<NoteView />} />
<Route path="/notes/:id/edit" element={<NoteEditor />} />
<Route path="/add" element={<AddVault />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/vaults" element={<Vaults />} />
<Route path="*" element={<Home />} />
</Routes>
</main>
<footer className="mx-auto max-w-5xl px-6 py-10 text-center text-sm text-fg-dim">
<p>
Part of the{" "}
<a href="https://parachute.computer" className="text-accent hover:underline">
Parachute Computer
</a>{" "}
ecosystem. AGPL-3.0.
</p>
</footer>
</div>
</BrowserRouter>
<SyncProvider>
<BrowserRouter>
<div className="min-h-dvh bg-bg text-fg">
<Toaster />
<UpdateBanner />
<Header />
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/notes" element={<Notes />} />
<Route path="/tags" element={<Tags />} />
<Route path="/new" element={<NoteNew />} />
<Route path="/graph" element={<VaultGraph />} />
<Route path="/notes/:id" element={<NoteView />} />
<Route path="/notes/:id/edit" element={<NoteEditor />} />
<Route path="/add" element={<AddVault />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/vaults" element={<Vaults />} />
<Route path="*" element={<Home />} />
</Routes>
</main>
<footer className="mx-auto max-w-5xl px-6 py-10 text-center text-sm text-fg-dim">
<p>
Part of the{" "}
<a href="https://parachute.computer" className="text-accent hover:underline">
Parachute Computer
</a>{" "}
ecosystem. AGPL-3.0.
</p>
</footer>
</div>
</BrowserRouter>
</SyncProvider>
</QueryProvider>
);
}
53 changes: 53 additions & 0 deletions src/lib/sync/blob-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createIdbBlobStore, newBlobId } from "./blob-store";
import { type LensDB, openLensDB } from "./db";

async function freshDb(): Promise<LensDB> {
indexedDB.deleteDatabase("parachute-lens");
return openLensDB();
}

describe("IdbBlobStore (fallback path)", () => {
let db: LensDB;
beforeEach(async () => {
db = await freshDb();
});
afterEach(() => {
db.close();
});

it("reports the 'idb' backend", () => {
const store = createIdbBlobStore(db);
expect(store.backend).toBe("idb");
});

it("round-trips bytes + mime type", async () => {
const store = createIdbBlobStore(db);
const id = newBlobId();
const buffer = new Uint8Array([1, 2, 3, 4]).buffer;
await store.put(id, buffer, "audio/wav", "v1");
const read = await store.get(id);
expect(read).not.toBeNull();
expect(read!.mimeType).toBe("audio/wav");
expect(Array.from(new Uint8Array(read!.data))).toEqual([1, 2, 3, 4]);
});

it("returns null for a missing blob", async () => {
const store = createIdbBlobStore(db);
expect(await store.get("missing")).toBeNull();
});

it("delete removes the blob", async () => {
const store = createIdbBlobStore(db);
const id = newBlobId();
await store.put(id, new Uint8Array([1]).buffer, "application/octet-stream", "v1");
await store.delete(id);
expect(await store.get(id)).toBeNull();
});

it("newBlobId returns a fresh UUID each call", () => {
const a = newBlobId();
const b = newBlobId();
expect(a).not.toBe(b);
});
});
124 changes: 124 additions & 0 deletions src/lib/sync/blob-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { LensDB } from "./db";

// OPFS gives us a much larger quota (and streaming writes) than IndexedDB, but
// it's not universally supported yet. When unavailable we fall back to the
// `blobs` object store in IndexedDB.

const OPFS_DIR = "lens-blobs";

function hasOPFS(): boolean {
return (
typeof navigator !== "undefined" &&
typeof navigator.storage !== "undefined" &&
typeof navigator.storage.getDirectory === "function"
);
}

async function opfsDir(): Promise<FileSystemDirectoryHandle> {
const root = await navigator.storage.getDirectory();
return root.getDirectoryHandle(OPFS_DIR, { create: true });
}

export interface StoredBlob {
data: ArrayBuffer;
mimeType: string;
}

export interface BlobStore {
readonly backend: "opfs" | "idb";
put(blobId: string, data: ArrayBuffer, mimeType: string, vaultId: string): Promise<void>;
get(blobId: string): Promise<StoredBlob | null>;
delete(blobId: string): Promise<void>;
}

// Convenience helper for callers holding a Blob from e.g. MediaRecorder. Wraps
// the Response trick, which is the most portable way to read bytes across
// environments (real browsers, jsdom, node).
export async function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Response(blob).arrayBuffer();
}

export function newBlobId(): string {
return crypto.randomUUID();
}

class OpfsBlobStore implements BlobStore {
readonly backend = "opfs" as const;
async put(blobId: string, data: ArrayBuffer, mimeType: string): Promise<void> {
const dir = await opfsDir();
const handle = await dir.getFileHandle(blobId, { create: true });
// createSyncAccessHandle is faster but only available in workers; writable
// stream works on the main thread. We also need the mimeType later, so
// stash it in a sidecar file — OPFS has no metadata channel of its own.
const writable = await handle.createWritable();
await writable.write(data);
await writable.close();
const metaHandle = await dir.getFileHandle(`${blobId}.meta`, { create: true });
const metaWritable = await metaHandle.createWritable();
await metaWritable.write(mimeType);
await metaWritable.close();
}
async get(blobId: string): Promise<StoredBlob | null> {
try {
const dir = await opfsDir();
const handle = await dir.getFileHandle(blobId);
const file = await handle.getFile();
const buffer = await file.arrayBuffer();
let mimeType = "application/octet-stream";
try {
const metaHandle = await dir.getFileHandle(`${blobId}.meta`);
const metaFile = await metaHandle.getFile();
mimeType = (await metaFile.text()) || mimeType;
} catch {
// No sidecar — use the default.
}
return { data: buffer, mimeType };
} catch (e) {
if (e instanceof DOMException && e.name === "NotFoundError") return null;
throw e;
}
}
async delete(blobId: string): Promise<void> {
const dir = await opfsDir();
for (const name of [blobId, `${blobId}.meta`]) {
try {
await dir.removeEntry(name);
} catch (e) {
if (e instanceof DOMException && e.name === "NotFoundError") continue;
throw e;
}
}
}
}

class IdbBlobStore implements BlobStore {
readonly backend = "idb" as const;
constructor(private readonly db: LensDB) {}
async put(blobId: string, data: ArrayBuffer, mimeType: string, vaultId: string): Promise<void> {
await this.db.put("blobs", {
blobId,
data,
mimeType,
vaultId,
createdAt: Date.now(),
});
}
async get(blobId: string): Promise<StoredBlob | null> {
const row = await this.db.get("blobs", blobId);
if (!row) return null;
return { data: row.data, mimeType: row.mimeType };
}
async delete(blobId: string): Promise<void> {
await this.db.delete("blobs", blobId);
}
}

export function createBlobStore(db: LensDB): BlobStore {
return hasOPFS() ? new OpfsBlobStore() : new IdbBlobStore(db);
}

// Exported for tests — allows forcing the IDB fallback path even when OPFS is
// available (e.g., to exercise it in a browser that has OPFS).
export function createIdbBlobStore(db: LensDB): BlobStore {
return new IdbBlobStore(db);
}
51 changes: 51 additions & 0 deletions src/lib/sync/db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { type LensDB, deleteMeta, getMeta, openLensDB, setMeta } from "./db";

async function freshDb(): Promise<LensDB> {
indexedDB.deleteDatabase("parachute-lens");
return openLensDB();
}

describe("openLensDB", () => {
let db: LensDB;
beforeEach(async () => {
db = await freshDb();
});
afterEach(() => {
db.close();
});

it("creates all expected object stores on first open", () => {
const names = Array.from(db.objectStoreNames);
expect(names).toContain("pending");
expect(names).toContain("id_map");
expect(names).toContain("blob_path_map");
expect(names).toContain("blobs");
expect(names).toContain("meta");
});

it("creates pending with autoincrement + by-vault + by-status indexes", async () => {
const tx = db.transaction("pending", "readonly");
const store = tx.store;
expect(store.autoIncrement).toBe(true);
expect(store.keyPath).toBe("seq");
expect(Array.from(store.indexNames)).toEqual(expect.arrayContaining(["by-vault", "by-status"]));
});

it("round-trips meta values", async () => {
await setMeta(db, "schemaVersion", 1);
expect(await getMeta(db, "schemaVersion")).toBe(1);
await setMeta(db, "schemaVersion", 2);
expect(await getMeta(db, "schemaVersion")).toBe(2);
await deleteMeta(db, "schemaVersion");
expect(await getMeta(db, "schemaVersion")).toBeUndefined();
});

it("survives reopen — data persists across handles", async () => {
await setMeta(db, "hello", "world");
db.close();
const db2 = await openLensDB();
expect(await getMeta(db2, "hello")).toBe("world");
db2.close();
});
});
Loading