Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions contracts/spec/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// PocketJS db spec — the boundary of the DB module (`globalThis.db`).
//
// This is a MODULE spec in the docs/RUNTIMES.md §5 sense: a vertical slice
// with its own vocabulary, mounted as its own namespace, pinned here as data.
// It is deliberately NOT part of the `ui` op table — db evolves append-only
// in its own op space, and a host adopts it independently of the UI surface
// (capability id `data.sqlite` in contracts/spec/platforms.ts).
//
// The module is SQLite behind a five-op namespace. The engine, the SQL
// dialect, and the file format are SQLite's — the spec pins only what
// crosses the boundary: op codes, the JSON row encoding, the resource
// ceilings, and the storage/clock rules a host must follow.
//
// The four parts of the boundary:
//
// ops guest -> core intent (numeric codes below, append-only)
// events none — every op is synchronous; the module owns no clock
// data contract the JSON value encoding + database name rules (this file)
// frame contract every op completes inside the guest's single per-tick
// turn (law 3 holds unchanged). SQL time and randomness
// resolve host-side: deterministic hosts pin them, and a
// golden-tested app must not depend on `random()` or
// 'now'-relative SQL (same rule as Date.now in guest code).
//
// Storage rule: `open(name)` is the ONLY path to a database. Names are
// logical (below); the HOST maps them to real files under the app's own
// data root (or memory). The guest never sees a path, and a database file
// is never shared between apps. Hosts MUST refuse `ATTACH` — it is the one
// SQL statement that names a file — so the app's data root stays the
// sandbox boundary. `sqlite3_load_extension` stays disabled (SQLite's
// default).
//
// If you change ANY value here: run `bun contracts/spec/gen-rust.ts`, commit
// the regenerated engine/core/src/spec.rs (tests/contract.ts byte-compares).

// ---------------------------------------------------------------------------
// Db ops (the `db.*` native contract)
// ---------------------------------------------------------------------------
// Numeric codes are the FFI ABI identity of each op. 0 is reserved
// (invalid/nop). Codes are append-only: never renumber, never reuse.
//
// Signatures (authoritative; hosts marshal them however they like):
// open(name:string) -> handle | -1
// [name is DB_MEMORY or matches DB_NAME_PATTERN; -1 =
// refused (bad name or DB_MAX_DATABASES already open).
// Opening the same persistent name twice returns the
// SAME handle; DB_MEMORY always opens a fresh private
// database]
// close(handle) [idempotent; a closed handle is
// dead and every later op on it
// fails with "database is closed"]
// exec(handle, sql:string) -> 0 | 1 [run one or more statements, no
// result rows — the schema and
// migration path. 1 = failed; the
// detail is lastError()]
// query(handle, sql:string, args:string) -> string
// [run ONE statement with bound parameters and return
// the complete result as one JSON line (shape below).
// `args` is a JSON array (positional) or object (named
// $x / :x / @x) of encoded values. Statement caching is
// HOST-side, keyed by the sql string — the guest holds
// no statement handles]
// lastError(handle) -> string [detail for the last failed
// exec/query on this handle; ""
// when none]
//
// query() result, one JSON object per call (fields append-only):
//
// { "cols": ["id","name"], "rows": [[1,"a"],[2,"b"]],
// "changes": 0, "lastInsertRowid": 0 }
// `cols` are the statement's column names ([] for non-readers),
// `rows` are arrays in column order using the value encoding below.
// `changes` / `lastInsertRowid` are SQLite's counters after the call.
// { "error": "no such table: t" }
// The statement failed (parse, bind, step, or a ceiling below).
// lastError() returns the same string.

export const DB_OP = {
open: 1,
close: 2,
exec: 3,
query: 4,
lastError: 5,
} as const;

// ---------------------------------------------------------------------------
// Data contract — value encoding (rows out, parameters in)
// ---------------------------------------------------------------------------
// SQLite value -> JSON value, both directions:
//
// NULL <-> null
// INTEGER <-> number [|v| must be <= 2^53 - 1. A larger stored
// integer makes the op FAIL — a loud error
// beats silent precision loss. Store money
// in cents and ids under 2^53.]
// REAL <-> number [non-finite REAL fails the same way]
// TEXT <-> string
// BLOB <-> { "$b": "<base64>" }
//
// Binding accepts additionally: true/false bind as INTEGER 1/0, and a JSON
// number binds as INTEGER when integer-valued, else REAL (the bun:sqlite /
// better-sqlite3 convention the SDK mirrors).

/** Marker key for a BLOB value inside a row or a parameter list. */
export const DB_BLOB_KEY = "$b";

/** Largest integer magnitude that crosses the boundary losslessly. */
export const DB_MAX_SAFE_INTEGER = 9007199254740991;

// ---------------------------------------------------------------------------
// Data contract — database names
// ---------------------------------------------------------------------------

/** The in-memory database name (private to the handle, gone on close). */
export const DB_MEMORY = ":memory:";

/**
* Logical persistent-database names: a filename-safe token, no paths, no
* extensions games. The host maps a name to a real file under the app's own
* data root; the mapping is host policy and never guest-visible. The 57-char
* ceiling keeps the reference mapping `<name>.sqlite` (+7 bytes) within the
* fs module's 64-byte segment ceiling, so a co-mounted fs module can always
* address the database file the docs call "visible like any of its files".
*/
export const DB_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,56}$/;

// ---------------------------------------------------------------------------
// Data contract — resource ceilings
// ---------------------------------------------------------------------------

/** Open databases per guest. An app has its own db plus room for a scratch
* or migration companion. Deliberately tiny — more simultaneous databases
* is a schema smell, not a bigger constant. */
export const DB_MAX_DATABASES = 4;

/**
* Result-row ceiling per query() call. A query producing more rows FAILS
* ("query exceeds DB_MAX_RESULT_ROWS; add LIMIT or aggregate") — the row
* budget of a 480x272..720x1280 UI is far below this, and an unbounded
* SELECT on a device heap is a bug surfaced early, not a workload.
*/
export const DB_MAX_RESULT_ROWS = 4096;
34 changes: 33 additions & 1 deletion contracts/spec/gen-rust.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Deterministic codegen: contracts/spec/{spec,audio}.ts -> engine/core/src/spec.rs.
// Deterministic codegen: contracts/spec/{spec,audio,db}.ts -> engine/core/src/spec.rs.
//
// Run from PocketJS/: bun contracts/spec/gen-rust.ts
//
Expand All @@ -16,6 +16,14 @@ import {
AUDIO_RING_FRAMES,
audioFramesForTick,
} from "./audio.ts";
import {
DB_BLOB_KEY,
DB_MAX_DATABASES,
DB_MAX_RESULT_ROWS,
DB_MAX_SAFE_INTEGER,
DB_MEMORY,
DB_OP,
} from "./db.ts";
import {
ANALOG_CENTER,
ANIMATABLE,
Expand Down Expand Up @@ -462,6 +470,30 @@ export function generateRust(): string {
put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`);
}
put("}");
put("");

// --- db module -----------------------------------------------------------
// The db MODULE's boundary (contracts/spec/db.ts): SQLite behind five
// synchronous ops, mounted as `globalThis.db`, independent of the ui
// surface. A native host implementing it reads these constants; the
// reference implementation is engine/crates/pocket-db.
put("/// DB module boundary (contracts/spec/db.ts — `globalThis.db`).");
put("/// SQLite behind five synchronous ops; rows cross as one JSON line per");
put("/// query() call. The module owns no clock and emits no events.");
put("pub mod db {");
for (const [name, v] of Object.entries(DB_OP)) {
put(` pub const OP_${screaming(name)}: u8 = ${v};`);
}
put(` /// The in-memory database name (private to the handle).`);
put(` pub const MEMORY: &str = ${JSON.stringify(DB_MEMORY)};`);
put(` /// Marker key for a BLOB value inside a row or a parameter list.`);
put(` pub const BLOB_KEY: &str = ${JSON.stringify(DB_BLOB_KEY)};`);
put(` /// Largest integer magnitude that crosses the boundary losslessly.`);
put(` pub const MAX_SAFE_INTEGER: i64 = ${DB_MAX_SAFE_INTEGER};`);
put(` pub const MAX_DATABASES: usize = ${DB_MAX_DATABASES};`);
put(` /// Result-row ceiling per query() call (exceeding it fails the op).`);
put(` pub const MAX_RESULT_ROWS: usize = ${DB_MAX_RESULT_ROWS};`);
put("}");

return L.join("\n") + "\n";
}
Expand Down
9 changes: 9 additions & 0 deletions contracts/spec/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ export const POCKET_CAPABILITIES = defineCapabilityRegistry([
// appends the id to its profile only when its native host ships the module
// (the ring/thread discipline to copy is hosts/psp/src/audio.rs).
"audio.pcm",
// SQLite behind the db module's own namespace (`globalThis.db`,
// contracts/spec/db.ts): five synchronous ops, rows as one JSON line per
// query() call, per-app storage the host confines. Registered ahead of any
// stock TARGET advertising it: the sim host and the engine/crates/pocket-db
// reference core implement and test the whole contract, so apps can already
// declare the requirement and fail admission where the module is absent. A
// device target appends the id to its profile only when its native host
// ships the module.
"data.sqlite",
// Copy/cut/paste round-trips with the OS clipboard.
"host.clipboard",
// The logical viewport is runtime-mutable: the app is told about live
Expand Down
162 changes: 162 additions & 0 deletions docs/DB.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# The DB Module

DB is PocketJS's fourth module (after `ui`, `strike` and `audio`): SQLite
mounted as `globalThis.db` behind five synchronous ops. Like audio it was
written spec-first — the boundary existed before any host code, every host
implements the same pinned protocol, and a developer adding a data feature
extends the spec instead of forking a host. `contracts/spec/db.ts` is
normative; this page is the map.

```
platform storage (POSIX file · LittleFS · memory) Host / substrate
↑ SQLite's own VFS is the port point
db core: SQLite + handle table + statement cache the module
db spec: ops (open, close, exec, query, lastError)
events (none — every op is synchronous)
data contract (JSON value encoding · name rules · ceilings)
frame contract (no module clock; ops complete in the guest turn)
SDK: @pocketjs/framework/db (Database, Statement — the bun:sqlite shape)
app: schema in exec(), rows out of query(), state in tables Guest
```

## The boundary in one page

**Mount.** The module is its own namespace: `globalThis.db`, one method per
op (`DB_OP` codes are the ABI identity, append-only). Capability id
`data.sqlite`. Unlike audio, absence does **not** degrade to a no-op — data
code that silently drops writes is a corruption bug, so the SDK throws where
the namespace is unmounted, and an app declares `data.sqlite` in
`pocket.json` `requires` so admission catches the gap before eval does.

**Ops** (guest → core, all synchronous): `open(name)` → handle,
`close(handle)`, `exec(handle, sql)` → 0/1 for schema and migrations,
`query(handle, sql, argsJson)` → one JSON line
(`{cols, rows, changes, lastInsertRowid}` or `{error}`), and
`lastError(handle)`. Statement caching is **host-side**, keyed by the sql
string — the guest holds no statement handles, so there is nothing to
finalize and nothing to leak.

**Values** cross as JSON: NULL ↔ `null`, INTEGER ↔ number, REAL ↔ number,
TEXT ↔ string, BLOB ↔ `{"$b": "<base64>"}`. Integers beyond 2^53 − 1 and
non-finite REALs **fail the op** instead of losing precision silently —
store money in cents. Booleans bind as 1/0; integer-valued numbers bind as
INTEGER, fractional as REAL (the bun:sqlite convention).

**Storage rule.** `open(name)` is the only path to a database: names are
filename-safe tokens (`DB_NAME_PATTERN`, ≤ 57 chars so `<name>.sqlite`
stays within the fs module's 64-byte segment ceiling) or `:memory:`, and
the host maps a name to a real file under the app's own data root — the reference core
spells that mapping `<data root>/<name>.sqlite`. The database is an
ORDINARY file in the app's home, deliberately visible to a co-mounted fs
module: it is the app's own asset (backup = a file copy), and an app that
overwrites its own database corrupts its own data — the same trust class
as deleting its own files (SQLite fails loudly on a corrupt image). The
guest never sees a path. `ATTACH` — the one SQL statement that names a
file — is refused twice over (engine/crates/pocket-db uses a real SQLite
authorizer for the literal spelling and **`SQLITE_LIMIT_ATTACHED=0`** for
the expression spelling that reaches an authorizer with a NULL filename),
and `load_extension` stays disabled, so the data root stays the sandbox
boundary.

**Ceilings.** `DB_MAX_DATABASES` (4) open handles; `DB_MAX_RESULT_ROWS`
(4096) rows per `query()` call, above which the op fails with "add LIMIT or
aggregate" — an unbounded SELECT on a device heap is a bug surfaced early,
not a workload.

**Frame contract.** The module owns no clock and emits no events: every op
completes inside the guest's single per-tick turn (law 3 unchanged). SQL
time and randomness resolve host-side — deterministic hosts pin them, and a
golden-tested app must not depend on `random()` or 'now'-relative SQL, the
same rule as `Date.now` in guest code.

## The SDK

`@pocketjs/framework/db` is the bun:sqlite shape, so code written against
Bun's built-in SQLite runs against the mounted module unchanged:

```ts
import { Database } from "@pocketjs/framework/db";

const db = new Database("portfolio");
db.exec(`CREATE TABLE IF NOT EXISTS history (
bucket TEXT PRIMARY KEY, total_cents INTEGER NOT NULL
)`);

const insert = db.query("INSERT OR REPLACE INTO history VALUES ($bucket, $cents)");
const record = db.transaction((bucket: string, cents: number) => {
insert.run({ $bucket: bucket, $cents: cents });
});
record("2026-08-06T14:35", 1_532_042);

db.query("SELECT * FROM history ORDER BY bucket DESC LIMIT 288").all();
```

`query()` returns a cached `Statement` (`.get/.all/.values/.run`);
`prepare()` skips the cache; `transaction(fn)` wraps BEGIN/COMMIT with
ROLLBACK on throw and nests as savepoints — batching writes into one
transaction is also the flash-wear discipline on device hosts.

## Host status

| Host | Implementation | Status |
|---|---|---|
| sim (`hosts/sim/db.ts`) | bun:sqlite behind the op namespace, injected via `bootWorld` `extraGlobals` | ships with the test host; `tests/db.test.ts` runs the contract and an oracle comparison against bun:sqlite directly |
| reference core (`engine/crates/pocket-db`) | rusqlite (bundled SQLite) + the ATTACH authorizer, mountable on any `pocket-mod` guest as `globalThis.db` | tested including a live QuickJS guest round-trip |
| consoles / devices | — | a target appends `data.sqlite` to its profile when its native host ships the module; the port point is SQLite's VFS (POSIX on desktop, LittleFS-backed on MCU hosts). ESP-IDF support ships in the crate — see below |

## Adoption path

A device host that wants the module makes three moves, none of which touch
the spec, the SDK, or any app:

1. compile SQLite with the platform toolchain and register a VFS for the
platform filesystem (the desktop default VFS already serves
`Storage::Dir`);
2. mount the namespace beside `ui` — `pocket_db::mount(&guest, module)` on
`pocket-mod` hosts, or the raw-QuickJS spelling of the same five
functions elsewhere;
3. append `data.sqlite` to the target's profile in
`contracts/spec/platforms.ts`.

## ESP32 / ESP-IDF

`pocket-db` carries its own ESP-IDF support behind
`cfg(target_os = "espidf")` — desktop builds never see it:

- **newlib shims** for the POSIX symbols SQLite's syscall table references
but newlib lacks (`geteuid`/`fchmod`/`fchown`/`utimes`/`readlink` no-ops
that are honest on a filesystem with no users or symlinks, and
`nanosleep` routed through `usleep` for the busy handler);
- **the `unix-none` VFS** on open — LittleFS has no fcntl file locks, and
a module instance is its files' only writer — plus flash-friendly
pragmas (`journal_mode=TRUNCATE`, `synchronous=NORMAL`,
`cache_size=-32`).

What the crate cannot carry is the build environment; a firmware adds, in
its `.cargo/config.toml` (values validated on an ESP32-P4, ESP-IDF v5.5.x,
LittleFS workspace — where a 288-row transaction landed in ~0.4 s on
~70–80 KB of heap and survived power cycling):

```toml
[env]
# The vendored sqlite3.c, tuned for the device: temp tables in memory, no
# mmap, no WAL (shared memory), no dynamic extension loading; lstat does
# not exist on newlib and the VFS never follows symlinks anyway.
LIBSQLITE3_FLAGS = "-DSQLITE_TEMP_STORE=3 -DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_MAX_MMAP_SIZE=0 -DSQLITE_OMIT_WAL -DSQLITE_OMIT_LOAD_EXTENSION -Dlstat=stat"
# CC_/AR_/CFLAGS_riscv32imafc_esp_espidf point at the ESP-IDF gcc as usual.
# newlib has no sys/ioctl.h: put an EMPTY sys/ioctl.h in a shim directory
# and add `-isystem <that dir>` to CFLAGS. Setting CFLAGS in [env] replaces
# cargo's derived flags, so repeat the arch flags (-mabi/-march) alongside.
```

`sqlite3_os_init` for the `unix-none` VFS, journal-file creation, and
power-loss recovery all run against ESP-IDF's VFS layer over LittleFS —
no SQLite source patches, no custom VFS to write.

A firmware that brings its own QuickJS embedding depends with
`default-features = false`: that drops the `mount` helper and its
pocket-mod/rquickjs dependency, so the MCU build compiles only the module
core plus SQLite. Verified: `cargo check` for `riscv32imafc-esp-espidf`
compiles the crate and the bundled `libsqlite3.a` clean under this
recipe.
Loading
Loading