TailorDB
TailorDB is a type-safe database service for defining and managing data models on the Tailor Platform.
Overview
TailorDB provides:
- Type-safe schema definitions using TypeScript
- Automatic GraphQL API generation (CRUD operations)
- Relations between tables with automatic index and foreign key constraints
- Permission system for access control
- Field-level hooks and validations
For the official Tailor Platform documentation, see TailorDB Guide.
Table Definition
Define TailorDB tables in files matching glob patterns specified in tailor.config.ts.
Definition Rules:
- Multiple tables per file: You can define multiple TailorDB tables in a single file
- Export method: Use named exports (
export const) - Export both value and type: Always export both the runtime value and TypeScript type
- Uniqueness: Table names must be unique across all TailorDB namespaces in the application
import { db } from "@tailor-platform/sdk";
// Export both value and type
export const user = db.table("User", {
name: db.string(),
email: db.string().unique(),
age: db.int(),
...db.fields.timestamps(),
});
export type user = typeof user;
// You can define multiple tables in the same file
export const role = db.table("Role", {
name: db.string().unique(),
});
export type role = typeof role;Specify plural form by passing an array as first argument:
db.table(["User", "UserList"], {
name: db.string(),
});Pass a description as second argument:
db.table("User", "User in the system", {
name: db.string(),
});Field Types
| Method | TailorDB | TypeScript |
|---|---|---|
db.string() | String | string |
db.int() | Integer | number |
db.float() | Float | number |
db.decimal() | Decimal | string |
db.bool() | Boolean | boolean |
db.date() | Date | string |
db.datetime() | DateTime | string | Date |
db.time() | Time | string |
db.uuid() | UUID | string |
db.enum() | Enum | string |
db.object() | Nested | object |
Optional and Array Fields
db.string({ optional: true });
db.string({ array: true });
db.string({ optional: true, array: true });Decimal Fields
Decimal fields are stored as strings to preserve precision. The optional scale parameter sets the number of digits after the decimal point and must be an integer between 0 and 12. When scale is omitted, the platform default of 6 is used.
// Default scale (6 decimal places)
db.decimal();
// Custom scale (2 decimal places)
db.decimal({ scale: 2 });
// Optional with custom scale
db.decimal({ scale: 4, optional: true });Values are rounded half-up to fit the configured scale before being stored. Negative values follow the same rule based on absolute magnitude:
| Input | Scale | Stored |
|---|---|---|
"1.234" | 2 | "1.23" |
"1.235" | 2 | "1.24" |
"-1.235" | 2 | "-1.24" |
"1.5" | 0 | "2" |
"1.123456" | 6 | "1.123456" |
"1.1234567" | 6 | "1.123457" |
Enum Fields
db.enum(["red", "green", "blue"]);
db.enum([
{ value: "active", description: "Active status" },
{ value: "inactive", description: "Inactive status" },
]);Object Fields
// Object field
db.object({
street: db.string(),
city: db.string(),
country: db.string(),
});
// Object array field
db.object(
{
id: db.uuid(),
name: db.string(),
size: db.int(),
},
{ array: true },
);
// Optional object array field
db.object(
{
kind: db.string(),
days: db.int(),
},
{ optional: true, array: true },
);Field Modifiers
Description
db.string().description("User's full name");Index / Unique
db.string().index();
db.string().unique();Relations
Add a relation to field with automatic index and foreign key constraint:
const role = db.table("Role", {
name: db.string(),
});
const user = db.table("User", {
name: db.string(),
roleId: db.uuid().relation({
type: "n-1",
toward: { type: role },
}),
});For one-to-one relations, use type: "1-1":
const userProfile = db.table("UserProfile", {
userId: db.uuid().relation({
type: "1-1",
toward: { type: user },
}),
bio: db.string(),
});For foreign key constraint without creating a relation, use type: "keyOnly":
const user = db.table("User", {
roleId: db.uuid().relation({
type: "keyOnly",
toward: { type: role },
}),
});Create relations against different fields using toward.key:
const user = db.table("User", {
email: db.string().unique(),
});
const userProfile = db.table("UserProfile", {
userEmail: db.string().relation({
type: "1-1",
toward: { type: user, key: "email", as: "user" },
}),
});userEmail does not end in ID, Id, or id, so this example specifies the forward relation name with toward.as.
Customize relation names using toward.as / backward options:
const userProfile = db.table("UserProfile", {
userId: db.uuid().relation({
type: "1-1",
toward: { type: user, as: "base" },
backward: "profile",
}),
});This generates the following GraphQL types:
type UserProfile {
userId: ID!
base: User # toward.as: access User from UserProfile
}
type User {
id: ID!
profile: UserProfile # backward: access UserProfile from User
}toward.as- Customizes the field name for accessing the related table from this tablebackward- Customizes the field name for accessing this table from the related table
Relation names share the same GraphQL field namespace as fields, files, and other relations on the table. The SDK rejects duplicate or empty relation names. When toward.as is omitted, the default forward name comes from the relation field name with a trailing ID, Id, or id removed. This lets multiple fields point to the same target table with distinct forward names:
const post = db.table("Post", {
authorID: db.uuid().relation({
type: "n-1",
toward: { type: user },
backward: "authoredPosts",
}),
reviewerID: db.uuid().relation({
type: "n-1",
toward: { type: user },
backward: "reviewedPosts",
}),
});These fields generate the forward names author and reviewer. A relation field without one of the recognized ID suffixes needs an explicit toward.as, because its generated forward name would conflict with the field itself.
Use toward.as or backward when a generated relation name would conflict with an existing field, files entry, or relation on the same table.
Hooks
Add hooks to execute functions during data creation or update.
Field-level Hooks
Set hooks directly on individual fields.
Create hooks receive:
input: The field value from the input (null when not provided)invoker: Principal performing the operationnow: Operation timestamp (Date), shared across all hooks in the same operation
Update hooks receive the same arguments plus:
oldValue: The previous field value (null only for optional fields)
db.string().hooks({
create: ({ invoker }) => invoker?.id ?? "",
update: ({ input, oldValue }) => input ?? oldValue,
});Field-level hooks operate on a single field and cannot access other fields. Use type-level hooks for cross-field logic.
Type-level Hooks
Set hooks across multiple fields using db.table().hooks(). The hook returns an object with the fields to override. When both field-level and type-level hooks exist for the same field, type-level hooks take priority.
Create hooks receive:
input: The submitted record data. When field-level hooks or defaults exist,inputreflects their applied resultsinvoker: Principal performing the operationnow: Operation timestamp (Date), shared across all hooks in the same operation
Update hooks receive the same arguments plus:
oldRecord: The existing record (non-null)
export const customer = db
.table("Customer", {
firstName: db.string(),
lastName: db.string(),
fullName: db.string(),
})
.hooks({
create: ({ input }) => ({
fullName: `${input.firstName} ${input.lastName}`,
}),
update: ({ input, oldRecord }) => ({
fullName: `${input.firstName ?? oldRecord.firstName} ${input.lastName ?? oldRecord.lastName}`,
}),
});Use now to stamp several fields with the exact same instant:
export const order = db
.table("Order", {
createdAt: db.datetime(),
updatedAt: db.datetime(),
})
.hooks({
create: ({ now }) => ({ createdAt: now, updatedAt: now }),
update: ({ now }) => ({ updatedAt: now }),
});Note: .hooks() can only be called once on a table. Duplicate type-level calls fail at compile time and throw at runtime.
Validation
Add validation rules to fields. Validators run after hooks.
Note: Custom validators run only when built-in type validation succeeds, so value always has the field's declared type. For array fields, the validator is called once with the complete array, not per element:
// value is string[], not string
db.string({ array: true }).validate(({ value }) => value.length >= 2);Field-level Validation
Set validators directly on individual fields. Each validator receives { value } (the field value after hooks) and returns an error message string to fail, or void to pass:
db.string().validate(
({ value }) => (value.includes("@") ? undefined : "Must contain @"),
({ value }) => (value.length >= 5 ? undefined : "Must be at least 5 characters"),
);Type-level Validation
Set a validator across all fields using db.table().validate(). The validator receives { newRecord, oldRecord, invoker } and an issues() callback to report errors per field:
export const user = db
.table("User", {
name: db.string(),
email: db.string(),
})
.validate(({ newRecord }, issues) => {
if (newRecord.name.length <= 5) {
issues("name", "Name must be longer than 5 characters");
}
if (!newRecord.email.includes("@")) {
issues("email", "Must contain @");
}
});Defaults
Set a default value for a required field on create. The field becomes optional in the create input — the default fills in when no value is provided:
db.int().default(0);
db.string().default("pending");For datetime/date/time fields, pass "now" to use the operation timestamp:
db.datetime().default("now");Note: .validate() can only be called once on a table. Duplicate type-level calls fail at compile time and throw at runtime.
Vector Search
db.string().vector();Serial / Auto-increment
db.int().serial({
start: 0,
maxValue: 100,
});
db.string().serial({
start: 0,
format: "CUST_%d",
});Common Fields
export const user = db.table("User", {
name: db.string(),
...db.fields.timestamps(),
});db.fields.timestamps() adds non-null createdAt and updatedAt datetime fields. Both fields are populated when a record is created; provided values are preserved so seed data can use historical timestamps. updatedAt is also refreshed automatically when a record is updated.
Table Modifiers
Table builder methods that set one type-level configuration can be called only once on the same table. Duplicate calls fail at compile time and throw at runtime. This applies to .description(), .hooks(), .validate(), .features(), .indexes(), .files(), .permission(), and .gqlPermission().
Conditional assignment is still supported when only one branch calls the method:
let user = db.table("User", {
name: db.string(),
});
if (enableFiles) {
user = user.files({
avatar: "profile image",
});
}Composite Indexes
db.table("User", {
firstName: db.string(),
lastName: db.string(),
}).indexes({
fields: ["firstName", "lastName"],
unique: true,
name: "user_name_idx",
});File Fields
db.table("User", {
name: db.string(),
}).files({
avatar: "profile image",
});Features
db.table("User", {
name: db.string(),
}).features({
aggregation: true,
bulkUpsert: true,
});Event Publishing
Enable event publishing for a table to trigger executors on record changes:
db.table("User", {
name: db.string(),
}).features({
publishEvents: true,
});Behavior:
- When
publishEvents: true, record creation/update/deletion events are published - When not specified,
deploysets it from the executors taking part in the same run:truewhile one of them uses this table withrecordCreatedTrigger,recordUpdatedTrigger, orrecordDeletedTrigger, andfalseonce none does. Removing the last such trigger turns publishing back off on the nextdeploy - When explicitly set to
falsewhile an executor taking part in the same run uses this table,deployfails
Use cases:
Auto-detection (recommended): Don't set
publishEvents-deployenables it while an executor taking part in the same run needs ittypescript// publishEvents is automatically enabled because an executor uses this table export const order = db.table("Order", { status: db.string(), }); // In executor file: export default createExecutor({ trigger: recordCreatedTrigger(order), // ... });Manual enable: Enable event publishing for external consumers or debugging
typescriptdb.table("AuditLog", { action: db.string(), }).features({ publishEvents: true, // Enable even without executor triggers });Explicit disable: Disable event publishing for a table that doesn't need it (error if an executor taking part in the same run uses it)
typescriptdb.table("TempData", { data: db.string(), }).features({ publishEvents: false, // Explicitly disable });
Sharing a table across configs: an executor in another config auto-enables publishing the same way, as long as both configs take part in the same deploy (--config a,b). deploy records that dependency, so deploying the owning config alone later asks for confirmation instead of silently turning publishing off — it fails outright in a non-interactive environment. Set publishEvents: true on the table to keep it on regardless of which configs take part.
GraphQL Operations
Control which GraphQL operations (create, update, delete, read) are exposed for a table. All operations are enabled by default.
db.table("Order", {
status: db.string(),
}).features({
gqlOperations: {
delete: false, // Disable the delete mutation
},
});Use the "query" alias to disable all mutations at once (read-only table: create/update/delete false, read true):
db.table("AuditLog", {
action: db.string(),
}).features({
gqlOperations: "query",
});Namespace-level default
Set a default for every table in a TailorDB namespace in tailor.config.ts. A table's own .features({ gqlOperations }) always takes precedence over this default.
// tailor.config.ts
export default defineConfig({
db: {
tailordb: {
files: ["./tailordb/*.ts"],
gqlOperations: { delete: false }, // Default for every table in this namespace
},
},
});This default is re-evaluated on every tailor deploy, so changing it also updates tables that already exist on the platform, not only newly created ones.
Field Extraction (pickFields / omitFields)
Extract subsets of fields from a TailorDBType for reuse in resolvers, executors, seed schemas, etc.
pickFields(keys, options)
Select specific fields and optionally modify their properties:
const user = db.table("User", {
id: db.uuid(),
name: db.string(),
email: db.string().unique(),
...db.fields.timestamps(),
});
// Pick id, createdAt, and updatedAt, making them optional
user.pickFields(["id", "createdAt", "updatedAt"], { optional: true });Available options:
| Option | Effect |
|---|---|
optional | Makes the selected fields optional |
array | Makes the selected fields array types |
Note: The array option cannot change fields with custom validation — their validators expect the original value shape. Define a new field with a matching validator instead.
omitFields(keys)
Return all fields except the specified ones:
// All fields except id, createdAt, and updatedAt
user.omitFields(["id", "createdAt", "updatedAt"]);Common Pattern: Input Schema Composition
The typical use case is combining pickFields and omitFields with spread syntax to build input schemas where identifiers are optional but other fields remain required:
import { createResolver, t } from "@tailor-platform/sdk";
import { user } from "../tailordb/user";
export default createResolver({
name: "createUser",
operation: "mutation",
input: {
// id/createdAt/updatedAt are optional (auto-generated), other fields are required
...user.pickFields(["id", "createdAt", "updatedAt"], { optional: true }),
...user.omitFields(["id", "createdAt", "updatedAt"]),
},
output: t.object({ id: t.uuid() }),
body: async (context) => {
// ...
return { id: "..." };
},
});This is also used in seed data schemas:
import { t } from "@tailor-platform/sdk";
import { invoice } from "../../tailordb/invoice";
const schemaType = t.object({
...invoice.pickFields(["id", "createdAt", "updatedAt"], { optional: true }),
...invoice.omitFields(["id", "createdAt", "updatedAt", "invoiceNumber", "sequentialId"]),
});Permissions
Configure Permission and GQLPermission. For details, see the TailorDB Permission documentation.
Important: Following the secure-by-default principle, all operations are denied if permissions are not configured. You must explicitly grant permissions for each operation (create, read, update, delete).
generate/deploy reject a table that has no .permission(), or no .gqlPermission() while GraphQL operations are enabled for it (see GraphQL Operations above). Disable GraphQL exposure entirely with .features({ gqlOperations: { create: false, update: false, delete: false, read: false } }) if a table only needs record-level permission.
db.table("User", {
name: db.string(),
role: db.enum(["admin", "user"]).index(),
})
.permission({
create: [[{ user: "role" }, "=", "admin"]],
read: [
[{ user: "role" }, "=", "admin"],
[{ record: "id" }, "=", { user: "id" }],
],
update: [[{ user: "role" }, "=", "admin"]],
delete: [[{ user: "role" }, "=", "admin"]],
})
.gqlPermission([
{ conditions: [[{ user: "role" }, "=", "admin"]], actions: "all" },
{ conditions: [[{ user: "role" }, "=", "user"]], actions: ["read"] },
]);Development/Test Helpers
For local development, prototyping, or testing, the SDK provides helper constants that grant full access without conditions:
import {
db,
unsafeAllowAllTypePermission,
unsafeAllowAllGqlPermission,
} from "@tailor-platform/sdk";
db.table("User", {
name: db.string(),
})
.permission(unsafeAllowAllTypePermission)
.gqlPermission(unsafeAllowAllGqlPermission);Warning: Do not use unsafeAllowAllTypePermission or unsafeAllowAllGqlPermission in production environments as they effectively disable authorization checks.
Migrations
When you change a TailorDB table definition, the SDK can generate a migration that captures the diff and, for breaking changes, runs a data transformation script during tailor deploy. See the TailorDB Migrations guide for the full workflow, configuration, supported change types, team coordination, and troubleshooting.
For the CLI command reference, see tailordb migration.