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 a record by id (upsert). → { id, ts }
await PREEN.db.update("state", id, { 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 are author-chosen: [A-Za-z0-9_-], up to 64 characters. 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, the best is the longest session ever recorded.
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 are raw JSON and never run code on the phone (D7). The datastore is side-effect-free (no shell), so it works in release builds — unlike interactive actions.