Skip to content

Commit 175942b

Browse files
committed
feat: US-018 - Tighten runtime SQL boundary types
1 parent 4966b21 commit 175942b

9 files changed

Lines changed: 243 additions & 32 deletions

File tree

rivetkit-typescript/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
- Select runtime behavior from `CoreRuntime.kind`, not `instanceof` adapter classes; NAPI maps to the native runtime kind and wasm maps to wasm.
1818
- Keep `CoreRuntime` SQL methods on the portable `RuntimeSql*` structs from `packages/rivetkit/src/registry/runtime.ts`; NAPI-only `Buffer` conversion belongs inside `NapiCoreRuntime`.
19+
- Keep `RuntimeSqlBindParam` variants exact and `RuntimeSqlExecuteResult.route` limited to `read`, `write`, or `writeFallback`; normalize generated adapter output before returning it.
1920
- Keep `CoreRuntime` byte payloads on `RuntimeBytes`/`Uint8Array`; NAPI-only `Buffer` conversion belongs inside `NapiCoreRuntime`.
2021
- Shared actor glue in `packages/rivetkit/src/registry/native.ts` should construct `RuntimeBytes`/`Uint8Array`; leave `Buffer` creation to `NapiCoreRuntime`.
2122
- Wasm bindings for NAPI-supported runtime APIs should forward to `rivetkit-core`; avoid placeholder returns that break runtime parity.

rivetkit-typescript/packages/rivetkit/src/common/database/native-database.test.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,18 +147,15 @@ describe("wrapJsNativeDatabase", () => {
147147
blob,
148148
]);
149149

150-
expect(native.executeCalls[0]?.params).toMatchObject([
150+
expect(native.executeCalls[0]?.params).toEqual([
151151
{ kind: "int", intValue: 1 },
152152
{ kind: "int", intValue: 1 },
153153
{ kind: "text", textValue: "text" },
154154
{ kind: "float", floatValue: 1.5 },
155155
{ kind: "null" },
156156
{ kind: "null" },
157-
{ kind: "blob" },
157+
{ kind: "blob", blobValue: Buffer.from(blob) },
158158
]);
159-
const blobParam = native.executeCalls[0]?.params?.[6];
160-
expect(blobParam?.blobValue).toBeInstanceOf(Uint8Array);
161-
expect(Array.from(blobParam?.blobValue ?? [])).toEqual([1, 2, 3]);
162159

163160
native.resolveNext({ columns: ["value"], rows: [[1]] });
164161

@@ -168,6 +165,30 @@ describe("wrapJsNativeDatabase", () => {
168165
});
169166
});
170167

168+
test("normalizes native execute routes and rejects unsupported routes", async () => {
169+
const native = new FakeNativeDatabase();
170+
const db = wrapJsNativeDatabase(native);
171+
172+
const read = db.execute("SELECT 1");
173+
native.resolveNext({ route: "read" });
174+
await expect(read).resolves.toMatchObject({ route: "read" });
175+
176+
const write = db.execute("INSERT INTO test VALUES (1)");
177+
native.resolveNext({ route: "write" });
178+
await expect(write).resolves.toMatchObject({ route: "write" });
179+
180+
const fallback = db.execute("SELECT last_insert_rowid()");
181+
await expect(fallback).resolves.toMatchObject({
182+
route: "writeFallback",
183+
});
184+
185+
const unsupported = db.execute("SELECT 2");
186+
native.resolveNext({ route: "custom" });
187+
await expect(unsupported).rejects.toThrow(
188+
"unsupported sqlite execute route: custom",
189+
);
190+
});
191+
171192
test("close waits for admitted native calls and rejects new work", async () => {
172193
const native = new FakeNativeDatabase();
173194
const db = wrapJsNativeDatabase(native);

rivetkit-typescript/packages/rivetkit/src/common/database/native-database.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,43 @@ import type {
55
SqliteExecuteResult,
66
} from "./config";
77

8-
interface NativeBindParam {
9-
kind: "null" | "int" | "float" | "text" | "blob";
10-
intValue?: number;
11-
floatValue?: number;
12-
textValue?: string;
13-
blobValue?: Buffer;
14-
}
8+
type NativeBindNoValues = {
9+
intValue?: never;
10+
floatValue?: never;
11+
textValue?: never;
12+
blobValue?: never;
13+
};
14+
15+
type NativeBindParam =
16+
| ({ kind: "null" } & NativeBindNoValues)
17+
| {
18+
kind: "int";
19+
intValue: number;
20+
floatValue?: never;
21+
textValue?: never;
22+
blobValue?: never;
23+
}
24+
| {
25+
kind: "float";
26+
intValue?: never;
27+
floatValue: number;
28+
textValue?: never;
29+
blobValue?: never;
30+
}
31+
| {
32+
kind: "text";
33+
intValue?: never;
34+
floatValue?: never;
35+
textValue: string;
36+
blobValue?: never;
37+
}
38+
| {
39+
kind: "blob";
40+
intValue?: never;
41+
floatValue?: never;
42+
textValue?: never;
43+
blobValue: Buffer;
44+
};
1545

1646
interface NativeExecResult {
1747
columns: string[];

rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type {
3838
RuntimeWebSocketEvent,
3939
WebSocketHandle,
4040
} from "./runtime";
41+
import { normalizeRuntimeSqlExecuteResult } from "./runtime";
4142

4243
type NativeBindings = typeof import("@rivetkit/rivetkit-napi");
4344
type NapiSqlDatabase = ReturnType<NativeActorContext["sql"]>;
@@ -80,13 +81,18 @@ function asActorFactoryHandle(handle: NativeActorFactory): ActorFactoryHandle {
8081
function toNapiSqlBindParam(
8182
param: RuntimeSqlBindParam,
8283
): NonNullable<NapiSqlBindParams>[number] {
83-
return {
84-
kind: param.kind,
85-
intValue: param.intValue,
86-
floatValue: param.floatValue,
87-
textValue: param.textValue,
88-
blobValue: param.blobValue ? Buffer.from(param.blobValue) : undefined,
89-
};
84+
switch (param.kind) {
85+
case "null":
86+
return { kind: "null" };
87+
case "int":
88+
return { kind: "int", intValue: param.intValue };
89+
case "float":
90+
return { kind: "float", floatValue: param.floatValue };
91+
case "text":
92+
return { kind: "text", textValue: param.textValue };
93+
case "blob":
94+
return { kind: "blob", blobValue: Buffer.from(param.blobValue) };
95+
}
9096
}
9197

9298
function toNapiSqlBindParams(params?: RuntimeSqlBindParams): NapiSqlBindParams {
@@ -521,21 +527,23 @@ export class NapiCoreRuntime implements CoreRuntime {
521527
sql: string,
522528
params?: RuntimeSqlBindParams,
523529
): Promise<RuntimeSqlExecuteResult> {
524-
return await this.#actorSql(ctx).execute(
530+
const result = await this.#actorSql(ctx).execute(
525531
sql,
526532
toNapiSqlBindParams(params),
527533
);
534+
return normalizeRuntimeSqlExecuteResult(result);
528535
}
529536

530537
async actorSqlExecuteWrite(
531538
ctx: ActorContextHandle,
532539
sql: string,
533540
params?: RuntimeSqlBindParams,
534541
): Promise<RuntimeSqlExecuteResult> {
535-
return await this.#actorSql(ctx).executeWrite(
542+
const result = await this.#actorSql(ctx).executeWrite(
536543
sql,
537544
toNapiSqlBindParams(params),
538545
);
546+
return normalizeRuntimeSqlExecuteResult(result);
539547
}
540548

541549
async actorSqlQuery(
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from "vitest";
2+
import {
3+
normalizeRuntimeSqlExecuteResult,
4+
type RuntimeSqlBindParam,
5+
type RuntimeSqlBindParams,
6+
type RuntimeSqlExecuteResult,
7+
} from "./runtime";
8+
9+
describe("runtime SQL boundary", () => {
10+
test("accepts exact bind param variants", () => {
11+
const blob = new Uint8Array([1, 2, 3]);
12+
const params = [
13+
{ kind: "null" },
14+
{ kind: "int", intValue: 1 },
15+
{ kind: "float", floatValue: 1.5 },
16+
{ kind: "text", textValue: "text" },
17+
{ kind: "blob", blobValue: blob },
18+
] satisfies RuntimeSqlBindParams;
19+
20+
expect(params).toEqual([
21+
{ kind: "null" },
22+
{ kind: "int", intValue: 1 },
23+
{ kind: "float", floatValue: 1.5 },
24+
{ kind: "text", textValue: "text" },
25+
{ kind: "blob", blobValue: blob },
26+
]);
27+
});
28+
29+
test("rejects bind params with mismatched value fields at typecheck time", () => {
30+
const invalidIntParamCandidate = {
31+
kind: "int",
32+
intValue: 1,
33+
textValue: "extra",
34+
} as const;
35+
// @ts-expect-error Runtime SQL int params must only carry intValue.
36+
const invalidIntParam: RuntimeSqlBindParam = invalidIntParamCandidate;
37+
38+
expect(invalidIntParam.kind).toBe("int");
39+
});
40+
41+
test("normalizes exact execute result routes", () => {
42+
const base = {
43+
columns: ["value"],
44+
rows: [[1]],
45+
changes: 1,
46+
lastInsertRowId: null,
47+
};
48+
49+
expect(
50+
normalizeRuntimeSqlExecuteResult({ ...base, route: "read" }).route,
51+
).toBe("read");
52+
expect(
53+
normalizeRuntimeSqlExecuteResult({ ...base, route: "write" }).route,
54+
).toBe("write");
55+
expect(
56+
normalizeRuntimeSqlExecuteResult({
57+
...base,
58+
route: "writeFallback",
59+
}).route,
60+
).toBe("writeFallback");
61+
expect(() =>
62+
normalizeRuntimeSqlExecuteResult({ ...base, route: "custom" }),
63+
).toThrow("unsupported runtime sqlite execute route: custom");
64+
});
65+
66+
test("rejects custom execute result routes at typecheck time", () => {
67+
const invalidRouteResultCandidate = {
68+
columns: [],
69+
rows: [],
70+
changes: 0,
71+
route: "custom",
72+
} as const;
73+
// @ts-expect-error Runtime SQL execute routes are exact.
74+
const invalidRouteResult: RuntimeSqlExecuteResult =
75+
invalidRouteResultCandidate;
76+
77+
expect(invalidRouteResult.route).toBe("custom");
78+
});
79+
});

rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,43 @@ export interface RuntimeKvEntry {
109109
value: RuntimeBytes;
110110
}
111111

112-
export interface RuntimeSqlBindParam {
113-
kind: "null" | "int" | "float" | "text" | "blob";
114-
intValue?: number;
115-
floatValue?: number;
116-
textValue?: string;
117-
blobValue?: Uint8Array;
118-
}
112+
type RuntimeSqlBindNoValues = {
113+
intValue?: never;
114+
floatValue?: never;
115+
textValue?: never;
116+
blobValue?: never;
117+
};
118+
119+
export type RuntimeSqlBindParam =
120+
| ({ kind: "null" } & RuntimeSqlBindNoValues)
121+
| {
122+
kind: "int";
123+
intValue: number;
124+
floatValue?: never;
125+
textValue?: never;
126+
blobValue?: never;
127+
}
128+
| {
129+
kind: "float";
130+
intValue?: never;
131+
floatValue: number;
132+
textValue?: never;
133+
blobValue?: never;
134+
}
135+
| {
136+
kind: "text";
137+
intValue?: never;
138+
floatValue?: never;
139+
textValue: string;
140+
blobValue?: never;
141+
}
142+
| {
143+
kind: "blob";
144+
intValue?: never;
145+
floatValue?: never;
146+
textValue?: never;
147+
blobValue: RuntimeBytes;
148+
};
119149

120150
export type RuntimeSqlBindParams = RuntimeSqlBindParam[] | null;
121151

@@ -126,10 +156,34 @@ export interface RuntimeSqlQueryResult {
126156

127157
export type RuntimeSqlExecResult = RuntimeSqlQueryResult;
128158

159+
export type RuntimeSqlExecuteRoute = "read" | "write" | "writeFallback";
160+
129161
export interface RuntimeSqlExecuteResult extends RuntimeSqlQueryResult {
130162
changes: number;
131163
lastInsertRowId?: number | null;
132-
route: string;
164+
route: RuntimeSqlExecuteRoute;
165+
}
166+
167+
export function normalizeRuntimeSqlExecuteRoute(
168+
route: string,
169+
): RuntimeSqlExecuteRoute {
170+
if (route === "read" || route === "write" || route === "writeFallback") {
171+
return route;
172+
}
173+
throw new Error(`unsupported runtime sqlite execute route: ${route}`);
174+
}
175+
176+
export function normalizeRuntimeSqlExecuteResult(
177+
result: RuntimeSqlQueryResult & {
178+
changes: number;
179+
lastInsertRowId?: number | null;
180+
route: string;
181+
},
182+
): RuntimeSqlExecuteResult {
183+
return {
184+
...result,
185+
route: normalizeRuntimeSqlExecuteRoute(result.route),
186+
};
133187
}
134188

135189
export interface RuntimeSqlRunResult {

rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type {
5151
RuntimeWebSocketEvent,
5252
WebSocketHandle,
5353
} from "./runtime";
54+
import { normalizeRuntimeSqlExecuteResult } from "./runtime";
5455

5556
type WasmBindings = WasmRuntimeBindings;
5657
export type WasmInitInput = WasmRuntimeInitInput;
@@ -736,17 +737,21 @@ export class WasmCoreRuntime implements CoreRuntime {
736737
sql: string,
737738
params?: RuntimeSqlBindParams,
738739
): Promise<RuntimeSqlExecuteResult> {
739-
return await callWasm(() => this.#actorSql(ctx).execute(sql, params));
740+
const result = await callWasm(() =>
741+
this.#actorSql(ctx).execute(sql, params),
742+
);
743+
return normalizeRuntimeSqlExecuteResult(result);
740744
}
741745

742746
async actorSqlExecuteWrite(
743747
ctx: ActorContextHandle,
744748
sql: string,
745749
params?: RuntimeSqlBindParams,
746750
): Promise<RuntimeSqlExecuteResult> {
747-
return await callWasm(() =>
751+
const result = await callWasm(() =>
748752
this.#actorSql(ctx).executeWrite(sql, params),
749753
);
754+
return normalizeRuntimeSqlExecuteResult(result);
750755
}
751756

752757
async actorSqlQuery(

scripts/ralph/prd.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@
312312
"Tests pass"
313313
],
314314
"priority": 18,
315-
"passes": false,
315+
"passes": true,
316316
"notes": ""
317317
},
318318
{

0 commit comments

Comments
 (0)