data-schema

SKILL.md

Data Schema

Define your data model with entities, fields, and relationships. Bknd uses a declarative API similar to Drizzle/Prisma but with fewer boilerplate.

What You'll Learn

  • Define entities with em() and entity()
  • Use all field types with proper configuration
  • Create relationships between entities
  • Add indices for performance
  • Follow naming conventions and best practices

Entity Definition

Basic Entity

Use em() to define multiple entities:

import { em, entity, text, boolean } from "bknd";

const schema = em({
  todos: entity("todos", {
    title: text().required(),
    done: boolean(),
  }),
});

Auto-generated fields:

  • id (primary key) - automatically added to every entity
  • Format depends on database: SQLite uses integer, PostgreSQL uses UUID

Multiple Entities

const schema = em({
  users: entity("users", {
    email: text().required(),
    name: text(),
  }),
  posts: entity("posts", {
    title: text().required(),
    content: text(),
  }),
});

Field Types

Primary Field

Primary key is automatically added to every entity:

entity("users", {
  email: text().required(),
})
// Auto-generated: id (integer or UUID)

Customize primary key format:

entity("users", {
  email: text().required(),
}, {
  primary_format: "uuid",  // "integer" | "uuid" (default: "integer")
})

Note: Use the timestamps plugin for automatic created_at/updated_at fields.

Enum Field

import { enumm } from "bknd";

entity("users", {
  role: enumm({
    enum: ["admin", "user", "guest"],
  }).required(),
  status: enumm({
    enum: {
      ACTIVE: "active",
      INACTIVE: "inactive",
      PENDING: "pending",
    },
    default_value: "PENDING",
  }),
})

JSON Field

import { json } from "bknd";

entity("users", {
  metadata: json<{ theme: string; notifications: boolean }>(),
  preferences: json<string[]>({
    default_value: [],
  }),
})

JSON Schema Field

For validated JSON with schema enforcement:

import { jsonschema } from "bknd";

entity("events", {
  payload: jsonschema({
    type: "object",
    properties: {
      type: { type: "string" },
      data: { type: "object" },
    },
    required: ["type", "data"],
  }),
})

Relationships

Many-to-One

Posts belong to one user:

const schema = em(
  {
    users: entity("users", {
      email: text().required(),
    }),
    posts: entity("posts", {
      title: text().required(),
    }),
  },
  ({ relation }, { users, posts }) => {
    relation(posts).manyToOne(users);
  }
);

Auto-generated: user_id foreign key on posts table

One-to-One

User has one settings profile:

const schema = em(
  {
    users: entity("users", {
      email: text().required(),
    }),
    settings: entity("settings", {
      theme: text(),
    }),
  },
  ({ relation }, { users, settings }) => {
    relation(settings).oneToOne(users);
  }
);

Note: One-to-one cannot use $set operator for updates (maintains exclusivity).

Many-to-Many

Posts have many tags:

const schema = em(
  {
    posts: entity("posts", {
      title: text().required(),
    }),
    tags: entity("tags", {
      name: text().required(),
    }),
  },
  ({ relation }, { posts, tags }) => {
    relation(posts).manyToMany(tags);
  }
);

Auto-generated: Junction table posts_tags with post_id and tag_id

Many-to-Many with Custom Fields

const schema = em(
  {
    users: entity("users", {
      email: text().required(),
    }),
    courses: entity("courses", {
      title: text().required(),
    }),
  },
  ({ relation }, { users, courses }) => {
    relation(users).manyToMany(courses, {
      connectionTable: "user_courses",
    }, {
      enrolled_at: date(),
      completed_at: date(),
    });
  }
);

Self-Referencing

Categories can have parent/child relationships:

const schema = em(
  {
    categories: entity("categories", {
      name: text().required(),
    }),
  },
  ({ relation }, { categories }) => {
    relation(categories).manyToOne(categories, {
      inversedBy: "children",
      mappedBy: "parent",
    });
  }
);

Indices

Simple Index

const schema = em(
  {
    users: entity("users", {
      email: text().required(),
    }),
  },
  ({ index }, { users }) => {
    index(users).on(["email"]);
  }
);

Unique Index

const schema = em(
  {
    users: entity("users", {
      email: text().required(),
      username: text().required(),
    }),
  },
  ({ index }, { users }) => {
    index(users).on(["email"], true); // Second parameter = unique
  }
);

Composite Index

const schema = em(
  {
    posts: entity("posts", {
      author_id: number(),
      published: boolean(),
      created_at: date(),
    }),
  },
  ({ index }, { posts }) => {
    index(posts).on(["author_id", "published"]);
  }
);

Chained Index Definition

const schema = em(
  {
    users: entity("users", {
      email: text().required(),
      username: text().required(),
    }),
  },
  ({ index }, { users }) => {
    index(users)
      .on(["email"], true)  // Unique index on email
      .on(["username"], true) // Unique index on username
      .on(["created_at"]);  // Non-unique index
  }
);

Field Validation

Built-in Validation

Fields validate data on insert/update:

entity("users", {
  age: number({
    minimum: 18,
    maximum: 120,
  }),
  email: text({
    pattern: "^[^@]+@[^@]+\\.[^@]+$", // Basic email regex
  }),
})

Validation Errors

For server-side mutations, use the API:

const result = await api.data.createOne("users", {
  email: "invalid-email", // Fails pattern validation
  age: 15,               // Fails minimum validation
});

if (result.error) {
  console.log(result.error); // Validation error details
}

Naming Conventions

Entity Names

Use plural, lowercase entity names:

// ✅ Good
entity("users", { ... })
entity("posts", { ... })

// ❌ Bad
entity("User", { ... })
entity("user", { ... })

Field Names

Use snake_case for database compatibility:

// ✅ Good
entity("users", {
  first_name: text(),
  last_name: text(),
  created_at: date(),
})

// ❌ Bad (works but inconsistent)
entity("users", {
  firstName: text(),  // Stored as firstName in DB
  lastName: text(),
})

Type Generation

Extract types from schema for type safety:

const schema = em({
  users: entity("users", {
    email: text().required(),
    name: text(),
  }),
});

type Database = (typeof schema)["DB"];
declare module "bknd" {
  interface DB extends Database {}
}

Generate with CLI:

npx bknd types

DOs and DON'Ts

DO:

  • Use plural entity names (users, posts, comments)
  • Use snake_case for field names (created_at, user_id)
  • Add unique indices for fields used in lookups (email, username)
  • Use composite indices for frequently filtered combinations
  • Generate types with npx bknd types for full type safety
  • Use timestamps plugin for created_at/updated_at fields
  • Index foreign key fields (user_id, post_id)

DON'T:

  • Define the same entity twice in em()
  • Forget .required() for non-nullable fields
  • Skip indices on frequently queried fields (you'll see warnings)
  • Use PascalCase for entity or field names
  • Store large binary data in JSON fields (use media fields instead)
  • Overuse jsonschema unless you need runtime validation
Weekly Installs
1
GitHub Stars
2
First Seen
Jan 19, 2026
Installed on
opencode1
claude-code1