Skip to content

Typed data

Typed data is the structured store an extension keeps on the user’s machine. You declare tables as a plain object, and the host generates the SQL, compiles your queries, and hands back an API where column names, row shapes, and where clauses are all checked by TypeScript. When you later add a column, the host adds it to the existing table instead of losing the rows.

After this page you can declare a schema, read and write it from both the main and renderer halves of an extension, query it with predicates, and know exactly which schema changes apply themselves and which ones stop you.

Put the schema in a file both halves import — that single source of truth is the whole point, because the renderer’s type inference and main’s DDL generation must agree.

shared/schema.ts
import type { DataSchema } from '@wamp/extension-sdk';
export const schema = {
version: 1,
tables: {
tasks: {
id: { type: 'integer', primary: true, autoIncrement: true },
title: { type: 'text', notNull: true },
status: { type: 'enum', values: ['todo', 'doing', 'done'] as const, default: 'todo' },
notes: { type: 'text' },
dueAt: { type: 'datetime' },
createdAt: { type: 'datetime', defaultNow: true, notNull: true },
},
},
indexes: [
{ table: 'tasks', columns: ['status'] },
{ table: 'tasks', columns: ['dueAt'] },
],
} as const satisfies DataSchema;

as const satisfies DataSchema is not decoration. Without as const the enum literals widen to string, and where: { status: 'todo' } stops being type-checked. Without satisfies you lose the error when a column descriptor is malformed.

Field Type Notes
tables Record<string, TableSchema> Required. Table name → column name → column descriptor.
indexes { table, columns, unique? }[] Optional. Created after the tables, so an index may target a column added in the same run.
version number Optional, defaults to 1. Only meaningful alongside migrations.
migrations { version, up }[] Optional raw-SQL escape hatch. up runs when the persisted version is lower than version.

Every column is { type, …options }. The shared options are notNull, primary (implies notNull), and unique.

type JavaScript value Extra options
text string default
integer number default, autoIncrement
real number default
boolean boolean default — stored as 0/1, read back as a boolean
datetime Date defaultNow: true for the insert timestamp, or a literal default
json unknown default as plain JS — serialized on write, parsed on read
enum the literal union values (required), default

A column with no notNull and no primary is nullable, and its inferred type includes null. Writing a value outside an enum’s values throws Enum violation: 'x' is not in [a, b] rather than storing it.

The two halves reach the same store through two calls with the same argument.

// main/activate.ts — returns a Promise
const data = await ctx.api.data.defineSchema(schema);
// ui/index.tsx — synchronous; the proxy pushes the schema itself
const data = pluginAPI.data.defineSchema(schema);

Both are idempotent, and either one alone is enough: an app that is only a page with no main half is initialized by the renderer call. The renderer surface is a proxy over the host store, not a second copy of it — same rows, same file.

The store is one SQLite file per extension on the user’s machine. Nothing in it leaves the device, and nothing in it is shared with another extension.

Each table on the handle carries six operations.

Operation Argument Returns
findMany { where?, orderBy?, limit?, offset? } Row[]
findUnique { where } Row | null
create { data } the created Row
update { where, data } { changes: number }
delete { where } { changes: number }
count { where? } number

Plus data.raw<R>(sql, params?) on the handle itself, which returns untyped rows for the queries the predicate vocabulary cannot express.

const created = await data.tasks.create({
data: { title: 'Ship the docs', dueAt: new Date(Date.now() + 86_400_000) },
});
await data.tasks.update({ where: { id: created.id }, data: { status: 'doing' } });
const open = await data.tasks.count({ where: { status: q.ne('done') } });

create lets you omit any column that the runtime can fill in: nullable columns, an autoIncrement primary key, anything with a literal default, and datetime columns with defaultNow: true. Everything else is required, and TypeScript says so.

A where clause maps column names to either a bare value (which means equality) or a predicate built with q. Predicates from separate columns are AND-ed.

import { q } from '@wamp/extension-sdk';
const urgent = await data.tasks.findMany({
where: {
status: q.in(['todo', 'doing']),
dueAt: q.lt(q.now()),
},
orderBy: { dueAt: 'asc' },
limit: 20,
});
Predicate SQL
q.eq(v) / a bare value = ?
q.ne(v) <> ?
q.gt(v), q.gte(v), q.lt(v), q.lte(v) >, >=, <, <=
q.in(vs) IN (…); an empty array matches no rows
q.notIn(vs) NOT IN (…); an empty array matches every row
q.like(pattern) LIKE ? — you supply the % and _
q.isNull() / q.isNotNull() IS NULL / IS NOT NULL

q.now() returns a Date for use as a value, not a predicate — write dueAt: q.lt(q.now()).

For alternatives, add an OR key holding a list of full where clauses. The group is AND-ed with the column predicates beside it.

const mine = await data.tasks.findMany({
where: {
status: q.ne('done'),
OR: [{ title: q.like('%docs%') }, { notes: q.like('%docs%') }],
},
});

orderBy takes column names mapped to 'asc' or 'desc', and several columns sort in the order you wrote them. limit and offset are integers — pass offset only together with limit, since an offset alone produces invalid SQL.

Mistakes that do throw, immediately and by name: an unknown column in where (Unknown column 'x' in where clause), an unknown column in a create or update payload, an empty or missing where on update or delete — so there is no way to accidentally rewrite the whole table — and an empty data payload.

Reconciliation runs on every defineSchema call. Tables that do not exist are created. Tables that do exist are compared against the declaration column by column, and the outcome is one of three things.

Applied automatically. A newly declared column that SQLite can backfill onto existing rows is added with ALTER TABLE … ADD COLUMN. Existing rows keep their data and get the column’s default, or NULL if it has none. This is the case you want, and it is why adding a field to a shipped app does not lose the user’s records. New tables and new indexes are created the same way. (One wrinkle: a column added to an existing table gets its type, default, and enum check, but not a notNull constraint — SQLite cannot add one after the fact, so the persisted table is slightly more permissive than a freshly created one.)

Tolerated. A column, table, or index that is still in the database but no longer in your schema is left alone. Reads ignore it. Nothing is dropped, ever — losing data is worse than carrying a dead column.

Refused. Three changes cannot be applied to a populated table, and defineSchema throws with the table and column named:

  • adding a column marked primary or uniqueALTER ADD cannot introduce either constraint;
  • adding a notNull column with no default — existing rows would have no value to backfill;
  • changing the type of an existing column — retyping in place would reinterpret the stored bytes.

To make a refused change anyway, bump version and add the SQL yourself:

export const schema = {
version: 2,
tables: { /* … */ },
migrations: [
{ version: 2, up: 'CREATE UNIQUE INDEX tasks_title_unique ON tasks(title)' },
],
} as const satisfies DataSchema;

Migrations are up-only; there is no down direction.

Queries from a page cross a JSON boundary, so the runtime converts values in both directions using your schema as the shape oracle. You do not do this yourself, but knowing it explains what you get back:

  • datetime columns take a Date (or an ISO string) on write and come back as a Date.
  • boolean columns are stored as 0/1 and come back as booleans.
  • json columns are serialized on write and parsed on read; a value that fails to parse comes back as the raw string rather than throwing.

Typed data is per machine. It answers “what did this person store in this app on this computer” and nothing more — a second device sees an empty store, and two people using the same app never see each other’s rows.

When the product needs those other two answers, the store to reach for is cloud, which is a document store on your app’s own backend rather than a local table:

Surface Scope Requires
data (this page) This machine only Nothing
cloud.mine One signed-in end user, across their devices database permission, and a product with an app backend
cloud.shared One copy for everyone using the app database permission, and a product with an app backend

cloud is a key/value surface — get, set, delete, list over collections — not a typed table API, and cloud.shared is readable and writable by any signed-in user of the app, so it is a shared board rather than a place for secrets. See Hosted end-user accounts for the identity half and Your own backend for the deployment half.

There is no permission for typed data, and none is checked. The store is scoped to your extension id, no other extension can reach it, and nothing in it leaves the machine — the same posture as the ungated key/value storage primitive.

Declare database anyway if your extension keeps records:

{ "permissions": ["database"] }

It is the consent signal the install screen shows the user, and it is what cloud is actually gated on — the host re-checks it in the main process before any record leaves the machine, so a modified page bundle cannot reach your backend without it.

An app whose page lists tasks and whose main half checks for overdue ones once a day. Three files plus the schema above.

extension.json
{
"name": "Task Tracker",
"version": "1.0.0",
"engines": { "wamp": "^12.0.0" },
"description": "Typed task storage with a daily overdue check.",
"main": "dist/main.js",
"icon": "ListChecks",
"permissions": ["database", "cron", "notifications"],
"activationEvents": ["onStartupFinished"],
"contributes": {
"pages": [
{ "id": "task-tracker", "title": "Tasks", "icon": "ListChecks", "presentation": "app" }
]
}
}
main/activate.ts
import type { PluginContext } from '@wamp/extension-sdk';
import { q } from '@wamp/extension-sdk';
import { schema } from '../shared/schema';
export async function activate(ctx: PluginContext): Promise<void> {
const data = await ctx.api.data.defineSchema(schema);
const handle = ctx.api.cron?.schedule('task-tracker.overdue', '0 9 * * *', async () => {
const overdue = await data.tasks.findMany({
where: { status: q.ne('done'), dueAt: q.lt(new Date()) },
});
if (overdue.length === 0) return;
ctx.api.notifications?.show({
title: `${overdue.length} overdue task${overdue.length === 1 ? '' : 's'}`,
body: overdue.slice(0, 3).map((t) => `${t.title}`).join('\n'),
});
});
if (handle) ctx.api.disposables.add(handle);
}
ui/index.tsx
import { Badge, Button, EmptyState, useDataQuery } from '@wamp/ui';
import { pluginAPI } from '@wamp/plugin-api';
import type { RowFor } from '@wamp/extension-sdk';
import { schema } from '../shared/schema';
type Task = RowFor<typeof schema.tables.tasks>;
const data = pluginAPI.data.defineSchema(schema);
function TaskTrackerPage() {
const tasks = useDataQuery(
() => data.tasks.findMany({ orderBy: { createdAt: 'desc' } }),
[],
);
const add = async () => {
await data.tasks.create({ data: { title: 'Untitled task' } });
await tasks.refetch();
};
const cycle = async (task: Task) => {
const next = task.status === 'todo' ? 'doing' : task.status === 'doing' ? 'done' : 'todo';
await data.tasks.update({ where: { id: task.id }, data: { status: next } });
await tasks.refetch();
};
if (tasks.loading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
if (tasks.error) return <div className="p-4 text-sm text-destructive">{String(tasks.error)}</div>;
return (
<div className="h-full flex flex-col bg-background text-foreground">
<header className="flex items-center justify-between border-b border-border p-3">
<h1 className="text-sm font-semibold">Tasks</h1>
<Button size="sm" onClick={add}>New task</Button>
</header>
{tasks.data?.length ? (
<ul className="flex-1 divide-y divide-border overflow-y-auto">
{tasks.data.map((task) => (
<li key={task.id} className="flex items-center gap-3 px-4 py-3">
<button onClick={() => cycle(task)} aria-label={`Status: ${task.status}`}>
<Badge variant="outline" className="capitalize">{task.status}</Badge>
</button>
<span className="truncate">{task.title}</span>
</li>
))}
</ul>
) : (
<EmptyState title="No tasks yet" description="Add one to get started." />
)}
</div>
);
}
export const views = { 'task-tracker': TaskTrackerPage };
export default TaskTrackerPage;

RowFor<typeof schema.tables.tasks> is how you name a row type in your own code; with the schema declared as const, task.status is 'todo' | 'doing' | 'done' and not string.

useDataQuery(thunk, deps) from @wamp/ui runs the query, coalesces identical in-flight calls, and gives you { data, loading, error, refetch }. It re-runs when deps change, and it invalidates itself when a write lands from another window. Call refetch() after your own mutations; never poll on an interval.