TanStack DB Adapter
Sync host-side data to TanStack DB collections in the webview
Overview
The @nativewindow/tsdb package bridges host-side data to TanStack DB collections in the webview. It provides:
- Imperative push API on the host —
insert,update,delete,batch, andsync - Collection options creator on the webview — plug directly into
createCollection()from TanStack DB - Read-only data flow — data flows host to webview only; the webview collection is a reactive read-only view
- Automatic snapshot/resync — call
sync()after page load to re-initialize the webview collection
The package is pure TypeScript with zero runtime dependencies. It uses the same { $ch, p } envelope format as the Typed IPC layer and works alongside typed channels on the same window.
Two entry points are provided:
| Entry Point | Side | Description |
|---|---|---|
@nativewindow/tsdb | Host (Bun/Deno/Node.js) | createDataSource() — push mutations to the webview |
@nativewindow/tsdb/client | Webview (browser) | nativeWindowCollectionOptions() — TanStack DB collection config |
Installation
bun add @nativewindow/tsdb
# or
deno add npm:@nativewindow/tsdbIn your webview app, install TanStack DB separately:
bun add @tanstack/db @tanstack/react-db
# or
deno add npm:@tanstack/db npm:@tanstack/react-dbNote:
@tanstack/dbis not a peer dependency of the TSDB package — it is only needed in the webview app's own dependencies. The adapter uses a structural interface compatible with TanStack DB'screateCollection()without importing it.
Host Side
createDataSource
Create a data source bound to a window (or any object with a postMessage method):
import { NativeWindow } from "@nativewindow/webview";
import { createDataSource } from "@nativewindow/tsdb";
type Todo = { id: string; text: string; done: boolean };
const win = new NativeWindow({ title: "My App" });
win.loadUrl("http://localhost:5173");
const todos = createDataSource<Todo, string>(win, {
channel: "tsdb:todos",
getKey: (todo) => todo.id,
});
// Push data to the webview
todos.insert({ id: "1", text: "Buy milk", done: false });
todos.update("1", { id: "1", text: "Buy oat milk", done: false });
todos.delete("1");
// Re-send full state after page load
win.onPageLoad((event) => {
if (event === "finished") todos.sync();
});The sender parameter accepts any object implementing the MessageSender interface ({ postMessage(message: string): void }). NativeWindow satisfies this interface.
| Option | Type | Description |
|---|---|---|
channel | string | IPC channel name (e.g. "tsdb:todos"). Must match the webview side. |
getKey | (item: T) => TKey | Extract the primary key from an item. |
DataSource API
| Method | Description |
|---|---|
insert(item) | Send an insert to the webview and track the item internally. |
update(key, item) | Send a full-item update to the webview and update internal state. |
delete(key) | Send a delete to the webview and remove the item internally. |
batch(fn) | Execute multiple operations as a single IPC message (see below). |
sync(items?) | Send the current internal state as a full snapshot. If items is provided, replaces the internal state first. |
Batch Operations
Use batch() to send multiple operations in a single IPC message. The webview applies them atomically within one begin/commit cycle:
todos.batch((b) => {
b.insert({ id: "1", text: "A", done: false });
b.insert({ id: "2", text: "B", done: false });
b.delete("3");
});If the callback produces no operations, no message is sent.
Snapshots with sync()
sync() sends the data source's current internal state as a full snapshot to the webview. The webview collection replaces its contents and calls markReady(), signaling to TanStack DB that the initial data is available.
// Send current state
todos.sync();
// Replace internal state and send
todos.sync([
{ id: "1", text: "A", done: false },
{ id: "2", text: "B", done: true },
]);Typical usage: call sync() from an onPageLoad("finished") handler to re-initialize the webview collection after navigation or reload. The data source maintains an internal Map of all items, so you do not need to track items externally.
Webview Side
nativeWindowCollectionOptions
Create collection options that plug into TanStack DB's createCollection():
import { createCollection } from "@tanstack/db";
import { nativeWindowCollectionOptions } from "@nativewindow/tsdb/client";
type Todo = { id: string; text: string; done: boolean };
const todoCollection = createCollection(
nativeWindowCollectionOptions<Todo, string>({
id: "todos",
channel: "tsdb:todos",
getKey: (todo) => todo.id,
}),
);| Option | Type | Description |
|---|---|---|
id | string | Collection ID for TanStack DB. |
channel | string | IPC channel name. Must match the channel passed to createDataSource on the host side. |
getKey | (item: T) => TKey | Extract the primary key from an item. |
The returned config is read-only — no onInsert/onUpdate/onDelete mutation handlers. Data flows host to webview only. TanStack DB manages the subscribe/unsubscribe lifecycle automatically via the cleanup function returned by the sync callback.
Using with TanStack DB and React
Combine with useLiveQuery from @tanstack/react-db for reactive rendering:
import { useLiveQuery } from "@tanstack/react-db";
import { createCollection } from "@tanstack/db";
import { nativeWindowCollectionOptions } from "@nativewindow/tsdb/client";
type Todo = { id: string; text: string; done: boolean };
const todoCollection = createCollection(
nativeWindowCollectionOptions<Todo, string>({
id: "todos",
channel: "tsdb:todos",
getKey: (todo) => todo.id,
}),
);
function TodoList() {
const { data: todos } = useLiveQuery((q) => q.from({ todo: todoCollection }));
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} style={{ textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text}
</li>
))}
</ul>
);
}The component re-renders automatically whenever the host pushes new data via the data source.
Wire Protocol
Messages use the same { $ch, p } JSON envelope format as the Typed IPC layer. The $ch field is set to the channel string from the data source options (e.g. "tsdb:todos"). The p payload is a discriminated union on a t field:
t | Description | Payload Shape |
|---|---|---|
"i" | Insert | { t: "i", k: TKey, v: T } |
"u" | Update | { t: "u", k: TKey, v: T } |
"d" | Delete | { t: "d", k: TKey } |
"s" | Snapshot (full state) | { t: "s", items: Array<{ k: TKey, v: T }> } |
"b" | Batch (atomic) | { t: "b", ops: Array<SyncOp> } |
Single-letter keys minimize payload size over the IPC bridge.
Note: The TSDB adapter uses
postMessagedirectly — it does not register as a typed channel. This means TSDB traffic and typed channel traffic coexist on the same window without interfering with each other.
Integration with IPC
The webview-side sync function uses two strategies to receive messages:
-
Preferred: listener registry — when the IPC client (
createChannelClientor the auto-injected script) is present,window.__native_message_listeners__is available. The sync function callsregistry.add(handler)to register, andregistry.remove(handler)on cleanup. This is the recommended path when using the Typed IPC layer. -
Fallback: direct interposition — when the IPC client is not present (standalone usage), the sync function interposes on
window.__native_message__directly, chaining to the previous handler. The assignment is wrapped intry/catchfor safety in case the property was frozen.
For the best experience, use the TSDB adapter alongside the Typed IPC layer. See the Security guide for details on how the listener registry is hardened.
Type Exports
Host (@nativewindow/tsdb)
| Export | Description |
|---|---|
MessageSender | Sender interface: { postMessage(message: string): void } |
DataSourceOptions<T, TKey> | Options for createDataSource (channel, getKey) |
DataSource<T, TKey> | Return type of createDataSource (insert, update, delete, batch, sync) |
BatchBuilder<T, TKey> | Builder passed to the batch() callback (insert, update, delete) |
Webview (@nativewindow/tsdb/client)
| Export | Description |
|---|---|
NativeWindowCollectionConfig<T, TKey> | Options for nativeWindowCollectionOptions (id, channel, getKey) |
NativeWindowCollectionResult<T, TKey> | Return type of nativeWindowCollectionOptions — pass directly to createCollection() |