Skip to content

Commit c4dea0a

Browse files
committed
docs: add dynamic actor SQLite proxy spec
1 parent 51f2e16 commit c4dea0a

1 file changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
# Dynamic Actor SQLite Proxy Spec
2+
3+
## Problem
4+
5+
Dynamic actors run in sandboxed `secure-exec` / `isolated-vm` processes. The current SQLite path requires `@rivetkit/sqlite` WASM to load inside the isolate, which isn't set up and is the wrong direction — we plan to add a native SQLite extension on the host side. Dynamic actors need a way to use `db()` and `db()` from `rivetkit/db` and `rivetkit/db/drizzle` without running WASM in the isolate.
6+
7+
## Approach
8+
9+
Run SQLite on the **host side** and bridge a thin `execute(sql, params) → rows` RPC from isolate → host. The `ActorDriver` already has `overrideRawDatabaseClient()` and `overrideDrizzleDatabaseClient()` hooks designed for this exact purpose. The `DatabaseProvider.createClient()` already checks for overrides before falling back to KV-backed construction.
10+
11+
```
12+
Isolate Host
13+
────── ────
14+
db.execute(sql, args) ──bridge──► host SQLite (per-actor)
15+
◄────────── { rows, columns }
16+
```
17+
18+
One bridge call per query instead of per KV page.
19+
20+
## Architecture
21+
22+
### Host side (manager process)
23+
24+
Each actor gets a dedicated SQLite database file managed by the host. For the file-system driver, this is already done for KV via `#actorKvDatabases` in `FileSystemGlobalState`. The actor's **application database** is a separate SQLite file alongside the KV database.
25+
26+
The host exposes two bridge callbacks to the isolate:
27+
28+
1. **`sqliteExec(actorId, sql, params) → string`** — Executes a SQL statement. Returns JSON-encoded `{ rows: unknown[][], columns: string[] }`. Handles both read and write queries. Params are JSON-serialized across the boundary.
29+
30+
2. **`sqliteBatch(actorId, statements) → string`** — Executes multiple SQL statements in a single bridge call, wrapped in a transaction. Each statement is `{ sql: string, params: unknown[] }`. Returns JSON-encoded array of `{ rows, columns }` per statement. This is critical for migrations and reduces bridge round-trips.
31+
32+
### Isolate side (dynamic actor process)
33+
34+
The isolate-side `actorDriver` (defined in `host-runtime.ts` line 1767) gains:
35+
36+
- `overrideRawDatabaseClient(actorId)` — Returns a `RawDatabaseClient` whose `exec()` method calls through the bridge to `sqliteExec`.
37+
- `overrideDrizzleDatabaseClient(actorId)` — Returns a drizzle `sqlite-proxy` instance whose async callback calls through the bridge to `sqliteExec`.
38+
39+
Because the overrides are set, `DatabaseProvider.createClient()` in both `db/mod.ts` and `db/drizzle/mod.ts` will use them instead of trying to construct a KV-backed WASM SQLite. No `createSqliteVfs()` is needed in the dynamic actor driver.
40+
41+
## Detailed Changes
42+
43+
### 1. Bridge contract (`src/dynamic/runtime-bridge.ts`)
44+
45+
Add new bridge global keys:
46+
47+
```typescript
48+
export const DYNAMIC_HOST_BRIDGE_GLOBAL_KEYS = {
49+
// ... existing keys ...
50+
sqliteExec: "__rivetkitDynamicHostSqliteExec",
51+
sqliteBatch: "__rivetkitDynamicHostSqliteBatch",
52+
} as const;
53+
```
54+
55+
### 2. Host-side SQLite pool (`src/drivers/file-system/global-state.ts`)
56+
57+
Add a **per-actor application database** map alongside the existing KV database map:
58+
59+
```typescript
60+
#actorAppDatabases = new Map<string, SqliteRuntimeDatabase>();
61+
```
62+
63+
Add methods:
64+
65+
```typescript
66+
#getOrCreateActorAppDatabase(actorId: string): SqliteRuntimeDatabase
67+
// Opens/creates a SQLite database file at: <storagePath>/app-databases/<actorId>.db
68+
// Separate from the KV database. Enables WAL mode for concurrency.
69+
70+
#closeActorAppDatabase(actorId: string): void
71+
// Called during actor teardown, alongside #closeActorKvDatabase.
72+
73+
sqliteExec(actorId: string, sql: string, params: unknown[]): { rows: unknown[][], columns: string[] }
74+
// Runs a single statement against the actor's app database.
75+
// Uses the same SqliteRuntime (bun:sqlite / better-sqlite3) already loaded.
76+
// Synchronous — native SQLite is sync, the bridge async wrapper handles the rest.
77+
78+
sqliteBatch(actorId: string, statements: { sql: string, params: unknown[] }[]): { rows: unknown[][], columns: string[] }[]
79+
// Wraps all statements in BEGIN/COMMIT. Returns results per statement.
80+
```
81+
82+
Cleanup: extend `#destroyActorData` and actor teardown to also close and delete app databases.
83+
84+
### 3. Host bridge wiring — `isolated-vm` path (`src/dynamic/isolate-runtime.ts`)
85+
86+
In `#setIsolateBridge()` (around line 880), add refs for the new bridge callbacks:
87+
88+
```typescript
89+
const sqliteExecRef = makeRef(
90+
async (actorId: string, sql: string, paramsJson: string): Promise<{ copy(): string }> => {
91+
const params = JSON.parse(paramsJson);
92+
const result = this.#config.globalState.sqliteExec(actorId, sql, params);
93+
return makeExternalCopy(JSON.stringify(result));
94+
},
95+
);
96+
97+
const sqliteBatchRef = makeRef(
98+
async (actorId: string, statementsJson: string): Promise<{ copy(): string }> => {
99+
const statements = JSON.parse(statementsJson);
100+
const results = this.#config.globalState.sqliteBatch(actorId, statements);
101+
return makeExternalCopy(JSON.stringify(results));
102+
},
103+
);
104+
105+
await context.global.set(DYNAMIC_HOST_BRIDGE_GLOBAL_KEYS.sqliteExec, sqliteExecRef);
106+
await context.global.set(DYNAMIC_HOST_BRIDGE_GLOBAL_KEYS.sqliteBatch, sqliteBatchRef);
107+
```
108+
109+
### 4. Host bridge wiring — `secure-exec` path (`src/dynamic/host-runtime.ts`)
110+
111+
In `#setIsolateBridge()` (around line 586), add the same refs using the same base64/JSON bridge pattern already used for KV:
112+
113+
```typescript
114+
const sqliteExecRef = makeRef(
115+
async (actorId: string, sql: string, paramsJson: string): Promise<string> => {
116+
const params = JSON.parse(paramsJson);
117+
const result = this.#config.globalState.sqliteExec(actorId, sql, params);
118+
return JSON.stringify(result);
119+
},
120+
);
121+
// ... same for sqliteBatch
122+
123+
await context.global.set("__dynamicHostSqliteExec", sqliteExecRef);
124+
await context.global.set("__dynamicHostSqliteBatch", sqliteBatchRef);
125+
```
126+
127+
And on the isolate-side `actorDriver` object (line 1767), add:
128+
129+
```typescript
130+
const actorDriver = {
131+
// ... existing methods ...
132+
133+
async overrideRawDatabaseClient(actorIdValue) {
134+
return {
135+
exec: async (query, ...args) => {
136+
const resultJson = await bridgeCall(
137+
globalThis.__dynamicHostSqliteExec,
138+
[actorIdValue, query, JSON.stringify(args)]
139+
);
140+
const { rows, columns } = JSON.parse(resultJson);
141+
return rows.map((row) => {
142+
const obj = {};
143+
for (let i = 0; i < columns.length; i++) {
144+
obj[columns[i]] = row[i];
145+
}
146+
return obj;
147+
});
148+
},
149+
};
150+
},
151+
152+
async overrideDrizzleDatabaseClient(actorIdValue) {
153+
// Return undefined — let the raw override handle it.
154+
// Drizzle provider will fall back to using the raw override path.
155+
return undefined;
156+
},
157+
};
158+
```
159+
160+
### 5. Drizzle support
161+
162+
The drizzle `DatabaseProvider` in `db/drizzle/mod.ts` currently does NOT check for overrides — it always constructs a KV-backed WASM database. This needs to change.
163+
164+
Add an override check at the top of `createClient`:
165+
166+
```typescript
167+
createClient: async (ctx) => {
168+
// Check for drizzle override first
169+
if (ctx.overrideDrizzleDatabaseClient) {
170+
const override = await ctx.overrideDrizzleDatabaseClient();
171+
if (override) {
172+
// Wrap with RawAccess execute/close methods and return
173+
return Object.assign(override, {
174+
execute: async (query, ...args) => { /* delegate to override */ },
175+
close: async () => {},
176+
});
177+
}
178+
}
179+
180+
// Check for raw override — build drizzle sqlite-proxy on top of it
181+
if (ctx.overrideRawDatabaseClient) {
182+
const rawOverride = await ctx.overrideRawDatabaseClient();
183+
if (rawOverride) {
184+
const callback = async (sql, params, method) => {
185+
const rows = await rawOverride.exec(sql, ...params);
186+
if (method === "run") return { rows: [] };
187+
if (method === "get") return { rows: rows[0] ? Object.values(rows[0]) : undefined };
188+
return { rows: rows.map(r => Object.values(r)) };
189+
};
190+
const client = proxyDrizzle(callback, config);
191+
return Object.assign(client, {
192+
execute: async (query, ...args) => rawOverride.exec(query, ...args),
193+
close: async () => {},
194+
});
195+
}
196+
}
197+
198+
// Existing KV-backed path...
199+
}
200+
```
201+
202+
This lets dynamic actors use `db()` from `rivetkit/db/drizzle` with migrations working through the bridge. The host runs the actual SQL; the isolate just sends strings.
203+
204+
### 6. Migrations
205+
206+
Drizzle inline migrations (`runInlineMigrations`) currently operate on the `@rivetkit/sqlite` `Database` WASM instance directly. For the proxy path, migrations need to run through the same `execute()` bridge.
207+
208+
Option A (simpler): The raw override's `exec()` already supports multi-statement SQL via the host's `db.exec()`. Migrations can use `execute()` directly. The `sqliteBatch` bridge method handles transactional migration application.
209+
210+
Option B: Add a dedicated `sqliteMigrate(actorId, migrationSql[])` bridge call that runs all migrations in a single transaction on the host. Cleaner but more surface area.
211+
212+
**Recommendation**: Option A. The `execute()` path is sufficient. The drizzle provider's `onMigrate` can call `client.execute(migrationSql)` for each pending migration, same as it does today but through the bridge.
213+
214+
### 7. Engine driver (`src/drivers/engine/actor-driver.ts`)
215+
216+
The engine driver manages dynamic actors the same way. It needs the same `sqliteExec` / `sqliteBatch` bridge wiring, backed by whatever storage the engine provides for actor application databases.
217+
218+
For now, this can be deferred — the engine driver can continue using the KV-backed path for static actors and throw a clear error for dynamic actors that try to use `db()` until the engine-side SQLite proxy is implemented.
219+
220+
## Data model
221+
222+
Each dynamic actor gets TWO SQLite databases on the host:
223+
224+
| Database | Purpose | Path | Managed by |
225+
|----------|---------|------|------------|
226+
| KV database | Actor KV state (`kvBatchPut`/`kvBatchGet`) | `<storage>/databases/<actorId>.db` | Existing `#actorKvDatabases` |
227+
| App database | User-defined schema via `db()` / drizzle | `<storage>/app-databases/<actorId>.db` | New `#actorAppDatabases` |
228+
229+
On actor destroy, both databases are deleted. On actor sleep, both databases are closed (and reopened on wake).
230+
231+
## Serialization format
232+
233+
All data crosses the bridge as JSON strings:
234+
235+
- **Params**: `JSON.stringify(args)` — supports `null`, `number`, `string`, `boolean`. Binary (`Uint8Array`) params are base64-encoded.
236+
- **Results**: `JSON.stringify({ rows: unknown[][], columns: string[] })` — column-oriented format, same as `@rivetkit/sqlite`'s `query()` return shape.
237+
- **Batch**: Array of the above per statement.
238+
239+
## Error handling
240+
241+
- SQL errors on the host throw through the bridge. The isolate receives the error message and stack trace as a rejected promise.
242+
- If the actor's app database doesn't exist yet, `sqliteExec` creates it on first use (same lazy-open pattern as KV databases).
243+
- Invalid SQL, constraint violations, etc. surface as normal SQLite errors to the actor code.
244+
245+
## Testing
246+
247+
Add a driver test in `src/driver-test-suite/tests/` that:
248+
249+
1. Creates a dynamic actor that uses `db()` (raw) with a simple schema
250+
2. Runs migrations, inserts rows, queries them back
251+
3. Verifies data persists across actor sleep/wake cycles
252+
4. Creates a dynamic actor that uses `db()` from `rivetkit/db/drizzle` with schema + migrations
253+
5. Verifies drizzle queries work through the proxy
254+
255+
Add corresponding fixture actors in `fixtures/driver-test-suite/`.
256+
257+
## Files to modify
258+
259+
| File | Change |
260+
|------|--------|
261+
| `src/dynamic/runtime-bridge.ts` | Add `sqliteExec`, `sqliteBatch` bridge keys |
262+
| `src/drivers/file-system/global-state.ts` | Add `#actorAppDatabases`, `sqliteExec()`, `sqliteBatch()`, cleanup |
263+
| `src/dynamic/isolate-runtime.ts` | Wire `sqliteExec`/`sqliteBatch` refs in `#setIsolateBridge()` |
264+
| `src/dynamic/host-runtime.ts` | Wire bridge refs + add `overrideRawDatabaseClient` to isolate-side `actorDriver` |
265+
| `src/db/drizzle/mod.ts` | Add override check at top of `createClient` |
266+
| `src/driver-test-suite/tests/` | New test file for dynamic SQLite proxy |
267+
| `fixtures/driver-test-suite/` | New fixture actors using `db()` in dynamic actors |
268+
| `docs-internal/rivetkit-typescript/DYNAMIC_ACTORS_ARCHITECTURE.md` | Document SQLite proxy bridge |
269+
270+
## Non-goals
271+
272+
- Running WASM SQLite inside the isolate.
273+
- Implementing this for the engine driver (deferred until engine-side app database support exists).
274+
- Shared/cross-actor databases.
275+
- Direct filesystem access from the isolate.

0 commit comments

Comments
 (0)