ichibaseichibase

Search (Typesense)

Full-text, typo-tolerant search powered by a Typesense instance dedicated to your project. Mirror Postgres tables or Mongo collections into a search index that stays in lock-step with your data — then query it from your server. Search is a paid-plan add-on.

Paid feature. Search runs on a per-project Typesense container you enable under Add-ons → Typesense. Auto-sync also requires Realtime, since the sync engine rides the same change stream — both are paid-plan features, so sync is paid-only.

Auto-sync (CDC)

You don't index documents by hand. ichibase mirrors a Postgres table or a Mongo collection into a Typesense collection automatically, and keeps it current as a change-data-capture (CDC) stream. The sync engine reuses your project's realtime change stream — the same Postgres logical-replication consumer and Mongo change streams that power realtime subscriptions — so one replication slot feeds both WebSocket fan-out and search indexing.

For every write to a synced source: an INSERT or UPDATE upserts the projected document, and a DELETEremoves it by id. Indexing runs even when no client is subscribed and even if the table isn't realtime-listenable, so your index tracks the source of truth without cron jobs or manual reindexing.

Postgres WAL  ─┐
               ├─► realtime consumer ─► (WS fan-out, if subscribed)
Mongo streams ─┘                     └─► Typesense sink ─► project Typesense

Configuring a sync

Sync is set up per source in the dashboard under Add-ons → Search sync:

1. Pick a source — a Postgres table or a Mongo collection — and Load fields.
   The platform introspects the columns (and primary key), or samples a
   Mongo document, and suggests a Typesense type per field.
2. Select the fields to index, and adjust each one's type / facet / sort.
   Only the fields you select are projected into the search document.
3. Choose the document id field (the source primary key, or Mongo _id).
4. Name the target Typesense collection and Save. This creates (or
   field-diffs) the collection and starts live sync.
5. Optionally click "Import existing rows" to backfill existing data —
   live sync only covers changes from the moment you save.

Config is stored per source in auth.typesense_sync (source_type, source_name, target_collection, id_field, fields, enabled). Backfill is owner-triggered (the “Import existing rows” button), not automatic, so you control when a large table is scanned; re-runs are safe because documents are upserted by id.

Filtering which rows sync

A sync can carry an optional filter — a small boolean expression. Only rows that match it are indexed, for both the backfill and live writes. If a row is updated so it no longer matches, it is removed from the search collection; if it starts matching, it is added. The same filter applies to Postgres and Mongo sources (it is evaluated per row/document).

created_at > now() - (5 days) && published = true && title NOT contains ("spam")

Grammar:

boolean    A && B,  A || B,  NOT (…)
compare    field > 5,  field >= 5,  field = "x",  field != true,  field < now()
contains   field contains "x"      (case-insensitive substring)
           field NOT contains "x"
values     numbers, "strings", true / false, null
now()      now()                   (current time)
           now() - (N unit)        unit = ms | seconds | minutes | hours | days | weeks
fields     a column name (Postgres) or top-level document key (Mongo)

Stored on the sync row as filter (the text you wrote) plus a validated filter_ast JSON tree. A bad expression is rejected when you save. Time comparisons expect a timestamp/date field; contains coerces the value to text. Leave the filter empty to index every row.

The search collection schema

A synced collection is a Typesense collection. You pick which source fields become search fields and their Typesense types when you configure the sync; the resulting schema has the shape Typesense uses:

{
  "name": "posts",
  "fields": [
    { "name": "id",         "type": "string"  },
    { "name": "title",      "type": "string"  },
    { "name": "author",     "type": "string", "facet": true },
    { "name": "published",  "type": "bool",   "facet": true },
    { "name": "created_at", "type": "int64",  "sort": true }
  ],
  "default_sorting_field": "created_at"
}

Values are coerced to the chosen type on the way in. Common source → Typesense type suggestions:

int2 / int4              → int32
int8                     → int64
float4 / float8 / numeric → float
bool                     → bool
timestamptz / date       → int64 (epoch millis)
text / uuid / varchar    → string
json / jsonb / Mongo obj → object
arrays                   → string[] / int64[] / …

Querying

Server-side only. The @ichibase/typesenseSDK authenticates with your project's Typesense admin key (ICHIBASE_TYPESENSE_API_KEY). That key can read, write, and delete every collection — it must never reach a browser or native app. Use this SDK only from a trusted server or inside an ichibase Edge Function. It is a separate package from @ichibase/client, which is the one you ship to clients.

The SDK re-exports the official Typesense client. Inside an Edge Function the URL and admin key are injected as env vars (ICHIBASE_TYPESENSE_URL and ICHIBASE_TYPESENSE_API_KEY), so createTypesense() picks them up with no arguments. The full Typesense surface (search, multi_search, collections, documents, synonyms, overrides, aliases) is available on the returned client.

// Server-side / Edge Function only — uses the project ADMIN key.
// This is @ichibase/typesense, NOT @ichibase/client.
import { createTypesense, tryOp } from 'jsr:@ichibase/typesense';

// Reads ICHIBASE_TYPESENSE_URL + ICHIBASE_TYPESENSE_API_KEY from env.
const ts = createTypesense();

const { data, error } = await tryOp(() =>
  ts.collections('posts').documents().search({
    q: 'rocket',
    query_by: 'title,author',
    filter_by: 'published:true',
    sort_by: 'created_at:desc',
    per_page: 20,
  }),
);

if (error) throw new Error(error.detail);
const hits = data?.hits ?? [];

Searching from a client app

Because querying needs the admin key, client apps must not talk to Typesense directly. The recommended pattern is to put a thin Edge Function in front of it: the function holds the admin key server-side, runs the search with @ichibase/typesense, and returns only the results (and only the fields) you want to expose. Your browser or native app calls that function with the anon key (ich_pub_…) through @ichibase/client — the admin key (ich_admin_…) and the Typesense key never leave your server.

client app  ──(anon key)──►  Edge Function  ──(admin key)──►  Typesense
                             holds the key,
                             shapes the response

Caveats

Sync is eventually consistent. Worth knowing before you rely on it:

• Deletes need an id in the change. A Postgres table with no primary
  key can't be deindexed on delete (the delete carries only the PK
  columns). Mongo deletes always carry _id.
• Eventually consistent, not transactional. If Typesense is briefly
  down a change can be missed; the index self-heals on the next change
  to that row, or via a re-backfill. Don't treat it as a system of record.
• Schema changes. Re-saving a sync diffs the collection (adds new
  fields, drops removed ones; a type change is a drop + re-add).
  Existing documents keep values for fields that still exist.
Source of truth. Search is a read-optimized mirror, not your primary store. Keep authoritative data in Postgres or Mongo and let sync project it into the index; rebuild from the source any time with a backfill.