HyphaBase

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.

On this page