Your own data
Sarah's app needs things we never thought of. Give it a table, with real indexes and foreign keys, and keep going.
Glow Salon tracks campaigns: a spring promotion, a loyalty push, each with a budget and a status and an owner on the team. There is no campaigns module, and there shouldn't be — it's Sarah's idea, not ours.
Custom data lets you define collections of your own. Each one is a real Postgres table in a database that belongs to your environment and nothing else, with the indexes, unique constraints and foreign keys you ask for. You read and write records with your secret key, from your server.
Server-side, in this release
Custom data is reached with a secret (sk_) key. Signed-in end users can't touch it
yet — your backend reads it on their behalf, the way it would with any other
database. Browser-facing access rules are a later release.
Model it
Define the collection
Fields have a type, and the type is for life. Everything else — required, defaults, indexes, the name — can change later.
import { createClient } from "hyphabase";
const hb = createClient(url, process.env.HYPHABASE_SECRET_KEY);
await hb.data.collections.create({
slug: "campaigns",
fields: [
{ name: "name", type: "string", required: true, max_length: 200 },
{ name: "status", type: "enum", required: true, values: ["draft", "live", "done"], default: "draft", indexed: true },
{ name: "budget", type: "number", precision: [12, 2], min: 0 },
{ name: "code", type: "string", unique: true },
{ name: "starts_on", type: "date" },
{ name: "owner_id", type: "reference", collection: "team_members", on_delete: "set_null" },
{ name: "notes", type: "json" },
],
});Types: string, text, integer, number, boolean, date, datetime, enum,
uuid, json, and reference — a foreign key to another collection, with
restrict (the referenced record can't be deleted while anything points at it) or
set_null.
Four names are taken on every record: id, created_at, updated_at, deleted_at.
Write records
Payloads are flat: your fields at the top level, nothing nested. Defaults are filled in, types are checked against the schema, and a bad reference is a field error, not a database error.
const campaigns = hb.data.collection<Campaign>("campaigns");
const { data: spring } = await campaigns.create({
name: "Spring refresh",
budget: 1500,
code: "SPRING26",
owner_id: sarah.id,
});
await campaigns.update(spring.id, { status: "live" });A second record with code: "SPRING26" comes back as 422 — the unique constraint
is real.
Query them
Filters, sorting on up to two fields, projection, and cursor pagination that stays stable while records are being written.
const page = await campaigns.list({
filter: { status: "live", budget: { gte: 1000 } },
sort: "-starts_on",
select: ["name", "budget", "starts_on"],
limit: 25,
});
// page.data, page.meta.next_cursor
for await (const campaign of campaigns.iterate({ filter: { status: "done" } })) {
// every page, one request at a time
}Operators: eq, neq, gt, gte, lt, lte, in, contains (string and text
fields), is_null. Up to eight predicates per query; limit up to 100.
Evolve the schema
Change it with a list of operations. Each one is applied and logged on its own, so a failure part-way tells you which one, and the earlier ones stand.
await hb.data.collections.update("campaigns", [
{ op: "add_field", name: "channel", type: "enum", values: ["email", "sms", "social"], indexed: true },
{ op: "rename_field", name: "notes", to: "brief" },
{ op: "set_required", name: "budget", required: true },
]);
await hb.data.collections.waitUntilReady("campaigns");set_required refuses while any record lacks a value. A type never changes — add a
new field and move the data. Hiding a field keeps its column and data; the field
simply leaves the API.
On a large collection a new index is built in the background without locking the
table; the field reports index_status: "building" until it's done, which is what
waitUntilReady polls for.
Delete, and get it back
Records are soft-deleted: gone from every list, restorable for 30 days, then really
deleted. A record that other records point at through a restrict reference answers
409 until they're moved or deleted.
A collection works the same way: delete it, restore it within 30 days, or let the table be dropped. A collection that another collection references can't be deleted first.
Keys and scopes
Schema changes need data.collections.manage. Records need data.records.read,
create, update and delete — and each of those can be limited to one collection
by suffixing its slug: a key with data.records.read:campaigns reads campaigns and
nothing else. See Who can do what.
In the console
The same collections appear under Custom Data in the console, per environment: a
schema editor (add, rename and hide fields, toggle required and indexes, and watch a
queued index build finish) and a record browser with the filters and sorting above.
Team members reach it with their own sign-in, not a key — platform.data.view to
browse, platform.data.manage to change schemas and records — and the change history
names the person, where an SDK change names the key.
Limits
Per plan: how many collections, how many records in total, how many schema changes a
day, and how much storage. Per collection: 100 fields, 16 indexes, 32 references.
Records are capped by their column types; a json field holds up to 16 KB.
Events
Endpoints can subscribe to data.record.created, updated, deleted, restored
and data.collection.created, changed, deleted — see
Getting told when things happen.
Beta
Custom data is in beta: the shapes above are what we intend to keep, and anything that changes before general availability will be announced with a dated API version.