diff --git a/docs/content/docs/sqlite.mdx b/docs/content/docs/sqlite.mdx
index a0dbcc43f9..5797930d5a 100644
--- a/docs/content/docs/sqlite.mdx
+++ b/docs/content/docs/sqlite.mdx
@@ -83,6 +83,36 @@ const rows = await c.db.execute(
);
```
+### Synchronous operations in Node.js
+
+The Node.js native runtime also provides `c.db.executeSync(...)` and `c.db.transactionSync(...)` for integrations that cannot use an asynchronous API. `executeSync(...)` accepts the same SQL and parameters as `execute(...)`.
+
+
+**Use synchronous SQLite only when absolutely necessary.** Every synchronous operation blocks the entire Node.js runtime until SQLite finishes. This pauses the current actor and can also prevent other actors hosted by the same runtime from running. Prefer `await c.db.execute(...)` and `await c.db.transaction(...)` whenever possible.
+
+Worker-thread actor isolation is coming soon. It will allow each actor to run on its own thread so a synchronous operation in one actor does not block other actors. Even with that isolation, use the synchronous API only when an integration requires it.
+
+
+```ts @nocheck
+const rows = c.db.executeSync(
+ "SELECT id, title FROM todos WHERE title LIKE ?",
+ `%${query}%`,
+);
+```
+
+Use `transactionSync(...)` when multiple synchronous queries must commit or roll back together. The transaction commits before `transactionSync(...)` returns and rolls back if the callback throws.
+
+```ts @nocheck
+const todoId = c.db.transactionSync((tx) => {
+ tx.executeSync("INSERT INTO todos (title) VALUES (?)", title);
+ return tx.executeSync<{ id: number }>(
+ "SELECT last_insert_rowid() AS id",
+ )[0].id;
+});
+```
+
+The callback must be synchronous and must use its `tx` value, which exposes only `executeSync(...)`. It must not return a promise. `{ name, timeout }` options are supported, matching `transaction(...)`. Synchronous operations are unavailable in WebAssembly runtimes.
+
### Transactions
Use transactions when multiple writes must succeed or fail together.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e8d9fcae90..9a24d5c7cb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3806,6 +3806,9 @@ importers:
semver:
specifier: ^7.6.0
version: 7.7.4
+ yaml:
+ specifier: ^2.9.0
+ version: 2.9.0
devDependencies:
'@types/node':
specifier: ^24.3.0
diff --git a/rivetkit-typescript/packages/effect/src/Actor.test-d.ts b/rivetkit-typescript/packages/effect/src/Actor.test-d.ts
index 5be7817214..b1cedaba7e 100644
--- a/rivetkit-typescript/packages/effect/src/Actor.test-d.ts
+++ b/rivetkit-typescript/packages/effect/src/Actor.test-d.ts
@@ -6,7 +6,7 @@ import {
Schema,
SchemaTransformation,
} from "effect";
-import type { RawAccess } from "rivetkit/db";
+import type { SynchronousRawAccess } from "rivetkit/db";
import { db } from "rivetkit/db";
import { describe, expectTypeOf, it, test } from "@effect/vitest";
@@ -287,13 +287,21 @@ describe("Actor.make(...).toLayer", () => {
(wakeOptions) => {
expectTypeOf(
wakeOptions.rawRivetkitContext.db,
- ).toEqualTypeOf();
+ ).toEqualTypeOf();
expectTypeOf(
wakeOptions.rawRivetkitContext.db.transaction(
async () => {},
{ name: "effect-operation", timeout: 1_000 },
),
).toEqualTypeOf>();
+ expectTypeOf(
+ wakeOptions.rawRivetkitContext.db.executeSync<{
+ count: number;
+ }>("SELECT COUNT(*) AS count"),
+ ).toEqualTypeOf<{ count: number }[]>();
+ expectTypeOf(
+ wakeOptions.rawRivetkitContext.db.transactionSync(() => 42),
+ ).toEqualTypeOf();
return {
Ping: () => Effect.succeed(0),
diff --git a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts
index 6e980d29c6..de062a406a 100644
--- a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts
+++ b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts
@@ -379,19 +379,27 @@ export declare class JsNativeDatabase {
run(sql: string, params?: Array | undefined | null): Promise
query(sql: string, params?: Array | undefined | null): Promise
execute(sql: string, params?: Array | undefined | null): Promise
+ executeSync(sql: string, params?: Array | undefined | null): NativeExecuteResult
executeBatch(statements: Array): Promise>
exec(sql: string): Promise
+ execSync(sql: string): QueryResult
close(): Promise
beginTransaction(timeoutMs?: number | undefined | null, name?: string | undefined | null): Promise
+ beginTransactionSync(timeoutMs?: number | undefined | null, name?: string | undefined | null): JsSqliteTransaction
}
export declare class JsSqliteTransaction {
execute(sql: string, params?: Array | undefined | null): Promise
+ executeSync(sql: string, params?: Array | undefined | null): NativeExecuteResult
exec(sql: string): Promise
+ execSync(sql: string): QueryResult
commit(): Promise
+ commitSync(): void
rollback(): Promise
+ rollbackSync(): void
}
export declare class JsActorStateTransaction {
execute(sql: string, params?: Array | undefined | null): Promise
+ executeSync(sql: string, params?: Array | undefined | null): NativeExecuteResult
commit(payload: StateDeltaPayload): Promise
rollback(): Promise
}
diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/database.rs b/rivetkit-typescript/packages/rivetkit-napi/src/database.rs
index fad6e3c0ef..f4d1dfe61e 100644
--- a/rivetkit-typescript/packages/rivetkit-napi/src/database.rs
+++ b/rivetkit-typescript/packages/rivetkit-napi/src/database.rs
@@ -1,4 +1,4 @@
-use std::time::Duration;
+use std::{future::Future, time::Duration};
use crate::actor_context::{StateDeltaPayload, state_deltas_from_payload};
use napi::bindgen_prelude::Buffer;
@@ -176,6 +176,18 @@ impl JsNativeDatabase {
Ok(core_execute_result_to_js(result))
}
+ #[napi]
+ pub fn execute_sync(
+ &self,
+ sql: String,
+ params: Option>,
+ ) -> napi::Result {
+ let params = params.map(js_bind_params_to_core).transpose()?;
+ let db = self.db.clone();
+ wait_for_runtime(async move { db.execute(sql, params).await })
+ .map(core_execute_result_to_js)
+ }
+
#[napi]
pub async fn execute_batch(
&self,
@@ -196,6 +208,12 @@ impl JsNativeDatabase {
Ok(core_query_result_to_js(result))
}
+ #[napi]
+ pub fn exec_sync(&self, sql: String) -> napi::Result {
+ let db = self.db.clone();
+ wait_for_runtime(async move { db.exec(sql).await }).map(core_query_result_to_js)
+ }
+
#[napi]
pub async fn close(&self) -> napi::Result<()> {
self.db.close().await.map_err(crate::napi_anyhow_error)
@@ -215,6 +233,21 @@ impl JsNativeDatabase {
.map_err(crate::napi_anyhow_error)?;
Ok(JsSqliteTransaction { transaction })
}
+
+ #[napi]
+ pub fn begin_transaction_sync(
+ &self,
+ timeout_ms: Option,
+ name: Option,
+ ) -> napi::Result {
+ let timeout = timeout_ms.map(transaction_timeout).transpose()?;
+ let db = self.db.clone();
+ let transaction =
+ wait_for_runtime(
+ async move { db.begin_named_transaction(name.as_deref(), timeout).await },
+ )?;
+ Ok(JsSqliteTransaction { transaction })
+ }
}
#[napi]
@@ -233,6 +266,18 @@ impl JsSqliteTransaction {
.map_err(crate::napi_anyhow_error)
}
+ #[napi]
+ pub fn execute_sync(
+ &self,
+ sql: String,
+ params: Option>,
+ ) -> napi::Result {
+ let params = params.map(js_bind_params_to_core).transpose()?;
+ let transaction = self.transaction.clone();
+ wait_for_runtime(async move { transaction.execute(sql, params).await })
+ .map(core_execute_result_to_js)
+ }
+
#[napi]
pub async fn exec(&self, sql: String) -> napi::Result {
self.transaction
@@ -242,6 +287,12 @@ impl JsSqliteTransaction {
.map_err(crate::napi_anyhow_error)
}
+ #[napi]
+ pub fn exec_sync(&self, sql: String) -> napi::Result {
+ let transaction = self.transaction.clone();
+ wait_for_runtime(async move { transaction.exec(sql).await }).map(core_query_result_to_js)
+ }
+
#[napi]
pub async fn commit(&self) -> napi::Result<()> {
self.transaction
@@ -250,6 +301,12 @@ impl JsSqliteTransaction {
.map_err(crate::napi_anyhow_error)
}
+ #[napi]
+ pub fn commit_sync(&self) -> napi::Result<()> {
+ let transaction = self.transaction.clone();
+ wait_for_runtime(async move { transaction.commit().await })
+ }
+
#[napi]
pub async fn rollback(&self) -> napi::Result<()> {
self.transaction
@@ -257,6 +314,12 @@ impl JsSqliteTransaction {
.await
.map_err(crate::napi_anyhow_error)
}
+
+ #[napi]
+ pub fn rollback_sync(&self) -> napi::Result<()> {
+ let transaction = self.transaction.clone();
+ wait_for_runtime(async move { transaction.rollback().await })
+ }
}
#[napi]
@@ -275,6 +338,18 @@ impl JsActorStateTransaction {
.map_err(crate::napi_anyhow_error)
}
+ #[napi]
+ pub fn execute_sync(
+ &self,
+ sql: String,
+ params: Option>,
+ ) -> napi::Result {
+ let params = params.map(js_bind_params_to_core).transpose()?;
+ let transaction = self.transaction.clone();
+ wait_for_runtime(async move { transaction.execute(sql, params).await })
+ .map(core_execute_result_to_js)
+ }
+
#[napi]
pub async fn commit(&self, payload: StateDeltaPayload) -> napi::Result<()> {
self.transaction
@@ -292,6 +367,23 @@ impl JsActorStateTransaction {
}
}
+fn wait_for_runtime(future: F) -> napi::Result
+where
+ F: Future