Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Persist Client-Side Search Indexes — Design

**Date:** 2026-08-01
**Status:** Approved
**Fixes:** chilli-axe/mpc-autofill#418 (discussion in #282, follows PR #358)

## Problem

Local-files and Google Drive client-side search indexes live only in memory.
Every page load, the user must re-pick their folder / re-run the Drive picker
and wait for a full re-index. For large libraries this takes long enough that
the feature is painful to use.

## Approach: persist documents + handles in IndexedDB, rebuild Orama on load

The expensive step is walking the folder / hitting the Drive API — not
building the Orama database (insertMultiple of a few thousand documents is
~100ms). So we persist the raw `OramaCardDocument` arrays and rebuild the
index on load.

`FileSystemFileHandle` / `FileSystemDirectoryHandle` objects are
structured-cloneable and can be stored in IndexedDB natively, so local-file
documents (whose `params` embed live file handles) persist as-is.

Rejected alternatives:
- `@orama/plugin-data-persistence` — serializes to JSON/binary, which would
destroy the embedded file handles.
- Persisting only the directory handle and silently re-indexing on load —
re-walking the folder every load is exactly the slowness complained about.

## Components

### `frontend/src/features/clientSearch/persistence.ts`

Small typed wrapper over raw IndexedDB (no new dependency): `save`, `load`,
`clear` for two entries plus a schema version:

- `localFiles: { directoryHandle, documents, indexedAt }`
- `googleDrive: { documents, indexedAt }`
- On schema-version mismatch or corrupt/unreadable data: treat as empty and
clear the store.

Saves run after each successful index build, fire-and-forget (quota or other
errors are logged, never surfaced as failures).

### Restore flow

On client search service init, load both entries and rebuild the Orama
indexes through the existing document-insertion path. Search works
immediately; no permission prompt. Restore failure of any kind degrades to
today's empty state.

### Permission re-grant (local files)

Reading image *files* (thumbnails, export) needs handle permission again.
After restore, if `queryPermission({mode: "read"}) !== "granted"`, the
source-config UI shows a banner ("Re-grant access to show images") whose
click calls `requestPermission()` — satisfying the browser's user-gesture
requirement. Search never blocks on this.

### Re-sync + staleness

The source-config UI shows restored state ("<folder name> — N cards, indexed
<relative time>") with a **Re-sync** button that re-walks the folder /
re-fetches from Drive (Drive re-sync triggers re-auth as today) and
overwrites the stored entry. No automatic background re-sync.

## Error handling

Every persistence/restore operation is best-effort: failures log to console
and fall back to the un-persisted behavior. A fresh session can never be
broken by this feature.

## Testing

- Jest with `fake-indexeddb`: persistence module round-trip, restore rebuilds
a searchable index, corrupt/old-version entries restore to empty and clear.
- Guard test: with nothing persisted, service behaves exactly as today.
- E2E cannot drive `showDirectoryPicker`, so local-folder flows are covered at
the unit level; the Drive restore path gets a Playwright test if the
existing MSW mocks support it, otherwise unit-level too.
44 changes: 36 additions & 8 deletions frontend/package-lock.json

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

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"fake-indexeddb": "^6.2.5",
"file-loader": "^6.2.0",
"jest": "^30.2.0",
"jest-fixed-jsdom": "^0.0.11",
Expand Down
29 changes: 28 additions & 1 deletion frontend/src/features/backend/LocalFolderBackendConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import Alert from "react-bootstrap/Alert";
import Button from "react-bootstrap/Button";
import Col from "react-bootstrap/Col";
Expand Down Expand Up @@ -26,6 +26,21 @@ export const LocalFolderBackendConfig = () => {
const { clientSearchService, forceUpdate } = useClientSearchContext();
const directoryHandle = useLocalFilesDirectoryHandle();
const directoryIndexSize = useLocalFilesDirectoryIndexSize();
// a restored directory handle (issue #418) may need its read permission re-granted by a user gesture
const [needsPermission, setNeedsPermission] = useState<boolean>(false);
useEffect(() => {
// @ts-ignore - queryPermission is not in the standard lib typings yet
directoryHandle?.queryPermission({ mode: "readwrite" }).then(
(permission: PermissionState) =>
setNeedsPermission(permission !== "granted"),
() => setNeedsPermission(false)
);
}, [directoryHandle]);
const reGrantPermission = async () => {
const permission: PermissionState = await (directoryHandle as any) // requestPermission is not in the standard lib typings yet
?.requestPermission({ mode: "readwrite" });
setNeedsPermission(permission !== "granted");
};
const getTagsQuery = useGetTagsQuery();

const [validationStatus, setValidationStatus] = useState<
Expand Down Expand Up @@ -102,6 +117,18 @@ export const LocalFolderBackendConfig = () => {
<Alert variant="success">
You&apos;re connected to <b>{directoryHandle.name}</b>, with{" "}
<b>{directoryIndexSize ?? 0}</b> images indexed.
{needsPermission && (
<Row className="gx-1 pt-2">
<Col>
<div className="d-grid gap-0">
<Button variant="warning" onClick={reGrantPermission}>
<RightPaddedIcon bootstrapIconName="unlock" />
Re-grant folder access to display images
</Button>
</div>
</Col>
</Row>
)}
<Row className="gx-1 pt-2">
<Col xs={6}>
<div className="d-grid gap-0">
Expand Down
Loading