push_data gives a widget a single last-value feed — the latest snapshot, pushed
in. The datastore is the other half: a place for a widget to keep things itself,
across reloads — a high score, a log of every run, anything it computes.
Each widget gets its own SQLite database on the Mac (Application Support/Preen/ store/<widgetId>.sqlite). One widget can’t read or corrupt another’s; deleting a
widget deletes its database. Records are grouped into author-named collections and
hold raw JSON — the hub never interprets the shape, exactly like a data feed.
Why not
localStorage? Widgets load from an opaque origin, wherelocalStorageis unreliable (it can silently fail to persist). The datastore is durable, queryable, survives reloads, and works in release builds.
From a widget: window.PREEN.db
The widget calls its store through the window.PREEN bridge — no
network, no token, no widget id. Every call is scoped to this widget and returns a
Promise.
// Append a record to a collection. → { id, ts }
const { id } = await PREEN.db.put("sessions", { dur: 5400, end: Date.now() });
// List records, newest first. → [{ id, ts, body }]
const recent = await PREEN.db.get("sessions", { limit: 30 });
const best = Math.max(0, ...recent.map((r) => r.body.dur));
// Replace the record just inserted, in the same collection. → { id, ts }
await PREEN.db.update("sessions", id, { dur: 5400, end: Date.now(), paused: true });
// Delete one record, or clear the whole collection. → { deleted }
await PREEN.db.delete("sessions", id);
await PREEN.db.clear("sessions");
| Call | Returns | Notes |
|---|---|---|
put(collection, body) | { id, ts } | Append a record. body is any JSON object. |
get(collection, query?) | [{ id, ts, body }] | List records. See query options below. |
update(collection, id, body) | { id, ts } | Replace an existing record’s body. Rejects if id is gone. |
delete(collection, id) | { deleted } | Delete one record. |
clear(collection) | { deleted } | Delete every record in the collection. |
A record is { id, ts, body }: id is a hub-assigned autoincrement key, ts is the
write time (Unix seconds), and body is the JSON you stored.
Query options (all optional):
limit— max rows (default 100, max 1000).since— only rows withts >= since(Unix seconds).order—"desc"(newest first, default) or"asc".
Collection names must be nonempty, at most 64 characters, and contain only
letters, numbers, _, or - (Unicode letters and numbers are accepted).
Using [A-Za-z0-9_-] keeps names simple. Each collection
is capped at 5,000 rows — the oldest are trimmed past that, so an append loop can’t
grow unbounded.
Worked example: a focus timer’s history
The built-in Do-Not-Disturb widget records every focus session, then derives its “personal best” from real history instead of a fragile single value:
const hasDB = !!(window.PREEN && PREEN.db);
// On end-of-session, append it.
async function recordSession(durMs) {
if (hasDB) await PREEN.db.put("sessions", { dur: durMs, end: Date.now() });
}
// On load, find the best among the most recent 1000 retained sessions.
async function loadBest() {
if (!hasDB) return 0;
const rows = await PREEN.db.get("sessions", { limit: 1000 });
return rows.reduce((m, r) => Math.max(m, r.body.dur || 0), 0);
}
Because the raw sessions are kept, you can later compute streaks, weekly averages, or a sparkline — all from the same collection, no schema change.
From an agent, the CLI, or a script
The same store is reachable off-device so an agent can seed, inspect, or aggregate it.
Via the MCP — store_put, store_query, store_delete:
// store_query
{ "id": "wgt_abc123", "collection": "sessions", "limit": 5 }
// → { "records": [ { "id": 42, "ts": 1718900000, "body": { "dur": 5400 } }, … ] }
Via the CLI:
preen db put dnd sessions '{"dur":5400,"end":1718900000000}'
preen db get dnd sessions --limit 5 --order desc
preen db delete dnd sessions --record 42 # omit --record to clear the collection
When to use which
push_data/ data endpoint — live, externally-sourced values you want on screen now (weather, a build status, now-playing). Last value wins; no history.- Datastore — state the widget owns and accumulates over time (scores, logs, settings, history). Durable and queryable.
Both store JSON without interpreting it as code. Your widget’s JavaScript runs on the phone and can compute values from either. Datastore operations read and write records on the Mac without running shell commands, and do not need action consent. Interactive actions also work in release builds, with their separate capability approval.