Kunkun
Building Extensions

Record Storage

db.collection — a per-plugin document store with queries, full-text search, and live-updating UI.

For anything more than a handful of key/value pairs, Kunkun gives every extension a real record store: db.collection<T>(name). It's row-oriented, queryable, full-text searchable, paginated, and reactive — backed by SQLite + FTS5 on the host.

Why not LocalStorage?

LocalStorage is fine for a few flags. But storing a list in KV forces you to either rewrite one giant JSON blob on every change, or spray key:id entries you can't filter or paginate. The record store fixes both. Both APIs share the storage permission.

Getting a collection

import { db } from "@kunkunsh/sdk";

interface Bookmark { url: string; title: string; tags: string[]; visits: number }
const bookmarks = db.collection<Bookmark>("bookmarks");

Collections are created on first write. Names match ^[a-z0-9][a-z0-9-_]{0,63}$.

Writing

const id = await bookmarks.add({ url: "https://kunkun.sh", title: "Kunkun", tags: ["dev"], visits: 0 });
await bookmarks.set("my-id", data);                 // upsert, full replace
await bookmarks.update(id, { visits: 5 });          // shallow merge
await bookmarks.delete(id);                          // idempotent
await bookmarks.bulkSet([{ data }, { data, searchText: "…" }]); // ≤1000 items, one transaction
await bookmarks.bulkDelete([id1, id2]);
await bookmarks.clear();                             // empty the collection

add generates a ULID id; you can also supply your own (≤128 chars) via set / bulkSet.

Reading

const rec = await bookmarks.get(id);          // DbRecord<Bookmark> | undefined
const recs = await bookmarks.getMany([a, b]); // preserves order, missing ids omitted

Every record has a stable envelope:

interface DbRecord<T> {
  id: string;        // ULID (or your id)
  data: T;           // your JSON document
  createdAt: string; // ISO, set on first insert
  updatedAt: string; // ISO, updated on every write
}

Querying

const page = await bookmarks.query({
  where: [["visits", ">=", 10], ["tags", "contains", "dev"]],
  search: "github",                 // FTS5 over searchText (see below)
  orderBy: [["visits", "desc"]],
  limit: 50,
  cursor: prev?.nextCursor,         // keyset pagination
});
// => { items: DbRecord<Bookmark>[], nextCursor?: string }

const n = await bookmarks.count({ where: [["tags", "contains", "dev"]] });

Operators

OpMeaning
== !=Equality (matches JSON null or absent field when compared to null)
< <= > >=Comparison
inField is one of a list (≤100 values)
containsArray field contains the value
  • Field paths use dot notation into your document: ["author.name", "==", "Ada"].
  • Multiple where clauses are AND-ed (≤10 clauses; OR is planned).
  • orderBy accepts createdAt, updatedAt, id, or a JSON path; id is always appended as a tiebreaker (default order is id desc → newest ULIDs first).
  • Cursors are opaque keyset tokens valid only for the same where/orderBy shape — pass nextCursor back to get the next page.

ULIDs are load-bearing

Ids are ULIDs by default: collision-free across devices and lexicographically creation-ordered. That's why "newest first" needs no extra timestamp column — and it's what makes the store cloud-sync-ready without changing your code.

Search is opt-in per record via a searchText field you provide on write; queries then FTS5-match it:

await notes.add({ title: "Buy groceries", body: "milk, eggs" }, { searchText: "buy groceries milk eggs" });
const hits = await notes.query({ search: "milk" });

Reactivity

watch

const unsubscribe = bookmarks.watch((event) => {
  // event: { collection, ids, op: "set" | "delete" | "clear", timestamp }
});
db.watch((event) => { /* any collection in this plugin */ });

The host emits a db:changed event after each committed write, delivered to your extension across whichever host it runs under (desktop custom-view, worker relay, or CLI/web).

Don't hand-wire watch + refetch — bind the query and let it update itself.

Svelte (createLiveQuery is a store):

<script lang="ts">
  import { createLiveQuery } from "@kunkunsh/sdk";
  const q = createLiveQuery<Bookmark>("bookmarks", { orderBy: [["visits", "desc"]] });
</script>

{#if $q.isLoading}Loading…{:else}
  {#each $q.records as r (r.id)}<div>{r.data.title}</div>{/each}
  {#if $q.nextCursor}<button onclick={() => q.loadMore()}>More</button>{/if}
{/if}

React (useQuery / useRecord):

import { useQuery, useRecord } from "@kunkunsh/sdk/utils";

function List() {
  const { data, isLoading, nextCursor, loadMore, revalidate } = useQuery<Bookmark>("bookmarks", {
    where: [["tags", "contains", "dev"]],
    orderBy: [["visits", "desc"]],
    limit: 20,
  });
  // any add/set/update/delete → auto re-query → re-render
}

function Detail({ id }: { id: string }) {
  const { record, isLoading } = useRecord<Bookmark>("bookmarks", id);
}

Errors

import { db, DbError } from "@kunkunsh/sdk";
try {
  await bookmarks.delete("nope");
} catch (e) {
  if (e instanceof DbError && e.code === "RECORD_NOT_FOUND") { /* … */ }
}

Codes: RECORD_NOT_FOUND, QUOTA_EXCEEDED, INVALID_QUERY, INVALID_COLLECTION_NAME, BATCH_TOO_LARGE, EMBEDDING_MODEL_NOT_BOUND. Thanks to error rehydration across kkrpc, instanceof DbError and .code work in your extension.

Sync and limits

Current starting limits (tunable):

LimitValue
Record document size≤ 1 MB
searchText length≤ 16 KB
Per-plugin total≤ 50 MB soft quota
Bulk batch≤ 1000 items
Query limit / where clauses / in values≤ 500 / ≤ 10 / ≤ 100

The store is designed to plug into Kunkun's future cloud-sync as per-record objects (scope_type: "record") with last-write-wins per record — which is exactly why ids are ULIDs and writes are per-row. Semantic (vector) search over records is a planned follow-up; today, use FTS5 searchText.

Collection admin

await db.listCollections();          // metadata for this plugin's collections
await db.deleteCollection("bookmarks");

On this page