Files
3d2592a9a2 MUL-7754: feat: local search index for web and desktop (#8891)
* feat(server): add a change log and sync endpoints for local search (MUL-7754)

Web and Desktop will keep a per-workspace copy of issue, comment, and
project text for instant search. Triggers on issue/comment/project record
the xid of each entity's latest write in search_index_change, covering
every write path including hard and cascaded deletes. Clients bootstrap
with GET /api/search-index/manifest + /snapshot pages and catch up with
POST /api/search-index/changes, whose cursor is a pg_snapshot: a change is
returned once it is visible in the target snapshot but not in the one the
client holds, so late commits are never skipped and long transactions do
not stall catch-up.

Workspace teardown skips the triggers and clears the table, and an hourly
scheduler job prunes rows older than 30 days; a cursor older than the
prune mark gets 410 and rebuilds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(core): add a synced local search index for web and desktop (MUL-7754)

Each (user, workspace) gets an IndexedDB copy of its issue, comment, and
project text, kept in a worker (SharedWorker when available, otherwise a
dedicated worker per tab) and searched in memory. Matching, ranking, and
snippets port the server's issue/project search, so local rows have the
server's shape and order.

The worker bootstraps from /api/search-index/manifest + snapshot pages,
catches up through /changes after realtime events, reconnects, focus, and
every five minutes, and resumes an interrupted bootstrap. It has no
credentials of its own: tabs run its requests through their API client.
Local results are served only while the copy is complete and recently
confirmed; otherwise searchIssues/searchProjects fall back to the server.
Workspaces over 500 MB of text, lost access, and expired cursors are
handled by declining, wiping, and rebuilding respectively. Session cleanup
deletes every local index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* feat(views): search through the local index first (MUL-7754)

The command palette, @mention suggestions, the issue picker, and duplicate
suggestions now call the core local-first search. The palette drops its
300 ms debounce while the local index is serving, since no request leaves
the tab.

An E2E spec checks that palette results match server ranking without any
server search request, follow issues created and deleted through the API,
and that logging out deletes the local copy. .env.example documents the
FF_LOCAL_SEARCH_INDEX kill switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* fix(core): close local search gaps found in review (MUL-7754)

- Access loss: deleting, leaving, or being removed from a workspace now
  destroys its local copies right away (worker and tab both delete the
  database, since a full-page relocate may end the worker first). The sync
  hook also prunes every copy outside the user's current workspace list,
  which covers access lost while offline or on another device; copies a
  tab is showing are kept in case the list is stale.
- Memory budget: the engine counts UTF-8 bytes the way the manifest does,
  and the budget is checked on every load, snapshot page, and catch-up
  batch, not only at bootstrap. An over-budget copy is dropped and search
  falls back to the server until the workspace is measured again.
- Frozen tabs: a port the sweep dropped is restored with its workspace the
  next time the tab sends anything, a page restored from the bfcache
  attaches again, and a null local answer clears the tab's "serving" flag
  so the palette goes back to debounced server search.

E2E now also covers being removed from a shared workspace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-27 22:02:13 +08:00

115 lines
4.9 KiB
SQL

-- Local search index sync (MUL-7754). Web and Desktop copy the issue, comment,
-- and project text of a workspace, then catch up through search_index_change.
-- Snapshots are exchanged as pg_snapshot text; see migration 561.
-- name: GetSearchIndexManifest :one
-- Taken before any snapshot page, so every write the pages might miss is newer
-- than this snapshot and reaches the client through the change log. The byte
-- totals let the client reject a workspace too large for its memory budget
-- before downloading it; octet_length reads the stored length, so TOASTed
-- bodies are not decompressed.
WITH ws AS (SELECT @workspace_id::uuid AS id)
SELECT
pg_current_snapshot()::text AS snapshot,
(SELECT count(*) FROM issue i, ws WHERE i.workspace_id = ws.id)::bigint AS issue_count,
(SELECT COALESCE(sum(octet_length(i.title) + COALESCE(octet_length(i.description), 0)), 0)
FROM issue i, ws WHERE i.workspace_id = ws.id)::bigint AS issue_bytes,
(SELECT count(*) FROM comment c, ws
WHERE c.workspace_id = ws.id AND c.deleted_at IS NULL)::bigint AS comment_count,
(SELECT COALESCE(sum(octet_length(c.content)), 0) FROM comment c, ws
WHERE c.workspace_id = ws.id AND c.deleted_at IS NULL)::bigint AS comment_bytes,
(SELECT count(*) FROM project p, ws WHERE p.workspace_id = ws.id)::bigint AS project_count,
(SELECT COALESCE(sum(octet_length(p.title) + COALESCE(octet_length(p.description), 0)), 0)
FROM project p, ws WHERE p.workspace_id = ws.id)::bigint AS project_bytes;
-- name: GetSearchIndexCurrentSnapshot :one
SELECT pg_current_snapshot()::text AS snapshot;
-- name: ListSearchIndexIssuesPage :many
-- Pages by the unique (workspace_id, number) index.
SELECT * FROM issue
WHERE workspace_id = @workspace_id AND number > @after_number
ORDER BY number
LIMIT @page_limit;
-- name: ListSearchIndexIssuesByIDs :many
SELECT * FROM issue
WHERE workspace_id = @workspace_id AND id = ANY(@ids::uuid[]);
-- name: ListSearchIndexCommentsByIssues :many
SELECT id, issue_id, content, created_at
FROM comment
WHERE workspace_id = @workspace_id
AND issue_id = ANY(@issue_ids::uuid[])
AND deleted_at IS NULL;
-- name: ListSearchIndexCommentsByIDs :many
-- Tombstoned comments are returned so the caller can report them as deleted.
SELECT id, issue_id, content, created_at, deleted_at
FROM comment
WHERE workspace_id = @workspace_id AND id = ANY(@ids::uuid[]);
-- name: ListSearchIndexProjects :many
SELECT * FROM project
WHERE workspace_id = @workspace_id
ORDER BY id;
-- name: ListSearchIndexProjectsByIDs :many
SELECT * FROM project
WHERE workspace_id = @workspace_id AND id = ANY(@ids::uuid[]);
-- name: SearchIndexSinceIsPruned :one
-- True when retention pruning may have removed a change the client has not
-- seen yet: a pruned xid at or above the snapshot's xmin could be one it has
-- not consumed.
SELECT EXISTS (
SELECT 1 FROM search_index_prune_mark
WHERE singleton
AND pruned_through_xid >= pg_snapshot_xmin((@since_snapshot::text)::pg_snapshot)
) AS pruned;
-- name: ListSearchIndexChanges :many
-- Changes committed in the target snapshot but not in the since snapshot, in
-- keyset order. An entity rewritten after the target snapshot carries a newer
-- xid, drops out of this range, and is returned by the next catch-up instead.
SELECT entity_type, entity_id, change_xid::text AS change_xid
FROM search_index_change
WHERE workspace_id = @workspace_id
AND change_xid >= pg_snapshot_xmin((@since_snapshot::text)::pg_snapshot)
AND change_xid < pg_snapshot_xmax((@target_snapshot::text)::pg_snapshot)
AND NOT pg_visible_in_snapshot(change_xid, (@since_snapshot::text)::pg_snapshot)
AND pg_visible_in_snapshot(change_xid, (@target_snapshot::text)::pg_snapshot)
AND (change_xid, entity_type, entity_id)
> ((@after_xid::text)::xid8, @after_type::text, @after_id::uuid)
ORDER BY change_xid, entity_type, entity_id
LIMIT @page_limit;
-- name: PruneSearchIndexChanges :one
-- Deletes one bounded batch of rows older than the cutoff and raises the prune
-- mark to the highest xid removed, in the same statement.
WITH doomed AS (
SELECT sic.entity_type, sic.entity_id
FROM search_index_change sic
WHERE sic.changed_at < @cutoff
ORDER BY sic.changed_at
LIMIT @batch_size
FOR UPDATE SKIP LOCKED
), deleted AS (
DELETE FROM search_index_change c
USING doomed d
WHERE c.entity_type = d.entity_type
AND c.entity_id = d.entity_id
AND c.changed_at < @cutoff
RETURNING c.change_xid
), marked AS (
INSERT INTO search_index_prune_mark (singleton, pruned_through_xid)
SELECT TRUE, deleted.change_xid FROM deleted ORDER BY deleted.change_xid DESC LIMIT 1
ON CONFLICT (singleton) DO UPDATE
SET pruned_through_xid = GREATEST(search_index_prune_mark.pruned_through_xid, EXCLUDED.pruned_through_xid)
RETURNING 1
)
SELECT count(*)::bigint AS deleted_count FROM deleted;
-- name: DeleteWorkspaceSearchIndexChanges :exec
DELETE FROM search_index_change WHERE workspace_id = $1;