A writer key is a lightweight identity attached to state mutations. It lets UIs and services distinguish “my own writes” from external changes to the same entities, enabling echo‑suppression and correct refresh behavior without polling.
onStateCommit.null means “unspecified/unknown writer”. Treat null as external relative to any editor.Writer keys solve a general problem: any state that can be changed by multiple actors (tabs, services, background jobs, bots, imports, etc.) needs a way to react to external updates while avoiding feedback loops from its own writes.
Rich text editors (e.g., TipTap) are a familiar example, but the same mechanism applies to dashboards, forms, document viewers, code generators, ETL pipelines, and more. Writer keys provide the minimal attribution needed to filter “my” changes while still seeing everyone else’s.
Use the helper withWriterKey(db, writer, fn) to run a batch of mutations under a writer identity.
import { withWriterKey } from "@lix-js/sdk";
await withWriterKey(lix.db, "flashtype_tiptap_<session_id>", async (trx) => {
await trx
.insertInto("state")
.values({
entity_id,
file_id,
version_id,
schema_key,
schema_version,
plugin_key,
snapshot_content: snapshot as any,
})
.execute();
});Subscribe to onStateCommit and filter by writer. This is event‑triggered (fires on each commit event) and avoids extra DB work.
const writer_key = "flashtype_tiptap_<session_id>";
const unsubscribe = lix.hooks.onStateCommit(({ changes }) => {
const externalToMe = changes.some(
(c) =>
c.file_id === activeFileId &&
(c.writer_key == null || c.writer_key !== writer_key),
);
if (externalToMe) {
// e.g. reparse the document and update the UI
}
});Sometimes you may want query‑triggered semantics (react when the current query result changes) instead of event‑triggered updates. Observed queries are useful when:
Guidelines
writer_key alone for reactivity — identical projections dedupe and won’t emit.MAX(created_at), MAX(change_id), COUNT(*)) scoped to your entity.file_id, version_id, plugin_key).// Example: drive reactivity off a changing metric (and still select writer_key if useful)
const rows = await lix.db
.selectFrom("state_with_tombstones" as any)
.where("file_id", "=", activeFileId)
.where("plugin_key", "=", "plugin_md")
.where(
"version_id",
"=",
lix.db.selectFrom("active_version").select("version_id"),
)
.select([
sql`MAX(created_at)`.as("last_updated"),
sql`writer_key`.as("writer_key"),
])
.execute();Pros
useQuery/Suspense.Trade‑offs
onStateCommit before re‑executing your SQL.writer_key alone).Recommendation
onStateCommit with writer filtering for interactive UIs and echo suppression.onStateCommit + in‑memory filter
Query‑based watchers
onStateCommit, then re‑execute your SQL.For editor echo suppression and external change detection, onStateCommit is both faster and less error‑prone.
withWriterKey to avoid null (which is treated as external by everyone).file_id and your plugin’s plugin_key to ignore meta rows.lastAppliedCommitId per file to skip redundant refreshes.active_version and refresh on version switches.Do I need writer_key on state if I use onStateCommit?
How do I treat null?
Can I detect external changes with useQuery only?
MAX(created_at), MAX(change_id), COUNT(*)) scoped to your file/version. Don’t rely on writer_key alone for reactivity.const writer_key = "flashtype_tiptap_<session_id>";
// Subscribe to commit events and refresh on external changes
useEffect(() => {
if (!activeFileId || !editor) return;
const unsubscribe = lix.hooks.onStateCommit(({ changes }) => {
const external = changes.some(
(c) =>
c.file_id === activeFileId &&
(c.writer_key == null || c.writer_key !== writer_key),
);
if (external) {
assembleMdAst({ lix, fileId: activeFileId }).then((ast) => {
editor.commands.setContent(astToTiptapDoc(ast));
});
}
});
return () => unsubscribe();
}, [lix, editor, activeFileId]);