Querying
Use the chainable table API for inserts, filters, selection, and safe writes
Query result shape
Most ORM operations return:
type QueryResult<T> = {
success: boolean;
data?: T;
results?: T;
error?: string;
meta?: {
rowsRead?: number;
rowsWritten?: number;
duration?: number;
};
}
The chain is the API
Every operation in kunii is a chain. db.todos is an empty query that means "all rows". Each method either continues the chain (returns the same Table<Row> shape) or terminates it (returns a Promise). There are no shortcuts and no second API. Cardinality always comes from the chain, not the verb:
db.todos.where({ id: 1 }).first(); // one row
db.todos.where({ id: 1 }).update({ done: 1 }); // updates one row (id is unique)
db.todos.where({ done: 0 }).update({ done: 1 }); // updates many rows (broad where)
db.todos.where({ id: 1 }).delete(); // deletes one row
db.todos.where({ done: 1 }).delete(); // deletes many rows
update and delete always require a preceding where to prevent accidental table-wide writes. To affect a single row when the where is broad, narrow it (typically by primary key) or chain .limit(1).
Inserts
await db.todos.insert({ title: 'Hello', done: 0 });
await db.todos.insert([
{ title: 'First' },
{ title: 'Second' },
]);
Fields not present in the schema are skipped when using a schema-aware client.
Reads
const all = await db.todos.many();
const first = await db.todos.first();
const exact = await db.todos.where({ id: 1 }).one(); // errors if not exactly 1
const exists = await db.todos.where({ id: 1 }).exists();
const total = await db.todos.count();
const proCount = await db.todos.where({ tier: 'pro' }).count();
const titles = await db.todos.where({ done: 0 }).distinct('title');
Filtering
The value side of where({ ... }) carries intent in two ways: by shape for the common cases (a primitive means equality, null means IS NULL, an array means IN), and by operator object for everything else.
Value-shape shortcuts
db.todos.where({ done: 0 }) // done = 0
db.todos.where({ deleted_at: null }) // deleted_at IS NULL
db.todos.where({ tier: ['pro', 'enterprise'] }) // tier IN (...)
Bare strings are always equality. There is no auto-LIKE magic. Use { contains }, { like }, or { notLike } to opt into pattern matching.
Operator object
// Comparisons
db.todos.where({ id: { gt: 5 } })
db.todos.where({ age: { gte: 18, lt: 65 } })
db.todos.where({ id: { ne: 3 } })
// Substring / pattern
db.todos.where({ title: { contains: 'urgent' } }) // LIKE %urgent% with %/_ escaped
db.todos.where({ title: { like: '%hello%' } }) // raw LIKE pattern (escape hatch)
db.todos.where({ title: { notLike: '%spam%' } })
// Sets
db.todos.where({ id: { in: [1, 2, 3] } })
db.todos.where({ id: { notIn: [4, 5] } })
// Ranges
db.todos.where({ id: { between: [1, 100] } })
db.todos.where({ priority: { notBetween: [1, 3] } })
// Null
db.todos.where({ deleted_at: { isNull: true } }) // IS NULL
db.todos.where({ verified_at: { isNull: false } }) // IS NOT NULL
Multiple operators on the same column AND together:
db.todos.where({ priority: { gte: 5, lt: 10 } })
// -> priority >= ? AND priority < ?
contains vs like
Use contains when you have a literal needle. The ORM wraps it in %...% and escapes any %/_ in the input so a search for 'discount_50%' matches the literal substring instead of being interpreted as a wildcard. Use like when you want raw LIKE pattern syntax (for example _ single-character wildcards or pre-shaped patterns).
IN scales without limits
{ in: [...] } (and the array value-shape shortcut) compile to a single bound parameter using SQLite's json_each:
SELECT * FROM todos WHERE id IN (SELECT value FROM json_each(?))
This avoids D1's per-statement placeholder limit (around 100), so you can pass arrays of any size.
Empty arrays are predictable
db.todos.where({ id: [] }) // -> WHERE 0 (matches nothing)
db.todos.where({ id: { notIn: [] } }) // -> WHERE 1 (matches everything)
Pass dynamic arrays without a length guard. The result is always sensible.
OR conditions
db.todos.whereAny([
{ done: 0, priority: { gte: 5 } },
{ title: { contains: 'urgent' } },
])
// -> WHERE (done = ? AND priority >= ?) OR (title LIKE ? ESCAPE '\')
whereAny is the canonical OR. It accepts an array of full filters. Each entry is a where-style object whose keys AND together, and the entries OR with each other.
Raw SQL conditions
You can add raw predicates without giving up parameter binding:
db.todos.sql({
query: 'title LIKE ? OR done = ?',
params: ['%hello%', 1],
})
The query builder rejects obvious interpolation and multi-statement patterns. Use ? placeholders and a params array.
Ordering and selection
db.todos.orderBy({ created_at: 'desc' })
db.todos.orderBy([{ done: 'asc' }, { created_at: 'desc' }])
db.todos.orderBy('created_at DESC')
db.todos.select(['id', 'title'])
db.todos.limit(10).offset(20)
Updates and deletes
Both terminals require a preceding where for safety. Cardinality follows the chain:
// Single row (id is unique).
await db.todos.where({ id: 1 }).update({ done: 1 });
await db.todos.where({ id: 1 }).delete();
// Many rows.
await db.todos.where({ done: 0 }).update({ banner_seen: 1 });
await db.todos.where({ done: 1 }).delete();
To affect exactly one row when the where could match many, chain .limit(1):
await db.todos
.where({ done: 0 })
.orderBy({ created_at: 'asc' })
.limit(1)
.update({ in_progress: 1 });
Soft delete
If a table has a deleted_at column, the ORM filters soft-deleted rows by default. Use withDeleted() on the query builder when you need everything:
const allTodos = await db.todos.withDeleted().many();