Storage
Egglets store data in tables they declare in their manifest. Reads run against the local database directly. Writes route through the Gateway so update hooks fire and any sync push runs. The Egglet does not need to think about which side handles which call. From its perspective, both go through ctx.storage.
Tables and schemas
Every table the Egglet uses is declared in the manifest’s tables array. There are two kinds of entry. An owned table has a schema path; the host runs the SQL file at activation. A bound table has a physical_name; it points at an existing table the Egglet did not create.
"tables": [
{ "name": "log", "schema": "./schemas/log.sql" },
{ "name": "items", "schema": "./schemas/items.sql", "sync_category": "items" },
{ "name": "feeds", "physical_name": "external_feeds_table" }
]
Schema files contain CREATE statements that must be idempotent, since the host re-runs them on every boot. CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS are the standard pattern.
// src/egglets/myEgglet/schemas/log.sql
CREATE TABLE IF NOT EXISTS {{log}} (
ts INTEGER PRIMARY KEY,
message TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_log_ts ON {{log}}(ts DESC);
The schema file uses the same {{name}} placeholder that runtime SQL uses. The host substitutes the placeholder at every site (schema run, ad-hoc queries, writes), so the Egglet never sees the physical name.
Owned tables are dropped if the user uninstalls the Egglet with the “Purge data” option. Bound tables are never touched by the Egglet’s install lifecycle; they predate it.
Table placeholders
SQL strings reference tables by their logical name wrapped in double curly braces. {{items}} means “the physical table that name: "items" resolves to in this Egglet’s manifest.” The host expands placeholders before sending the SQL to the engine.
SELECT id, title FROM {{items}} WHERE is_read = 0
UPDATE {{items}} SET is_read = 1 WHERE id IN (?, ?, ?)
SQL that references a placeholder for a table not declared in the manifest is rejected. The Egglet cannot read or write tables it has not formally claimed.
Placeholders are not parameter substitution. They name tables, not values. Use parameter placeholders (?) for values, exactly as in plain SQLite. The two systems do not collide.
Reads
Two methods, both mapping to a SELECT. Reads do not require any uses declaration; they are baseline.
ctx.storage.all<T>(sql: string, params?: unknown[]): Promise<T[]> ctx.storage.one<T>(sql: string, params?: unknown[]): Promise<T | null>
all returns every row. one returns the first row or null. The generic parameter shapes the returned objects; the host does no validation, so the Egglet is responsible for matching the type to the columns it selected.
const recent = await ctx.storage.all<{ id: string; ts: number }>(
"SELECT id, ts FROM {{items}} ORDER BY ts DESC LIMIT ?",
[25],
);
const first = await ctx.storage.one<{ count: number }>(
"SELECT COUNT(*) AS count FROM {{items}} WHERE is_read = 0",
);
Writes
Writes use a single method. The Egglet must declare uses.gateway.writes: true in its manifest; calls without that declaration throw.
ctx.storage.exec(sql: string, params?: unknown[]): Promise<{ changes: number; lastInsertRowid: number }>
The result includes the number of rows affected and (for INSERTs) the last inserted rowid. SELECT and DDL are rejected; reads go through all and one, and table creation lives in schema files.
const r = await ctx.storage.exec(
"INSERT INTO {{log}} (ts, message) VALUES (?, ?)",
[Date.now(), "hello"],
);
ctx.log.debug(`inserted, rowid=${r.lastInsertRowid}`);
Writes route through the Gateway connection rather than a renderer-side connection. This is required so that the Gateway’s update hooks fire and any sync push runs. The Egglet does not need to think about this; the SDK handles it transparently.
Sync
Each table can opt into cross-device sync by setting sync_category. The category is a short string scoped to the Egglet that the sync pipeline groups by.
"tables": [
{ "name": "items", "schema": "./schemas/items.sql", "sync_category": "items" }
]
If any table has sync_category, the manifest must also declare uses.gateway.syncs: true; otherwise activation fails. The two go together: opting a table into sync is a Gateway-dependency claim, and the manifest has to say so.
Sync push is automatic. Every storage.exec on a synced table fires the Gateway’s update hook, which queues a push to the user’s paired devices. The Egglet writes once; the platform delivers everywhere.
Sync activity is daemon-driven and currently does not appear in the renderer-side usage rollups. The manifest declaration is what surfaces in Settings; per-table push counters are not yet exposed in the Egglet card.