Record APIs
TrailBase’s Record APIs allow you to easily configure endpoints exposing your
TABLEs and VIEWs though type-safe restful
CreateReadUpdateDelete operations, composite list queries and
even realtime change subscriptions.
For your data to be exposed via Record APIs:
TABLEs andVIEWs need to beSTRICTly1 typed to guarantee type-safety all the way from your DB records, over JSON schemas, to your client-side language bindings2.- To allow for stable sorting and thus efficient cursor-based pagination,
TABLEs are required to have eitherINTEGER, UUIDv4 or UUIDv7 primary key columns.
Configuration
Section titled “Configuration”Record APIs can be configured via the admin dashboard or direclty in TrailBase’s configuration file.
An example API setup for managing user profiles:
record_apis: [ { name: "profiles" table_name: "profiles" conflict_resolution: REPLACE acl_authenticated: [READ, CREATE] create_access_rule: "_REQ_.user = _USER_.id" },]A quick explanation:
- The
nameneeds to be unique. It’s what is used to access the API viahttp://<host>/api/records/v1/name table_namereferences theTABLEorVIEWthat is being exposed.conflict_resolutiondeclares what should happen if a newly created record is conflicting with an existing one.autofill_missing_user_id_columnlets you omit fields for columns with a foreign key relationship to_user(id). The field will then be filled with the credentials of the authenticated user. In most cases, this should probably be off, this only useful if you cannot explicitly provide the user id yourself, e.g. in a static HTML form.acl_worldandacl_authenticateddefine that anyone can read avatars but only authenticated users can modify them. The followingaccess_rulesfurther narrow mutations to records where theusercolumn (or request field for insertions) match. In other words, user X cannot modify user Y’s avatar.
Note that and TABLE/VIEW can be exposed multiple times as different APIs
with different properties.
Permissions
Section titled “Permissions”Access can be controlled through combination of a simple ACL-based system (a
matrix of who and what) and custom SQL access rules of the nature:
f(req, user, row) -> bool.
Generally, the ACLs are checked first and then the access rules are evaluated
when present.
For example, to validate that the requester provided a secret key and is member
of a group 'mygroup':
(_REQ_.secret = 'foo' AND EXISTS( SELECT 1 FROM groups WHERE groups.member = _USER_.id AND groups.name = 'mygroup' ))_REQ_is an injected sub-query containing a user request’s fields. It is available in the access rules forCREATEandUPDATEoperations. Fields that were either absent in the request or explicitly passed asnull, will beNULL.- To check for the presence of a specific field in a
CREATEorUPDATErequest, you can use'field' IN _REQ__FIELDS_. Note that_REQ_FIELDS_may only contain fields with a corresponding column in the underlyingTABLEorVIEW. - Similarly,
_ROW_is a sub-query of the target record. It is available in the access rules forREAD,UPDATE, andDELETEoperations. - Lastly,
_USER_.idreferences the id of the currently authenticated user andNULLotherwise.
Independently, you can use VIEWs to filter which rows and columns of
your TABLEs should be accessible.
Building Access Groups and Capabilities
Section titled “Building Access Groups and Capabilities”As hinted at by the example above, the SQL access rules can be used to
build higher-level access protection such as group ACLs or capabilities.
What makes the most sense in your case, is very application dependent.
The <repo>/examples/blog has an “editor” group to control who can write blog
posts.
Somewhat on a tangent, group and capability tables can themselves be exposed via Record APIs. This can be used to programmatically manage permissions, e.g. for building a moderation dashboard. When exposing authorization primitives, make sure the permissions are appropriately tight to avoid permission escalations.
VIEW-based APIs
Section titled “VIEW-based APIs”VIEWs can support a variety of use-cases, e.g.: read-only APIs on a subset of
columns, APIs exposing complex joins across many tables, computed values across
many columns, etc.
Note that computed columns require a top-level CAST expression for TrailBase
to determine the resulting type and satisfy the type-safety requirements for
record APIs, e.g.:
CREATE VIEW profile_view AS SELECT p.*, -- The CAST is needed to determine the type of the result. CAST(IIF(editors.user IS NULL, FALSE, TRUE) AS BOOLEAN) AS editor FROM profiles AS p LEFT JOIN editors ON p.user = editors.user;Write-only columns
Section titled “Write-only columns”Columns with names starting with an underscore can be written on insert or
updated but are hidden during read/list operations.
This is meant as a convenient convention to allow for internal data fields, e.g
hiding the record owner in an otherwise public data set or hiding a user’s
internal credit rating from their profile.
A similar effect could otherwise be achieved by only allowing create/update
operations on the table and exposing a subset of columns through a readable
VIEW.
However, this would lead to different API endpoints for read vs create/update.
Access
Section titled “Access”After setting up your API, TrailBase will expose the following main endpoints3:
- Create:
POST /api/records/v1/<api_name> - Read:
GET /api/records/v1/<api_name>/<url-safe_b64_uuid_or_int> - Update:
PATCH /api/records/v1/<api_name>/<url-safe_b64_uuid_or_int> - Delete:
DELETE /api/records/v1/<api_name>/<url-safe_b64_uuid_or_int> - List:
GET /api/records/v1/<api_name>?<search_params> - Change Subscriptions:
GET /api/records/v1/<api_name>/subscribe/[*|<url-safe_b64_uuid_or_int>] - Schema:
GET /api/records/v1/<api_name>/schema
All of the endpoints accept requests that are JSON encoded, url-encoded, or
multipart/form-data encoded, which makes them accessible via rich client-side
applications, progressive web apps, and static HTML forms alike.
Create
Section titled “Create”The create endpoint lets you insert new records and potentially override existing ones depending on conflict resolution strategy.
import type { Client } from "trailbase";
export const create = async (client: Client): Promise<string | number> => await client.records("simple_strict_table").create({ text_not_null: "test" });import 'package:trailbase/trailbase.dart';
Future<RecordId> create(Client client) async => await client .records('simple_strict_table') .create({'text_not_null': 'test'});import TrailBase
func create(client: Client) async throws -> RecordId { try await client.records("simple_strict_table").create( record: ["text_not_null": "test"])}package io.trailbase.examples.recordApi
import io.trailbase.client.*
suspend fun create(client: Client): RecordId { return client.records("simple_strict_table").create(SimpleStrictInsert(text_not_null = "test"))}using TrailBase;using System.Text.Json.Nodes;
public partial class Examples { public static async Task<RecordId> Create(Client client) => await client.Records("simple_strict_table").Create( new JsonObject { ["text_not_null"] = "test" });}use trailbase_client::Client;
pub async fn create(client: &Client) -> anyhow::Result<String> { Ok( client .records("simple_strict_table") .create(serde_json::json!({ "text_not_null": "test", })) .await?, )}package record_api_docs
import "github.com/trailbaseio/trailbase/client/go/trailbase"
func Create(client trailbase.Client) (trailbase.RecordId, error) { api := trailbase.NewRecordApi[SimpleStrict](client, "simple_strict_table") return api.Create(SimpleStrict{ TextNotNull: "test", })}from trailbase import Client, RecordId
def create(client: Client) -> RecordId: return client.records("simple_strict_table").create({"text_not_null": "test"})curl \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ --request POST \ --data '{"text_not_null": "test"}' \ http://localhost:4000/api/records/v1/simple_strict_tableThe read endpoint lets you read specific records given their id.
import type { Client } from "trailbase";
export const read = async (client: Client, id: string | number) => await client.records("simple_strict_table").read(id);import 'package:trailbase/trailbase.dart';
Future<Map<String, dynamic>> read(Client client, RecordId id) async => await client.records('simple_strict_table').read(id);import TrailBase
func read(client: Client, id: RecordId) async throws -> SimpleStrict { try await client.records("simple_strict_table").read(recordId: id)}package io.trailbase.examples.recordApi
import io.trailbase.client.*
suspend fun read(client: Client, id: RecordId): SimpleStrict { return client.records("simple_strict_table").read(id)}using TrailBase;using System.Text.Json.Nodes;
public partial class Examples { public static async Task<JsonNode?> Read(Client client, RecordId id) => await client.Records("simple_strict_table").Read<JsonNode>(id);}use trailbase_client::{Client, RecordId};
pub async fn read(client: &Client, id: impl RecordId<'_>) -> anyhow::Result<serde_json::Value> { Ok(client.records("simple_strict_table").read(id).await?)}package record_api_docs
import "github.com/trailbaseio/trailbase/client/go/trailbase"
func Read(client trailbase.Client, id trailbase.RecordId) (*SimpleStrict, error) { api := trailbase.NewRecordApi[SimpleStrict](client, "simple_strict_table") return api.Read(id)}from trailbase import Client, RecordId, JSON_OBJECT
def read(client: Client, id: RecordId) -> JSON_OBJECT: return client.records("simple_strict_table").read(id)curl \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ http://localhost:4000/api/records/v1/simple_strict_table/${RECORD_ID}Update
Section titled “Update”The update endpoint lets you modify, i.e. partially update, existing records given their id
import type { Client } from "trailbase";
export const update = async ( client: Client, id: string | number, record: object,) => await client.records("simple_strict_table").update(id, record);import 'package:trailbase/trailbase.dart';
Future<void> update(Client client, RecordId id) async => await client .records('simple_strict_table') .update(id, {'text_not_null': 'updated'});import TrailBase
func update(client: Client, id: RecordId) async throws { try await client.records("simple_strict_table").update( recordId: id, record: ["text_not_null": "updated"])}package io.trailbase.examples.recordApi
import io.trailbase.client.*
suspend fun update(client: Client, id: RecordId, record: SimpleStrictUpdate) { return client.records("simple_strict_table").update(id, record)}using TrailBase;using System.Text.Json.Nodes;
public partial class Examples { public static async Task Update(Client client, RecordId id) => await client.Records("simple_strict_table").Update( id, new JsonObject { ["text_not_null"] = "updated" });}use trailbase_client::{Client, RecordId};
pub async fn update(client: &Client, id: impl RecordId<'_>) -> anyhow::Result<()> { Ok( client .records("simple_strict_table") .update( id, serde_json::json!({ "text_not_null": "updated", }), ) .await?, )}package record_api_docs
import "github.com/trailbaseio/trailbase/client/go/trailbase"
func Update(client trailbase.Client, id trailbase.RecordId) error { api := trailbase.NewRecordApi[SimpleStrict](client, "simple_strict_table") return api.Update(id, SimpleStrict{ TextNotNull: "updated", })}from trailbase import Client, RecordId
def update(client: Client, id: RecordId): client.records("simple_strict_table").update( id, { "text_not_null": "updated", }, )curl \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ --request PATCH \ --data '{"text_not_null": "updated"}' \ http://localhost:4000/api/records/v1/simple_strict_table/${RECORD_ID}Delete
Section titled “Delete”import type { Client } from "trailbase";
export const remove = async (client: Client, id: string | number) => await client.records("simple_strict_table").delete(id);import 'package:trailbase/trailbase.dart';
Future<void> delete(Client client, RecordId id) async => await client.records('simple_strict_table').delete(id);import TrailBase
func delete(client: Client, id: RecordId) async throws { try await client.records("simple_strict_table").delete(recordId: id)}package io.trailbase.examples.recordApi
import io.trailbase.client.*
suspend fun delete(client: Client, id: RecordId) { return client.records("simple_strict_table").delete(id)}using TrailBase;
public partial class Examples { public static async Task Delete(Client client, RecordId id) => await client.Records("simple_strict_table").Delete(id);}use trailbase_client::{Client, RecordId};
pub async fn delete(client: &Client, id: impl RecordId<'_>) -> anyhow::Result<()> { Ok(client.records("simple_strict_table").delete(id).await?)}package record_api_docs
import "github.com/trailbaseio/trailbase/client/go/trailbase"
func Delete(client trailbase.Client, id trailbase.RecordId) error { api := trailbase.NewRecordApi[SimpleStrict](client, "simple_strict_table") return api.Delete(id)}from trailbase import Client, RecordId
def delete(client: Client, id: RecordId): client.records("simple_strict_table").delete(id)curl \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ --request DELETE \ http://localhost:4000/api/records/v1/simple_strict_table/${RECORD_ID}The delete endpoints lets you remove a record given its id.
List: Filter, Sort and Paginate
Section titled “List: Filter, Sort and Paginate”Using the GET /api/records/v1/<api_name>?<params> endpoint and given
sufficient permissions one can query records based the given read_access_rule
and query parameters.
Parameters:
- Pagination can be controlled via the following query parameters:
limit=N, with a built-in default of 50 and a hard limit of 1024 to avoid abuse.cursor=<primary key>to offset into results using a cursor. Significantly less expensive thanOFFSET-based pagination.offset=Nto offset into results.count=truewill yield atotal_countof records in the result. This can be used together withlimitandcursorto build pagination UIs.
- Ordering can be controlled using the
order=[[+-]?<column_name>]+parameter, e.g.order=created,-rank, which sorts records based on theircreatedcolumn in ascending order first (same as ”+”) and subsequently in descending order by theirrankcolumn due to the minus prefix. - Filtering can be controlled by passing one or more
filter[<column_name>][op]=<value>parameters. For example,filter[revenue][$gt]=0would list records with a positiverevenueonly. Multiple filters on the same column are supported and combined with AND logic, e.g.filter[date][$gte]=2025-01-01&filter[date][$lte]=2025-12-31for date ranges. - Supported operators are:
- $eq: equal, which is also the default if no explicit operator is
specified, i.e.
?success[$eq]=TRUEand?success=TRUEare identical. - $ne: not equal
- $gte: greater-than-equal
- $gt: greater-than
- $lte: less-than-equal
- $lt: less-than
- $is: is null or not null, i.e.
?col[$is]=NULLor?col[$is]=!NULL, respectively - $like: SQL
LIKEoperator, e.g.?col[$like]=%something% - $re: SQL
REGEXPoperator, e.g.?col[$re]=^something$
- $eq: equal, which is also the default if no explicit operator is
specified, i.e.
- Parent records, i.e. records pointed to by foreign key columns, can be
expanded using the
?expand=<col0>,<col>` parameter, if the respective columns were allow-listed in the API configuration.
Examples
Section titled “Examples”Date range filtering - Get all events between two dates:
GET /api/records/v1/events?filter[date][$gte]=2025-01-01&filter[date][$lte]=2025-12-31Numeric range - Find products within a price range:
GET /api/records/v1/products?filter[price][$gte]=10.00&filter[price][$lt]=50.00For a more complex example, to query the top-3 ranked movies with a watch time below 2 hours and “love” in their description:
import type { Client, ListResponse } from "trailbase";
export const list = async (client: Client): Promise<ListResponse<object>> => await client.records("movies").list({ pagination: { limit: 3, }, order: ["rank"], filters: [ { column: "watch_time", op: "lessThan", value: "120", }, { column: "description", op: "like", value: "%love%", }, ], });import 'package:trailbase/trailbase.dart';
Future<ListResponse> list(Client client) async => await client.records('movies').list( pagination: Pagination(limit: 3), order: ['rank'], filters: [ Filter( column: 'watch_time', op: CompareOp.lessThan, value: '120', ), Filter( column: 'description', op: CompareOp.like, value: '%love%', ), ], );import Foundationimport TrailBase
func list(client: Client) async throws -> ListResponse<Movie> { try await client .records("movies") .list( pagination: Pagination(limit: 3), order: ["rank"], filters: [ .Filter(column: "watch_time", op: .LessThan, value: "120"), .Filter(column: "description", op: .Like, value: "%love%"), ] )}package io.trailbase.examples.recordApi
import io.trailbase.client.*import kotlinx.serialization.json.JsonObject
suspend fun list(client: Client): ListResponse<JsonObject> { return client .records("movies") .list( pagination = Pagination(limit = 3), order = listOf("rank"), filters = listOf( Filter(column = "watch_time", op = CompareOp.lessThan, value = "120"), Filter(column = "description", op = CompareOp.like, value = "%love%"), ), )}using TrailBase;using System.Text.Json.Nodes;
public partial class Examples { public static async Task<ListResponse<JsonObject>> List(Client client) => await client.Records("movies").List( pagination: new Pagination(limit: 3), order: ["rank"], filters: [ new Filter(column:"watch_time", op:CompareOp.LessThan, value:"120"), new Filter(column:"description", op:CompareOp.Like, value:"%love%"), ]);}use trailbase_client::{Client, CompareOp, Filter, ListArguments, ListResponse, Pagination};
pub async fn list(client: &Client) -> anyhow::Result<ListResponse<serde_json::Value>> { Ok( client .records("movies") .list( ListArguments::new() .with_pagination(Pagination::new().with_limit(3)) .with_order(["rank"]) .with_filters([ Filter::new("watch_time", CompareOp::LessThan, "120"), Filter::new("description", CompareOp::Like, "%love%"), ]), ) .await?, )}package record_api_docs
import "github.com/trailbaseio/trailbase/client/go/trailbase"
func List(client trailbase.Client) (*trailbase.ListResponse[Movie], error) { api := trailbase.NewRecordApi[Movie](client, "movies")
limit := uint64(3)
return api.List(&trailbase.ListArguments{ Pagination: trailbase.Pagination{ Limit: &limit, }, Order: []string{"rank"}, Filters: []trailbase.Filter{ trailbase.FilterColumn{Column: "watch_time", Op: trailbase.LessThan, Value: "120"}, trailbase.FilterColumn{Column: "description", Op: trailbase.Like, Value: "%love%"}, }, })}from trailbase import Client, Filter, CompareOp, JSON_OBJECT
def list_movies(client: Client) -> list[JSON_OBJECT]: response = client.records("movies").list( limit=3, order=["rank"], filters=[ Filter(column="watch_time", value="120", op=CompareOp.LESS_THAN), Filter(column="description", value="%love%", op=CompareOp.LIKE), ], )
return response.recordscurl --globoff \ --header "Content-Type: application/json" \ --header "Authorization: Bearer ${AUTH_TOKEN}" \ --request GET \ 'http://localhost:4000/api/records/v1/movies?limit=3&order=rank&filter[watch_time][$lt]=120&filter[description][$like]=%love%'Subscribe
Section titled “Subscribe”The streaming subscribe endpoints lets you listen for changes to tables backing an API or specific records given their id. Change events can be insertions, updates, and deletions.
import type { Client } from "trailbase";
export const subscribe = async (client: Client, id: string | number) => await client.records("simple_strict_table").subscribe(id);
export const subscribeAll = async (client: Client) => await client.records("simple_strict_table").subscribe("*");import 'dart:async';
import 'package:trailbase/trailbase.dart';
Future<Stream<Event>> subscribe(Client client, RecordId id) async => await client.records('simple_strict_table').subscribe(id);
Future<Stream<Event>> subscribeAll(Client client) async => await client.records('simple_strict_table').subscribeAll();using TrailBase;
public partial class Examples { public static async Task<IAsyncEnumerable<Event>> Subscribe(Client client, RecordId id) => await client.Records("simple_strict_table").Subscribe(id);
public static async Task<IAsyncEnumerable<Event>> SubscribeAll(Client client) => await client.Records("simple_strict_table").SubscribeAll();}use trailbase_client::{Client, DbEvent, RecordId, Stream};
pub async fn subscribe( client: &Client, id: impl RecordId<'_>,) -> anyhow::Result<impl Stream<Item = DbEvent>> { Ok(client.records("simple_strict_table").subscribe(id).await?)}
pub async fn subscribe_all(client: &Client) -> anyhow::Result<impl Stream<Item = DbEvent>> { Ok(client.records("simple_strict_table").subscribe("*").await?)}Schema
Section titled “Schema”The schema endpoint allows for reading the APIs JSON schema definition. This can be useful for driving external code generation or introspection in general.
File Uploads
Section titled “File Uploads”Record APIs can also support file uploads and downloads. There’s some special handling in place so that only metadata is stored in the underlying table while the actual files are kept in an object store.
By adding a TEXT column with a CHECK(jsonschema('std.FileUpload'))
constrained to your table definition, you instruct TrailBase to store file
metadata as defined by the “std.FileUpload” JSON schema while keeping the contents
in a separate object store.
Files can then be upload by sending their contents as part of your JSON
requests or multipart/form-data POST requests.
Downloading files is slightly different, since reading the column through
record APIs will only yield the metadata. There’s a dedicated GET API endpoint
for file downloads:
/api/records/v1/<api_name>/<url-safe_b64_uuid_or_int>/file/<column_name>
S3 Integration
Section titled “S3 Integration”By default, TrailBase will keep the object store on the local file system under
<traildepot>/uploads.
Alternatively, an S3 bucket can be set up in the configuration.
It’s not yet accessible through the admin dashboard.
If you need support for
other storage backends,
let us know.
Custom JSON Schemas
Section titled “Custom JSON Schemas”Akin to std.FileUpload above, you can register your own nested JSON schemas
to be used with column CHECKs.
For now, the dashboard only allows viewing all registered JSON schemas, however
you can register schemas using the configuration:
schemas: [ { name: "simple_schema" schema: '{' ' "type": "object",' ' "properties": {' ' "name": { "type": "string" },' ' "obj": { "type": "object" }' ' },' ' "required": ["name"]' '}' }]Once registered, schemas can be added as column constraints:
CREATE TALE test ( simple TEXT CHECK(jsonschema('simple_schema')),
-- ...) STRICT;When generating new client-side bindings for a table or view with such nested schemas, they will be included ensuring type-safety all the way to the client-side APIs.
Footnotes
Section titled “Footnotes”-
By default, SQLite is not strictly typed. Column types merely express affinities unless the table is explicitly created as
STRICT. ↩ -
Views are more tricky to type strictly being the result of an arbitrary
SELECTstatement. For columns that are mapped 1:1 from aSTRICTly typedTABLEthe type can be infered. For everything else you can useCAST(col AS <TYPE>)expressions to assert the types of computed columns. ↩ -
There’s also a few other endpoints, e.g. for downloading files, which will be described further down in the docs. ↩