Skip to content

Structured Storage

Structured Storage gives your App a type-safe relational database — the place where your structured records live (products, orders, posts, anything with columns and rows). You reach it through sdk.db, which is a thin wrapper over Drizzle ORM.

By default every App uses SQLite — backed by Cloudflare D1 in production and a local SQLite file in development. Same API, no configuration change. If your App needs Postgres or MySQL instead, that is a platform-level choice the Anyknown App settings make for you; your handler code stays identical.

You don’t write the schema by hand. You describe your data model to the Builder in plain language, and the AI authors the Drizzle schema for the active driver via its generate_db_schema tool. The rest of this page documents the SDK surface you call against that schema.

CallShapeReturns
db(schema)callable form — SQLite shorthanda Drizzle SQLite instance
db.forSqlite(schema)explicit SQLite accessora Drizzle SQLite instance
db.forPostgres(schema)explicit Postgres accessor (async)Promise<PgDrizzle>
db.forMysql(schema)explicit MySQL accessor (async)Promise<MysqlDrizzle>
db.tx(schema, fn)run fn inside a transactionPromise<T>
db.introspect()inspect the live table structurePromise<DbIntrospection>

You reach these through the SDK inside a handler:

const sdk = c.get("sdk")
const products = await sdk.db(schema).query.products.findMany()

The callable form sdk.db(schema) is the common path. It targets the SQLite/D1 driver, returns a Drizzle instance directly (synchronous — no await on the accessor itself), and is what the vast majority of Apps use. From there you have the full Drizzle query API.

const sdk = c.get("sdk")
const db = sdk.db(schema)
const products = await db.query.products.findMany({
where: (p, { eq }) => eq(p.published, true),
})
await db.insert(schema.products).values({ name: "Widget", priceCents: 1200 })

sdk.db(schema) is exactly sdk.db.forSqlite(schema) — the callable form is just shorthand for the dominant case.

When the platform has selected Postgres or MySQL for your App, use the matching accessor. Unlike the SQLite shorthand, the Postgres and MySQL accessors are async — you await the accessor before querying, because the connection is established lazily.

// Postgres
const pgDb = await sdk.db.forPostgres(schema)
const rows = await pgDb.query.products.findMany()
// MySQL
const myDb = await sdk.db.forMysql(schema)
const rows = await myDb.query.products.findMany()

Each accessor only works on the App’s active driver. Calling forPostgres on an App running the SQLite driver (or forSqlite on a Postgres/MySQL App) throws loudly rather than silently falling back — see Things to know.

Reach for the explicit accessors only when your code must target one dialect on purpose (for example, to use a Postgres-specific JSONB operator). For everything else, the callable form keeps handlers clean and dialect-agnostic: the same sdk.db(schema).query.products.findMany() handler runs on SQLite in development and can run on Postgres in production with no code change.

db.tx runs a function inside a transaction. Every write inside the callback commits together, or none of them do.

db.tx<T, TSchema>(
schema: TSchema,
fn: (txDb: AnyDrizzle<TSchema>) => Promise<T>,
): Promise<T>
const sdk = c.get("sdk")
const order = await sdk.db.tx(schema, async (txDb) => {
await txDb.insert(schema.orders).values({ productId, quantity })
await txDb
.update(schema.inventory)
.set({ remaining: sql`${schema.inventory.remaining} - ${quantity}` })
.where(eq(schema.inventory.productId, productId))
return { ok: true }
})

Interactive transactions need a non-D1 driver in production. On the default SQLite driver, db.tx(...) works in local development (libsql) but throws in the cloud, because the D1 REST API does not support interactive transactions. If transactional writes are critical to your App, select Postgres or MySQL as the driver. Local dev (libsql) supports transactions regardless.

db.introspect() returns a plain description of your live tables and columns. It needs no Drizzle schema import — it is the right tool when your App must reason about its own structure dynamically.

db.introspect(): Promise<DbIntrospection>
type DbIntrospection = {
tables: {
name: string
columns: { name: string; type: string; nullable: boolean }[]
}[]
}
const sdk = c.get("sdk")
const { tables } = await sdk.db.introspect()
// → [{ name: "products", columns: [{ name: "id", type: "integer", nullable: false }, ...] }, ...]

Introspection skips internal bookkeeping tables, so you only see the tables your App actually defines.

You do not change a single line of App code to switch drivers — the choice happens in the platform settings for your App, and the connection string is configured there. The driver is selected by the platform through the DB_DRIVER binding; your handler never reads it. Available drivers:

DriverBacking storeNotes
SQLiteCloudflare D1 (default)synchronous callable form; local dev uses libsql
Postgrespostgres-jsconnection string set in App settings
MySQLmysql2connection string set in App settings

When no driver is configured, your App uses the SQLite/D1 default. The connection strings for Postgres and MySQL are managed for you in the App settings — you don’t set a secret name by hand, and there are no secrets to configure for the SQLite default.

An optional read replica is also configurable in the App settings (Postgres/MySQL only). When enabled, reads and introspection use the replica while writes always go to the primary.

These are the details that most often trip people up.

  • Each accessor only works on the active driver. db.forPostgres(schema) on a SQLite App — or db.forSqlite(schema) on a Postgres/MySQL App — throws NotAvailableInV1Error rather than silently switching. Use the accessor that matches your App’s configured driver, or use the callable shorthand on SQLite Apps.

  • Cloud transactions need Postgres or MySQL. db.tx(...) works in local development on every driver, but in production the SQLite/D1 driver cannot run interactive transactions. This is a D1 REST limitation, not an SDK one. If you need transactional writes in production, choose Postgres or MySQL.

  • Schema evolution is additive. When your data model grows, the platform applies ALTER TABLE ADD COLUMN to your existing tables. It never runs destructive DDL on a deployed App, so adding fields is safe and does not drop data.

  • Identity requires the SQLite driver. The Identity capability (sdk.auth) stores its tables in SQLite. An App that enables auth and sets the driver to Postgres or MySQL fails loudly — auth and a non-SQLite driver cannot coexist. If your App uses login, keep the default SQLite driver.

  • Let the Builder author your schema. The Builder’s generate_db_schema tool writes a dialect-correct Drizzle schema for your active driver from your description. Hand-authoring schema is possible, but only do it if you know Drizzle — otherwise describe what you need and let the AI write it.

  • The driver is invisible to your queries. Whatever store the platform selects, sdk.db always surfaces the same Drizzle ORM, and the Postgres/MySQL client libraries never enter your App’s edge bundle. Your handler stays the same and your App stays edge-slim.