Skip to content
View as Markdown

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
typescript
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:

typescript
db.table(["User", "UserList"], {
  name: db.string(),
});

Pass a description as second argument:

typescript
db.table("User", "User in the system", {
  name: db.string(),
});

Field Types

MethodTailorDBTypeScript
db.string()Stringstring
db.int()Integernumber
db.float()Floatnumber
db.decimal()Decimalstring
db.bool()Booleanboolean
db.date()Datestring
db.datetime()DateTimestring | Date
db.time()Timestring
db.uuid()UUIDstring
db.enum()Enumstring
db.object()Nestedobject

Optional and Array Fields

typescript
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.

typescript
// 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:

InputScaleStored
"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

typescript
db.enum(["red", "green", "blue"]);
db.enum([
  { value: "active", description: "Active status" },
  { value: "inactive", description: "Inactive status" },
]);

Object Fields

typescript
// 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

typescript
db.string().description("User's full name");

Index / Unique

typescript
db.string().index();
db.string().unique();

Relations

Add a relation to field with automatic index and foreign key constraint:

typescript
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":

typescript
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":

typescript
const user = db.table("User", {
  roleId: db.uuid().relation({
    type: "keyOnly",
    toward: { type: role },
  }),
});

Create relations against different fields using toward.key:

typescript
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:

typescript
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:

graphql
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 table
  • backward - 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:

typescript
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 operation
  • now: 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)
typescript
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, input reflects their applied results
  • invoker: Principal performing the operation
  • now: Operation timestamp (Date), shared across all hooks in the same operation

Update hooks receive the same arguments plus:

  • oldRecord: The existing record (non-null)
typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
db.int().default(0);
db.string().default("pending");

For datetime/date/time fields, pass "now" to use the operation timestamp:

typescript
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.

typescript
db.string().vector();

Serial / Auto-increment

typescript
db.int().serial({
  start: 0,
  maxValue: 100,
});

db.string().serial({
  start: 0,
  format: "CUST_%d",
});

Common Fields

typescript
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:

typescript
let user = db.table("User", {
  name: db.string(),
});

if (enableFiles) {
  user = user.files({
    avatar: "profile image",
  });
}

Composite Indexes

typescript
db.table("User", {
  firstName: db.string(),
  lastName: db.string(),
}).indexes({
  fields: ["firstName", "lastName"],
  unique: true,
  name: "user_name_idx",
});

File Fields

typescript
db.table("User", {
  name: db.string(),
}).files({
  avatar: "profile image",
});

Features

typescript
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:

typescript
db.table("User", {
  name: db.string(),
}).features({
  publishEvents: true,
});

Behavior:

  • When publishEvents: true, record creation/update/deletion events are published
  • When not specified, deploy sets it from the executors taking part in the same run: true while one of them uses this table with recordCreatedTrigger, recordUpdatedTrigger, or recordDeletedTrigger, and false once none does. Removing the last such trigger turns publishing back off on the next deploy
  • When explicitly set to false while an executor taking part in the same run uses this table, deploy fails

Use cases:

  1. Auto-detection (recommended): Don't set publishEvents - deploy enables it while an executor taking part in the same run needs it

    typescript
    // 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),
      // ...
    });
  2. Manual enable: Enable event publishing for external consumers or debugging

    typescript
    db.table("AuditLog", {
      action: db.string(),
    }).features({
      publishEvents: true, // Enable even without executor triggers
    });
  3. 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)

    typescript
    db.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.

typescript
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):

typescript
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.

typescript
// 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:

typescript
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:

OptionEffect
optionalMakes the selected fields optional
arrayMakes 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:

typescript
// 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:

typescript
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:

typescript
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.

typescript
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:

typescript
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.