Skip to content

Schemas

A schema describes two things: the shape of your rows and the indexes your code can read through. Schemas are defined with defineTable and the v validator library, and they also act as the source of truth for TypeScript types.

Indexes are part of the public data model. Selectors later name an index explicitly with selectFrom(table, "indexName"), so the schema is where you decide which access paths your application has.

Every table has a name and a set of fields. A table must have a string id field. HyperDB automatically creates a built-in unique hash index named byId on id; declare the other indexes your selectors will read through.

import { defineTable, v, type ExtractSchema } from "@will-be-done/hyperdb";
export const tasksTable = defineTable("tasks", {
id: v.string(),
projectId: v.string(),
title: v.string(),
slug: v.string(),
state: v.union(v.literal("todo"), v.literal("done")),
orderToken: v.string(),
note: v.optional(v.string()),
})
.index("byProjectOrder", ["projectId", "orderToken"])
.index("byTitle", ["title"], { type: "hash" })
.index("bySlug", ["slug"], { type: "uniqhash" })
.index("byIds", ["id"]);
export type Task = ExtractSchema<typeof tasksTable>;

ExtractSchema<typeof table> gives you the row type. Here, Task is:

type Task = {
id: string;
projectId: string;
title: string;
slug: string;
state: "todo" | "done";
orderToken: string;
note?: string;
};

Tables are plain descriptions. They don’t store data and aren’t bound to any database, so you can import the same table into multiple DB instances. You make a table usable on a database by calling loadTables.

byId and byIds are intentionally different in this example. The built-in byId index is a uniqhash for exact id lookups. The explicit byIds index is a B-tree over id, useful when you need ordered full-table scans, for example with preloadTables on a HybridDB.

Fields are described with validators from the v namespace. The full list of value types lives in Data Types; here are the building blocks:

v.string();
v.number(); // finite numbers only
v.bigint();
v.boolean();
v.null();
v.literal("done"); // string | number | bigint | boolean | null literals
v.array(v.string());
v.object({ x: v.number(), y: v.number() });
v.record(v.string(), v.number());
v.union(v.literal("todo"), v.literal("done"));
v.optional(v.string());
v.arrayBuffer();
v.any();

There are also helpers for deriving object validators:

  • v.partial(objectValidator): make every field optional.
  • v.required(objectValidator, ["a", "b"]): make the listed optional fields required again.
  • v.lazy(() => validator): defer evaluation, for recursive shapes.
  • v.pass<T>(): accept any value as type T without normalizing it.

Validators are useful beyond tables, for example to type selector/action arguments or intermediate data. Use Infer to get the TypeScript type of any validator.

import { v, type Infer } from "@will-be-done/hyperdb";
const filterSchema = v.object({
projectId: v.string(),
state: v.optional(v.union(v.literal("todo"), v.literal("done"))),
});
type Filter = Infer<typeof filterSchema>;
// { projectId: string; state?: "todo" | "done" }

defineTable also accepts a standalone object or union validator instead of a field map. This is how you model a table whose rows are a tagged union of several shapes:

const documentsTable = defineTable(
"documents",
v.union(
v.object({ id: v.string(), type: v.literal("post"), title: v.string() }),
v.object({ id: v.string(), type: v.literal("note"), body: v.string() }),
),
).index("byPostTitle", ["title"]);

Each variant must still include a string id. When you index a column, HyperDB collects that field’s validator across every variant that declares it.

Indexes are declared with .index(name, columns, options?). They are what make queries fast, and they make query execution explicit: a selector chooses one index, then builds equality or range bounds over that index. Each .index(...) call returns a new table definition, so you can chain them.

defineTable("tasks", {
/* fields */
})
.index("byProjectOrder", ["projectId", "orderToken"]) // btree (default)
.index("byTitle", ["title"], { type: "hash" }) // non-unique hash
.index("bySlug", ["slug"], { type: "uniqhash" }) // unique hash
.index("byIds", ["id"]); // btree full-table scan
  • btree (the default) supports equality and range queries, ordering, and composite (multi-column) keys.
  • hash supports equality lookups only and must have exactly one column.
  • uniqhash supports equality lookups only, must have exactly one column, and rejects duplicate values in every storage driver. The built-in byId index is a uniqhash.

Index columns must:

  • exist in the table schema, and
  • be indexable value types (string, finite number, bigint, boolean, null, ArrayBuffer/typed-array, and compatible literals, unions, and optionals of those).

Invalid index definitions throw at defineTable time, so mistakes surface immediately. For how composite indexes are queried, see Indexes.

Start from the reads your app needs:

  • Exact lookup by id: use the built-in byId.
  • Ordered list inside a parent: use a B-tree such as ["projectId", "orderToken"].
  • Exact lookup by a non-unique field: use hash.
  • Exact lookup by a unique field such as a slug: use uniqhash.
  • Whole-table preload or ordered full scan: add a B-tree full-scan index such as ["id"].

For composite B-tree indexes, put equality columns first and the ordered or range column last. A selector over ["projectId", "orderToken"] can read one project in order without scanning every task.

Validators give you TypeScript types and normalize values before they reach storage. In development, pass runtimeRowsValidation: true to DB to validate full rows on writes and on reads from the driver. That is useful when data may come from older versions, imports, sync, or external storage.

Stored values can never contain undefined. Optional object fields may be omitted entirely, and { field: undefined } is normalized to “missing”. But undefined is not allowed inside arrays or as a record value. See Data Types for the details.

HelperPurpose
ExtractSchema<typeof table>Row type of a table
ExtractIndexes<typeof table>Index definitions of a table
Infer<typeof validator>TypeScript type of any validator
InferObject<fields>TypeScript type of an object field map