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 collectionadd 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 omittedEvery 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
| Op | Meaning |
|---|---|
== != | Equality (matches JSON null or absent field when compared to null) |
< <= > >= | Comparison |
in | Field is one of a list (≤100 values) |
contains | Array field contains the value |
- Field paths use dot notation into your document:
["author.name", "==", "Ada"]. - Multiple
whereclauses are AND-ed (≤10 clauses; OR is planned). orderByacceptscreatedAt,updatedAt,id, or a JSON path;idis always appended as a tiebreaker (default order isid desc→ newest ULIDs first).- Cursors are opaque keyset tokens valid only for the same
where/orderByshape — passnextCursorback 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.
Full-text search
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).
Live queries (recommended)
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):
| Limit | Value |
|---|---|
| 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");