{"protocol":"clank-doc/1","frameworkVersion":"0.24.0","slug":"search-browsing","title":"Facets, paging and saved searches","description":"Enable browsing on a source linked search service when a screen needs complete authorized facet counts, stable pages and account owned saved definitions. The existing manual search service, SearchClient.search() and mountSearch() remain ava","group":{"id":"full-stack","title":"Full stack"},"url":"https://docs.clank.run/docs/search-browsing","source":"docs/search-browsing.md","headings":["Save and edit definitions","Persistence and rollback"],"tableOfContents":[{"id":"save-and-edit-definitions","title":"Save and edit definitions","level":2},{"id":"persistence-and-rollback","title":"Persistence and rollback","level":2}],"markdown":"# Facets, paging and saved searches\n\nEnable browsing on a source-linked search service when a screen needs complete authorized\nfacet counts, stable pages and account-owned saved definitions. The existing manual search\nservice, `SearchClient.search()` and `mountSearch()` remain available.\n\n```ts\nimport { defineDatabase, defineTable, s } from \"@clank.run/framework\";\nimport { openSearch } from \"@clank.run/framework/search\";\n\nconst schema = defineDatabase({\n  notes: defineTable({ title: s.string(), body: s.string(), category: s.string() }).owned(),\n});\nconst search = await openSearch({\n  path: \"app.sqlite\", auth, schema,\n  source: {\n    name: \"notes\", table: \"notes\", title: \"title\", body: \"body\", scope: \"owner\",\n    facets: [\"category\"],\n  },\n  browsing: { policyRevision: \"notes-policy/1\" },\n  authorize: ({ auth }, scope) => auth.requireUser().id === scope,\n});\nwhile (search.rebuild({ batchSize: 250 }).status !== \"ready\") {}\n```\n\nThe application owns the session and the scope/record policies. For public source tables,\ndeclare the persisted scope field and check current membership. Browser scope strings grant\nno access. Policies must return `true` synchronously; rejected promises are contained and\naccess is denied. Update `policyRevision` whenever policy semantics change. Source-linked\nindex registration, repair, detach and writer-upgrade requirements still apply; see\n[durable data workflows](durable-data-workflows.md#link-an-index-to-source-rows).\n\nDeclare at most eight distinct scalar source fields. Strings, finite numbers, booleans, null,\nscalar unions and optional scalar fields are supported. A missing optional field counts as\nnull. Arrays and nested objects are rejected. Facet strings have a 200-character bound; a\nsource value outside that bound makes browsing unavailable until corrected.\n\n```ts\nimport { createSearchBrowsingClient, mountSearchBrowsing } from \"@clank.run/framework/search\";\n\nconst client = createSearchBrowsingClient({ auth: browserAuth });\nconst definition = { text: \"launch\", filters: [{ field: \"category\", value: \"release\" }], sort: \"title\" } as const;\nconst first = await client.browse(currentUserId, definition, { limit: 20 });\nif (first.nextCursor) {\n  const next = await client.browse(currentUserId, definition, { limit: 20, cursor: first.nextCursor });\n}\nconst dispose = mountSearchBrowsing(container, {\n  client, currentUser: () => currentUserId, scope: () => currentUserId,\n  fields: [\"category\"], open: id => navigateToRecord(id), pageSize: 20,\n});\n```\n\n`text` accepts up to 500 characters and one to ten literal words combined with AND. Empty\ntext browses the entire scope. Filters use AND equality, with at most one filter per declared\nfacet. Values retain their types: `1`, `\"1\"`, true and null are distinct. Facets describe the\ncomplete authorized match set **after all filters**; they are not disjunctive suggestions.\nEach field returns `{ value, count }` entries. Counts include each source record once.\n\nRelevance uses the existing per-record word scores, never global hidden-document statistics.\nTitle ordering uses NFC-normalized lowercase strings. Both orders use raw string ID ordering\nto break ties, without depending on host locale settings. Snippets retain the original text.\nCurrent record policy runs before values are fetched, ranked or counted. Indexed source\nversion, title/body and scope must agree with the current owner-scoped source row. Inaccessible,\nstale, orphaned and duplicate entries contribute no values, counts, snippets or scores.\n\nPages return `hits`, complete `total`, `facets`, `revision` and `nextCursor`. The opaque cursor\npins current user, scope, canonical definition, index generation/revision, facet declaration,\npolicy revision and the currently authorized source versions. Each page rechecks current\naccess. A changed index, policy, ACL or definition returns `SEARCH_CURSOR_STALE` (409);\ndiscard the cursor and search again. Index revisions include writes to hidden records, so\nsuch a write can invalidate a cursor without exposing that record. Repair/replacement never\nsilently skips or duplicates a page. A rebuilding/detached index returns\n`SEARCH_SOURCE_UNAVAILABLE` (503).\n\nBrowsing examines at most `maxCandidates` authorized candidates (5,000 by default, maximum\n50,000), 16 MiB of current source JSON and 100 distinct values per facet. The existing whole\nscope and index admission bounds still apply. These checks happen before large source values\nare materialized. Unlike legacy limited search, overflow returns `SEARCH_BROWSING_CAPACITY`\n(503), with no partial facet result. Filters do not rescue a source that exceeds examined\ncandidate/byte bounds; narrow text, partition the scope or adjust the declared source.\nPages contain 1–100 hits. Cursor input is capped at 2,000 characters and serialized definitions\nat 8,000 characters.\n\n## Save and edit definitions\n\n```ts\nconst saved = await client.save(currentUserId, {\n  key: \"daily-release\", expectedRevision: 0, name: \"Daily releases\", definition,\n});\nconst edited = await client.save(currentUserId, {\n  key: saved.key, expectedRevision: saved.revision, name: \"Release review\", definition,\n});\nconst mine = await client.saved(currentUserId);\nawait client.removeSaved(currentUserId, edited.key, edited.revision);\n```\n\nA caller-selected key contains 1–120 characters and is owned by the current account, index\nand scope. New definitions use revision zero; edits/deletes require the observed positive\nrevision. Names contain 1–100 characters and must remain nonempty after trimming. SQLite\ncommits each mutation and its last accepted fingerprint/result together. An identical retry\nof the latest accepted save/delete returns the same revision after a lost response or restart.\nA changed retry or older edit returns `SEARCH_DEFINITION_STALE` (409) and never executes again.\nCurrent scope/session authorization applies before accepted-result replay.\n\nDeletion clears the name, definition and declaration while retaining a compact key, revision\nand retry fingerprint. A retired key cannot be recreated; choose a new key. This prevents an\nold creation retry from silently creating a different definition. The widget retains uncertain\ncreation keys while mounted; after remount, refresh saved searches before creating another\ndefinition. It supports load, rename/edit, save, delete and a new-definition action.\n\nDefinitions are pinned to the index generation, declared facets and policy revision. A changed\ndeclaration returns `usable: false` and `definition: null`; private stale filters are never\nautomatically applied. Replace it using the observed revision and current definition, or delete\nit. Ordinary source edits change result cursors but do not make saved query intent unusable.\n\nDefaults allow 50 live definitions per account/index/scope (`maxSavedSearches`, maximum 200),\n10,000 identities across the database (`maxSavedIdentities`, maximum 50,000), and 16 MiB of\nlogical stored metadata (`maxSavedBytes`, maximum 64 MiB). Global bounds include compact deleted\nidentities. Capacity applies backpressure and rolls back the complete mutation; accepted retry\nidentities are never silently evicted. SQLite page/WAL/backup overhead is additional.\n\nThe widget fences asynchronous replies and saved/result actions by account and scope, clears\nprivate forms/results on detected identity change or denied access, and drops late replies on\ndisposal. Host applications should remount/dispose it when their session changes; a getter\ncannot push an immediate logout notification by itself. All values render as text. Current\nbrowser CSRF headers are attached to POST queries and mutations.\n\n## Persistence and rollback\n\nBrowsing is opt-in and adds versioned private definition metadata to the same SQLite database.\nIt does not change the source-index binding format or register extra source projections.\nDisabled browsing adds no definition metadata. Native writes execute inside the existing host\nmutation transaction; current queries use its read snapshot and have no query cache. Saved\ndefinitions are retrieved explicitly and are not a live-query subscription API.\n\nDrain newer browsing writers before reverting to older code. Older binaries continue legacy\nsearch and leave definition metadata intact. Do not remove retained keys to enable old retry\nreuse. Unsupported metadata protocols fail closed. Source writers still require the existing\nupgraded source-search implementation; direct trusted SQL modification and independently\nretained backups remain outside the browser contract. Source-linked FTS retains its documented\npoint-in-time recovery incompatibility; this feature does not add transparent FTS recovery.\n"}