Database (ctx.db)
Every Function gets ctx.db, a client for your project's collections. It has eight methods, the same in TypeScript and Dart, and it talks to the same database your apps use.
ctx.db ignores your access rules — your Function must check the user
ctx.db can read and change every record, including collections marked server-only. This is what lets triggers and crons do their work, since they have no user at all.So when a Function acts for a user, call
ctx.db.asUser() so their access rules apply (see Acting as the user). Or check ctx.auth.user_id (null when the call is anonymous) and scope every query and write to it yourself.export async function handler(ctx) {
const userId = ctx.auth.user_id
if (!userId) {
return { error: 'sign in required' }
}
// ctx.db sees every record, so scope the query to this user yourself.
const { records, total } = await ctx.db.find('orders', { owner_id: userId }, 50)
return { orders: records, total }
}Acting as the user: asUser()
ctx.db.asUser() returns the same eight methods, but every call is made as the signed-in caller, with their own session. Your collection's access rules apply exactly as they do in your app: reads return only what that user may see, and writes they aren't allowed to make are refused.
export async function handler(ctx) {
// Access rules apply: only the orders this user may read come back.
const { records } = await ctx.db.asUser().find('orders', {}, 50)
return { orders: records }
}Only when someone signed in called the Function
asUser() works when a signed-in user called the Function from your app, or through another Function that passed their session along. Triggers, crons, the dashboard's Invoke button and anonymous calls have no user, so there asUser() throws. It never falls back to the elevated ctx.db.final db = ctx['db'] as Map<String, dynamic>;
final mine = (db['asUser'] as Function)() as Map<String, dynamic>;
final result = await (mine['find'] as Function)('orders', {}, 50);Methods
get(id)returns the record
Fetches one record by id. Throws with status 404 if it does not exist.
insert(collection, data)returns the new record
Creates a record in a collection.
find(collection, filters = {}, limit = 20, options)returns { records, total }
Returns matching records and the total number that match. Oldest first unless options sort them.
update(id, data, options)returns the updated record
Merges data into one record, found by id. options.expectedRevision makes it conditional.
upsert(collection, match, data)returns { record, created }
Updates the record that matches, or creates one if none does. created is true when it created.
delete(id)returns nothing
Deletes one record by id.
deleteWhere(collection, filters)returns { deleted }
Deletes every record matching filters and returns how many.
batch(operations)returns { results }
Runs several writes in one transaction: all apply, or none do.
Records
A record comes back with your fields at the top level, plus system fields that start with $:
$idThe record's id. Pass it to update, delete, or a batch op.$collectionThe collection the record belongs to.$createdAtWhen the record was created.$updatedAtWhen the record last changed.$revisionIncreases on every change. Pass it as expectedRevision to update, or expected_revision in a batch, to make an update conditional.$createdByWho created the record. Present only when set.Filters
find and deleteWhere take filters as an object of field names and values. They are deliberately simple:
- Exact matches only. Every key must equal its value, and all keys must match. There are no operators: no greater-than, no "in", no contains.
- Compared as text.
{ qty: 5 }matches a stored5, and{ active: true }matchestrue. You can't matchnullor a nested object. - Top-level fields only. Names may contain letters, digits and underscores. A name like
address.cityis rejected with an error, never silently ignored. created_byis special. It matches the record's creator, not a field in your data.
find
const { records, total } = await ctx.db.find('orders', { status: 'open' }, 50)
// records: up to 50 open orders, oldest first
// total: how many open orders exist in alllimit is capped at 100
limit defaults to 20 and is capped at 100: asking for 500 returns at most 100 records. Use total and the offset option to page through more.An optional fourth argument pages and sorts:
offsetSkip this many records first. Default 0.orderBySort by created_at, updated_at or id (the record's own timestamps and id), or by any field name in your data.orderDesctrue for newest or largest first. Default false.const page2 = await ctx.db.find('orders', { status: 'open' }, 50, {
offset: 50,
orderBy: 'created_at',
orderDesc: true,
})update
update merges: the fields you pass are set, and every other field keeps its value. It finds the record by $id alone. Because ctx.db bypasses access rules, confirm the record belongs to the user before changing it:
export async function handler(ctx) {
const userId = ctx.auth.user_id
if (!userId) return { error: 'sign in required' }
const orderNo = ctx.request.body.order_no
const { records } = await ctx.db.find('orders', { order_no: orderNo, owner_id: userId }, 1)
if (records.length === 0) return { error: 'order not found' }
const order = await ctx.db.update(records[0].$id, { status: 'cancelled' })
return { order }
}To make an update conditional, pass the $revision you read as expectedRevision. If the record changed since, nothing is written and it throws with error code revision_mismatch.
const order = await ctx.db.get(orderId)
await ctx.db.update(order.$id, { status: 'shipped' }, { expectedRevision: order.$revision })upsert
Updates the record that matches match, or creates one if nothing matches, and returns{ record, created }. If match finds more than one record, it throws with status 409 rather than guessing which one you meant.
const { record, created } = await ctx.db.upsert('counters', { name: 'orders' }, { value: 1 })deleteWhere
Deletes every record that matches and returns { deleted: n }. Two safeguards apply: empty filters are refused, so you can't wipe a collection by accident, and append-only collections refuse bulk deletes entirely.
const { deleted } = await ctx.db.deleteWhere('sessions', { owner_id: userId })batch
batch runs a list of writes in one transaction. If any of them fails, none of them apply. Each operation is an object with a type:
insertcollection, dataupdaterecord_id, data, and optionally expected_revisiondeleterecord_idupsertcollection, match, data. Its result has created: true when it inserted.An update with expected_revision applies only if the record still has that revision. If someone changed it in the meantime, the whole batch rolls back and throws with error code revision_mismatch. Re-read the record and try again.
const { records } = await ctx.db.find('products', { sku: sku }, 1)
const product = records[0]
try {
const { results } = await ctx.db.batch([
{
type: 'update',
record_id: product.$id,
data: { stock: product.stock - 1 },
expected_revision: product.$revision,
},
{ type: 'insert', collection: 'sales', data: { sku: sku, owner_id: userId } },
])
return { results }
} catch (err) {
if (err.errorCode === 'revision_mismatch') {
return { error: 'stock changed, try again' }
}
throw err
}For a single conditional write, update takes expectedRevision too.
Errors
Every method throws KoolbaseDatabaseException when the request fails: a collection or record that doesn't exist, a unique-constraint clash, a revision mismatch, and so on. In TypeScript it carries:
statusThe HTTP status the database returned.errorCodeA machine-readable code when there is one, such as revision_mismatch.operationWhich method failed: get, find, insert, update, upsert, delete, deleteWhere or batch.collectionThe collection involved, when known.recordIdThe record involved, for update and delete.bareMessageThe error message on its own.Dart
The same eight methods, with the same arguments. Options are maps, such as {'orderBy': 'name'}. In Dart, ctx is a map, so each method is looked up and called as a function. The access-rules warning above applies here too.
Future<Map<String, dynamic>> handler(Map<String, dynamic> ctx) async {
final auth = ctx['auth'] as Map<String, dynamic>;
final userId = auth['user_id'] as String?;
if (userId == null) return {'error': 'sign in required'};
final db = ctx['db'] as Map<String, dynamic>;
final result = await (db['find'] as Function)('orders', {'owner_id': userId}, 50);
return {'orders': result['records'], 'total': result['total']};
}In test mode
When a Function runs in test mode, ctx.db writes nothing. Each method returns a simulated result marked __test: true, and find returns no records. asUser() returns the same simulated methods.