Akan.js
Docs
DocsConventionsReferencesCheatsheet
Akan.js
DocsConventionsReferencesCheatsheet
Akan.js

Released under the MIT License

Official Akan.js Consulting onAkansoftCopyright © 2026 Akan.js All rights reserved.System managed bybassman
YouBothAgent▾
You — Business rules and flows you own. Read these yourself.
Both — Know the idea; your agent follows the details.
Agent — Conventions and references your agent follows. Look up as needed.
General▾
AuthorizationOAuth For AgentsSchema DesignText SearchEdge ComputingFile ManagementSingle Sign-OnDataList & Enum
Interface▾
CRUDEndpointMCP ServerAgent ChatForm
Observability▾
LoggingDependency InjectionError HandlingMetrics
Performance▾
CachingImage OptimizationLazy LoadingQueryingMutatingQueueingRealtime
Mobile▾
SetupPush NotificationsDeep LinksUI & KeyboardDesktop Release
Development▾
DocumentationSchema DocsScriptConsoleDockerKubernetesPWATesting
YouBothAgent▾
You — Business rules and flows you own. Read these yourself.
Both — Know the idea; your agent follows the details.
Agent — Conventions and references your agent follows. Look up as needed.
General▾
AuthorizationOAuth For AgentsSchema DesignText SearchEdge ComputingFile ManagementSingle Sign-OnDataList & Enum
Interface▾
CRUDEndpointMCP ServerAgent ChatForm
Observability▾
LoggingDependency InjectionError HandlingMetrics
Performance▾
CachingImage OptimizationLazy LoadingQueryingMutatingQueueingRealtime
Mobile▾
SetupPush NotificationsDeep LinksUI & KeyboardDesktop Release
Development▾
DocumentationSchema DocsScriptConsoleDockerKubernetesPWATesting
PreviousSchema DesignNextEdge Computing

Text Search

Akan has full-text search built in. There is no search server to run and no index to keep in sync by hand.
It takes three steps:
  1. Mark the fields. Give each searchable field a text role in constant.ts.
  2. Write the filter. Call q.search(text) from a filter in document.ts.
  3. Call it. Use the generated listBySearch, sorted by "relevance".
Letting clients search as well is a separate decision, covered in Publishing To Clients.
Search works in every database mode. For the same text, SQLite, libSQL and Postgres match the same documents, but Postgres can order them differently because its ranking does not weigh how rare a word is. Postgres setup is covered in Operating It.
Words used on this page
TermDescription
text
A field option that puts the field in the search index, written as { text: "title" }.
filter
A named query in document.ts. The service gets methods named after it, like listBySearch.
q.search()
The query node that matches text against the index.
slice
A filter published as an endpoint that a client store can load.
relevance
A built-in sort key that puts the best match first.

1. Mark The Fields

Name a role in the field's options, as in { text: "title" }. Pick the role by what the value is, not by how badly you want it found: each role carries its own ranking weight.
A product with all five roles in use:
apps/shop/lib/product/product.constant.ts
RoleWeight
↳ Use
title10
The name a person types into the search box. Ranked above everything else.
tag3
A keyword list. Ranked below the title and above prose.
desc1
Prose. It matches, but should not beat a name match.
filter0
A scoping value like status or owner. Searchable, never a reason to rank first.
thumb—
Stored with the entry so you can draw the result. Not indexed, so it never matches.
  • title, tag and desc take a String. filter and thumb also take an ID or a relation such as field(File), and a string enum counts as a String.
  • Arrays and embedded scalars work. [String] indexes every item, and a role inside an embedded scalar is indexed through its parent. A field inside a Map indexes nothing.
  • Declaring roles is all the wiring. There is no per-model switch; the index follows the roles you declare.
field.secret, field.hidden and resolve() take no text role. The index stores plain text, so an indexed secret would leak through search. The type check refuses it.

2. Write The Filter

q.search() is a query node like any other, so it combines with ordinary conditions inside q.all(). You do not need a slice to search from a service.
Declare the filter
A search that can also narrow by status:
apps/shop/lib/product/product.document.ts
  • .arg() is required, .opt() may be left out and comes after every .arg(). Both reach .query() in the order declared, followed by q.
  • An empty {} adds no condition. With no statuses given, only the search narrows the results.
Like every filter, it needs an entry under .query() in the dictionary, arguments included:
apps/shop/lib/product/product.dictionary.ts
Call it from the service
The filter gives the service a family of methods. These four are the ones a search uses:
MethodDescription
listBySearch
The matching documents. A last { sort, skip, limit } argument orders and pages them.
countBySearch
How many documents match.
insightBySearch
The model's insight, computed over the matches only.
queryBySearch
The query itself, for a slice's exec to return.
A service method that returns one page of results and the total:
apps/shop/lib/product/product.service.ts

3. Tune The Match

Three options cover almost everything, all passed as the second argument of q.search().
prefixbooleandefault false
Lets the last word match as a prefix, for as-you-type boxes. Without it, Ken misses Kenny.
columns("title" | "desc" | "tag" | "filter")[]default all four
Looks only in the named roles. thumb is not indexed, so it is not a column.
weights[title, desc, tag, filter]default [10, 1, 3, 0]
Replaces the ranking weights: four finite, non-negative numbers, in title, desc, tag, filter order.
How input is matched
  • Raw user input is safe. Nothing in it is read as search syntax. Punctuation splits a word into pieces that must appear side by side, so follow-up finds “follow-up” and “follow up”.
  • Every word must match, in any order. The default tokenizer ignores case and accents.
  • Blank input matches nothing. An empty search box never turns into a full listing.
Ordering
When sort is
↳ Order
"relevance"
Best match first.
Any other key, like "latest"
That key wins over the score.
Left off, in a service call
Best match first, because the query holds a search.
Left off, on a slice endpoint
"latest" is filled in, so the score is never used.
From a client, ask for "relevance" by name. A slice endpoint fills in "latest" when sort is left off, so it never falls through to the score.

Publishing To Clients

A filter runs on the server only. A slice turns it into an endpoint a client can call, and on a publicly readable model anyone can then walk the table one query at a time.
So decide per model:
Meant To Be Searched
A product catalog. Publishing a search slice is the point.
Usually Not
A user directory. Keep its search filter on the server.
A public catalog search, with its own guard:
apps/shop/lib/product/product.signal.ts
  • Name the slice in the dictionary too, under .slice() with a .desc(). An MCP agent picks the tool by that description.
  • Load it with the order named: st.do.initProductBySearch(text, statuses, { sort: "relevance" }).
  • A live search slice refetches. A search cannot be matched in memory, so a .live() slice holding one declares { fallback: "invalidate" }.
A named slice is guarded only by its own init({ guards }). The slice() map covers the root slice and generated CRUD. With no guards of its own, the search is open to anyone over HTTP and left out of MCP.

Operating It

The index keeps itself current through database triggers. A write from any path is reflected, including bulk query-level updates that fire no document hooks.
AKAN_SEARCH_ENABLED1 | true | 0 | falsedefault unset = on
Switches the index on or off. Off keeps indexed data; back on re-syncs every model.
AKAN_SEARCH_TOKENIZERstringdefault unicode61 remove_diacritics 2
Picks the fts5 tokenizer; Postgres reads only the two forms below.
  • Give every process in a deployment the same value. A process cannot clean up triggers for models it does not mount, so a mixed fleet leaves stale ones behind.
  • While search is off, q.search() throws. Filters that declare it still build; only a query that reaches it fails.
  • A tokenizer change is cheap. The next boot rebuilds the index from its own copy of the text without re-reading any model table, so the setting is safe to revisit.
  • A fleet restarted at once rebuilds once. The first process rebuilds and the rest wait for it. On SQLite a process waits only up to its busy timeout (5 seconds by default), so stagger restarts when the index is large.
On Postgres
Three things are specific to Postgres:
  • The tokenizer needs an extension. unicode61 needs unaccent unless it is remove_diacritics 0, and trigram needs pg_trgm. Akan creates it if its database role has the privilege; otherwise run CREATE EXTENSION unaccent (or pg_trgm) as a role that has it.
  • Create the database with a UTF-8 LC_CTYPE, such as en_US.UTF-8 or C.UTF-8. Otherwise case is ignored for ASCII letters only, where SQLite ignores it for every letter.
  • Only the start of a very long text is indexed. With unicode61, Postgres indexes the first 20,000 characters of a document's title, tag and filter text and the first 200,000 of its desc.

Gotchas

Where q.search() cannot go, and what else tends to surprise people:
  • q.search() sits at an AND position only. Under q.any() or q.not() the query throws.
  • Not in query-level writes. The model's updateOne / updateMany / removeOne / removeMany and the generated updateBySearch / removeBySearch family refuse it (the error names updateOneByQuery or updateManyByQuery), because a bulk write cannot join the index.
  • schema.index() has nothing to do with search. Even schema.index({ name: "text" }) builds an ordinary lookup index.
  • Removed documents leave the index, soft deletes included, and a restored one comes back.

On this page

Text Search
1. Mark The Fields
2. Write The Filter
3. Tune The Match
Publishing To Clients
Operating It
Gotchas