Skip to content
renkudocs
GuidesServer data

Server data

renku/data provides typed CRUD, conditional revisions, named indexes and synchronous transactions over one object's SQLite storage. Apps own object classes, partition keys and authorization. This public demo runs a fixed synthetic scenario and deletes its task before committing.

On this page
Run a fixed CRUD example in a SQLite-backed Durable Object.

Use required string, boolean, safe-integer and finite-number fields; field.nullable permits null, never omission. Supply one immutable string primary key. Schemas own named indexes; use { fields: ["title"], unique: true } for a unique index over non-nullable fields.

All writes validate at runtime. parse(tableDefinition, unknown) produces a typed input without coercion. Share Row<typeof definition> with type-only imports; runtime renku/data imports are server-only.

src/data-object.ts
import { DurableObject } from "cloudflare:workers";
import { defineSchema, field, openStore, table } from "renku/data";

const schema = defineSchema({
  name: "docsExample",
  tables: {
    tasks: table({
      fields: { id: field.string(), title: field.string(), done: field.boolean() },
      primaryKey: "id",
      indexes: { byDone: ["done"] },
    }),
  },
});

/** Public, fixed-input demonstration. No user-selected IDs or private records. */
export class DataExample extends DurableObject {
  private store = openStore(this.ctx.storage, schema);

  async demonstrate() {
    return this.store.transaction((tx) => {
      const created = tx.tasks.create({ id: "example", title: "Define a schema", done: false });
      const read = tx.tasks.get(created.data.id)!;
      const updated = tx.tasks.update(read.data.id, { done: true }, { ifRevision: read.revision });
      const listed = tx.tasks.list({ index: "byDone", equals: { done: true } });
      const deleted = tx.tasks.delete(updated.data.id, { ifRevision: updated.revision });
      return { created, read, updated, listed, deleted, remaining: tx.tasks.get(updated.data.id) };
    });
  }
}

Enable nodejs_compat and declare SQLite storage. Routed apps use workerExports to expose named classes from .ts modules. Apps with a custom worker.entrypoint export their classes directly. For a class in the same Worker, use the automatic namespace in exports from cloudflare:workers.

It stays local to the deployment, including previews; an explicit named Worker binding refers to that named Worker.

renku.config.ts
import {defineConfig} from "renku";
export default defineConfig({
  worker: {
    name: "example", compatibilityDate: "2026-10-09",
    compatibilityFlags: ["nodejs_compat"],
    exports: {DataExample: {type: "durable-object", storage: "sqlite"}},
  },
  workerExports: {DataExample: "./src/data-object.ts"},
  routes: [{index: true, page: "./src/page.tsrx"}],
});

A .server.ts function selects an authorized object and calls an app-owned method. Native RPC results carry lifecycle metadata: clone data before returning through a data-only server function, and dispose the native result.

The demo method intentionally accepts no arguments and operates only on synthetic data. In your application authenticate and authorize every exported operation; identifiers are not permissions. Check mutable object-owned policies in the same transaction as the write.

src/data-example.server.ts
import { exports as workerExports } from "cloudflare:workers";
import type { DataExample } from "./data-object.ts";

export async function runDataExample() {
  const { DataExample: namespace } = workerExports as {
    DataExample: DurableObjectNamespace<DataExample>;
  };
  // Deliberately public policy: this method only operates on fixed synthetic data.
  // Application CRUD must authorize every call before choosing an object.
  using result = await namespace.getByName("public-fixed-scenario").demonstrate();
  // Native RPC lifecycle metadata must not cross the data-only server-function boundary.
  return structuredClone(result);
}

get returns {data, revision} or null. create rejects duplicate keys and unique values. update and delete require {ifRevision}; a stale revision fails with CONFLICT, a missing row with NOT_FOUND.

Patches must be nonempty and cannot change the primary key; undefined is invalid. transaction commits synchronous operations across tables together or rolls them back on throw.

Async callbacks, nested transactions, outer-store access during a transaction and escaped transaction handles are rejected. openStore(storage, schema, {maxTransactionMutations}) can raise the default 100-write transaction budget up to 10,000; row and page limits remain unchanged.

Each mutation uses a savepoint so a caught constraint error leaves no partial write. No mutation is retried automatically; a lost response may follow a committed write. Cloudflare output gates protect external acknowledgements; storage.sync() is the explicit persistence barrier.

list supports primary-key order or a named index and equality on a leading index prefix. Ascending order uses SQLite binary strings, NULL first, and the primary key as a tie-breaker.

Pages default to 50 records, maximum 200, with a 1 MiB serialized response cap. nextCursor is signed and scoped to this object incarnation, schema, table, index and filter. It is not authorization. Pagination is live, not a snapshot: concurrent changes can move rows.

There are no arbitrary predicates, joins or cross-object transactions.

Records are at most 64 KiB encoded JSON; transactions default to 100 attempted mutations, configurable up to 10,000. Schemas allow 32 tables, 64 fields per table, 16 indexes per table and 8 fields per index.

  • Identifiers use 1–63 ASCII letters/digits/underscores starting with a letter; table names cannot collide with store methods or Object properties. Strings must be well-formed Unicode. StoreError exposes code, operation and optional field path without stored values or SQL.
  • Codes: VALIDATION, ALREADY_EXISTS, UNIQUE_CONSTRAINT, NOT_FOUND, CONFLICT, SCHEMA_MISMATCH, INVALID_CURSOR, LIMIT_EXCEEDED, STORAGE_FAILURE, TRANSACTION_STATE. store.diagnostics returns cumulative SQL statements, rows read and rows written, including rolled-back attempts.
  • Server functions redact ordinary exceptions; explicitly map only approved errors into public results.

renku dev persists local resources under the app's .cloudflare/state across builds and restarts. Stop other dev sessions before running renku dev --reset-data to clear that app's local resource state.

Reopening an unchanged schema preserves records and cursors; incompatible schema fingerprints fail before writes. There are no automatic migrations or resets. One store spans multiple tables per object.

Production schema evolution, restore coordination, durable retry receipts, subscriptions and browser stores are outside this first capability.

You can define a validated SQLite store and expose authorized operations with conditional writes.

Continue to local-first data

← All guides · Explore the docs source (access required) →