# ORM Reference

Complete ORM API for feature work. Prerequisites: `setup/server.md`.

## Core Rules

1. `ctx.orm.query.*` for reads, `ctx.orm.insert/update/delete` for writes.
2. Keep list queries bounded (`limit`/cursor) and index-aware.
3. Use relations (`with`) for loading related data.
4. Put cross-row side effects in schema triggers.
5. Constraints (unique, FK, check) enforced by ORM mutations only — `ctx.db` bypasses them.

## Column Types

All from `kitcn/orm`. See [Column Types](#column-types-1) in API Reference.

### Column Modifiers

```ts
text().notNull(); // required on select, required on insert
text().default("draft"); // optional on insert, uses default
text().notNull().unique(); // unique constraint (runtime-enforced)
timestamp().defaultNow(); // shorthand for $defaultFn(() => new Date())
timestamp().$onUpdateFn(() => new Date()); // runs on update when field not explicitly set
json<T>().$type<T>(); // type-only override
text().$defaultFn(() => crypto.randomUUID()); // custom default
```

### Type Inference

```ts
type Post = typeof posts.$inferSelect; // Select type (fields are T | null unless .notNull())
type NewPost = typeof posts.$inferInsert; // Insert type (required if .notNull() + no default)

// Or with helpers:
import { InferSelectModel, InferInsertModel } from "kitcn/orm";
type Post = InferSelectModel<typeof posts>;
```

## Constraints

### Unique

```ts
// Column-level
email: text().notNull().unique();

// Table-level unique index
import { uniqueIndex } from "kitcn/orm";
(t) => [uniqueIndex("users_email_unique").on(t.email)];

// Compound unique
import { unique } from "kitcn/orm";
(t) => [unique("full_name").on(t.firstName, t.lastName)];
```

### Foreign Keys

```ts
// Column-level (.references)
authorId: id("users")
  .notNull()
  .references(() => users.id);

// With cascading actions
authorId: id("users")
  .notNull()
  .references(() => users.id, {
    onDelete: "cascade", // cascade | set null | set default | restrict | no action
  });

// Self-referencing (use AnyColumn return type)
import { type AnyColumn } from "kitcn/orm";
parentId: text().references((): AnyColumn => commentsTable.id, {
  onDelete: "cascade",
});

// Bidirectional CMS revision pointers also work.
// This shape is valid:
// - revision.pageLocaleId -> pageLocales.id
// - pageLocales.currentRevisionId -> pageLocaleRevisions.id
// - pageLocales.publishedRevisionId -> pageLocaleRevisions.id
currentRevisionId: id("pageLocaleRevisions").references(
  () => pageLocaleRevisions.id
);

// Table-level (foreignKey builder, for non-id references)
import { foreignKey } from "kitcn/orm";
(t) => [foreignKey({ columns: [t.userSlug], foreignColumns: [users.slug] })];
```

### Check Constraints

```ts
import { check, gt, isNotNull } from "kitcn/orm";
(t) => [
  check("age_over_18", gt(t.age, 18)),
  check("email_present", isNotNull(t.email)),
];
```

## Indexes

```ts
import { index, searchIndex, vectorIndex } from 'kitcn/orm';

// Standard index
(t) => [index('by_author').on(t.authorId)]

// Search index (full-text)
(t) => [searchIndex('by_title').on(t.title).filter(t.authorId)]

// Vector index
(t) => [vectorIndex('embedding_vec').on(t.embedding).dimensions(1536).filter(t.authorId)]
```

## Relations

```ts
import { defineSchema } from "kitcn/orm";

export default defineSchema({ users, posts, tags, postsTags }).relations(
  (r) => ({
    users: {
      posts: r.many.posts(),
    },
    posts: {
      author: r.one.users({ from: r.posts.authorId, to: r.users.id }),
      // optional: false → non-nullable return type
      // alias: 'author' → disambiguate multiple relations to same table
      // where: { published: true } → predefined filter
    },
    // Many-to-many via join table
    postsTags: {
      post: r.one.posts({ from: r.postsTags.postId, to: r.posts.id }),
      tag: r.one.tags({ from: r.postsTags.tagId, to: r.tags.id }),
    },
  })
);
```

Plugin relation composition:

1. Extensions can expose `relations(...)`.
2. `defineSchema` merges extension relations first, then app `relations`.
3. Duplicate relation fields (`table.field`) throw.

### Many-to-many with `.through()`

```ts
users: {
  groups: r.many.groups({
    from: r.users.id.through(r.usersToGroups.userId),
    to: r.groups.id.through(r.usersToGroups.groupId),
    alias: 'users-groups-direct',
  }),
},
```

### Self-referencing

```ts
users: {
  manager: r.one.users({ from: r.users.managerId, to: r.users.id, alias: 'manager' }),
  reports: r.many.users({ from: r.users.id, to: r.users.managerId, alias: 'manager' }),
},
```

### Split relations (`defineRelationsPart`)

For large schemas, split relation definitions across modules and merge:

```ts
import { defineRelationsPart } from "kitcn/orm";
const userRelations = defineRelationsPart({ users, posts }, (r) => ({
  users: { posts: r.many.posts({ from: r.users.id, to: r.posts.authorId }) },
}));
// Merge into defineRelations
```

### Polymorphic associations

Polymorphism is schema-first via a discriminator column builder.

```ts
import { boolean, convexTable, discriminator, id, index, integer, text } from 'kitcn/orm';

const auditLogs = convexTable(
  'audit_logs',
  {
    timestamp: integer().notNull(),
    actionType: discriminator({
      as: 'details', // optional, default "details"
      variants: {
        role_change: {
          targetUserId: id('users'),
          oldRole: text().notNull(),
          newRole: text().notNull(),
        },
        document_update: {
          documentId: id('documents'),
          version: integer().notNull(),
          changes: text().notNull(),
        },
        security_alert: {
          severity: text().notNull(),
          errorCode: text().notNull(),
          isResolved: boolean().notNull(),
        },
      },
    }),
  },
  (t) => [
    index('by_action_ts').on(t.actionType, t.timestamp),
    index('by_role_target').on(t.actionType, t.targetUserId),
    index('by_doc').on(t.actionType, t.documentId),
  ]
);
```

Behavior:
- Storage and writes are flat (`actionType`, `targetUserId`, `documentId`, ...)
- Reads synthesize nested discriminated data at `details` (or custom `as`)
- `withVariants: true` auto-loads all `one()` relations on discriminator tables
- Generated variant fields are normal top-level refs for indexes/filters (`t.targetUserId`)

```ts
const rows = await ctx.orm.query.audit_logs.findMany({
  limit: 20,
  withVariants: true,
});

for (const row of rows) {
  if (row.actionType === 'role_change') {
    row.details.targetUserId;
    row.details.oldRole;
  }
}
```

Rules:
- One `discriminator(...)` discriminator column per table (current limit)
- Variant keys become discriminator literals
- Variant fields are generated as nullable physical columns
- Variant `.notNull()` means required in that branch only
- Duplicate field names across variants require identical builder signatures
- Alias (`as`) cannot collide with columns, relations, `with`, or `extras`
- Query config does not include a `polymorphic` option; polymorphism is defined in schema columns.

### Relation indexing requirements

- `many()` → index child FK field (e.g., `posts.userId`)
- `.through()` → index junction table FK fields (both directions)
- `one()` with `to: ...id` → uses `db.get()` (no extra index)
- Missing index throws unless `allowFullScan` on parent query

## Schema Definition

```ts
import {
  defineSchema,
} from "kitcn/orm";

// defineSchema takes tables map (not relations)
export default defineSchema(tables, {
  strict: false, // false = warn instead of throw on missing indexes
  defaults: {
    defaultLimit: 100, // default limit for findMany
    mutationBatchSize: 100, // page size for mutation row collection
    mutationMaxRows: 1000, // sync-mode hard cap
    mutationLeafBatchSize: 900, // async FK fan-out batch size
    mutationMaxBytesPerBatch: 2_097_152, // async measured-byte budget
    mutationScheduleCallCap: 100, // async schedule calls per mutation
    mutationExecutionMode: "async", // default when codegen wiring present; use 'sync' to opt out
    mutationAsyncDelayMs: 0,
    relationFanOutMaxKeys: 1000,
  },
}).relations((r) => ({
  users: {
    posts: r.many.posts(),
  },
  posts: {
      author: r.one.users({ from: r.posts.authorId, to: r.users.id }),
  },
}));
```

## Queries

```ts
// findMany with full options
const posts = await ctx.orm.query.posts.findMany({
  where: { authorId: ctx.userId, status: "published" },
  orderBy: { createdAt: "desc" },
  limit: 20,
  columns: { id: true, title: true, createdAt: true },
  with: { author: true, tags: { limit: 5 } },
});

// findFirst / findFirstOrThrow
const post = await ctx.orm.query.posts.findFirst({ where: { id: input.id } });
const post = await ctx.orm.query.posts.findFirstOrThrow({
  where: { id: input.id },
});

// Cursor pagination
const page = await ctx.orm.query.posts.findMany({
  where: { published: true },
  orderBy: { createdAt: "desc" },
  cursor: input.cursor ?? null,
  limit: 20,
});
// Returns: { page, continueCursor, isDone }

// Extras (computed fields, post-fetch)
const users = await ctx.orm.query.users.findMany({
  extras: { emailDomain: (row) => row.email.split("@")[1]! },
  limit: 50,
});

// System tables (raw Convex, not ORM)
const job = await ctx.orm.system.get(jobId);
const files = await ctx.orm.system.query("_storage").take(20);
```

### allowFullScan

Non-paginated `findMany()` requires sizing: `limit`, `cursor + limit`, `allowFullScan`, or `defaults.defaultLimit`.

### distinct (`findMany` unsupported)

`findMany({ distinct })` is not available to preserve strict no-scan/index-backed guarantees.

Use select-pipeline distinct instead:

```ts
const page = await ctx.orm.query.todos
  .select()
  .where({ projectId })
  .distinct({ fields: ['status'] })
  .paginate({ cursor: null, limit: 100 });
```

## Filtering + Pagination

| Query mode                       | Index required?                               | Pagination                                  | Ordering        |
| -------------------------------- | --------------------------------------------- | ------------------------------------------- | --------------- |
| `findMany({ where: object })`    | Optional (planner uses indexes when possible) | `limit/offset`, `cursor + limit`            | `orderBy`       |
| `findMany({ where: callback })`  | Optional (planner uses indexes when possible) | `limit/offset`, `cursor + limit`            | `orderBy`       |
| `findMany({ where: predicate })` | **Required** `.withIndex(name, range?)`       | `cursor + limit`, optional `maxScan`        | Index-backed    |
| `findMany({ search })`           | **Required** `searchIndex`                    | `limit/offset`, `cursor + limit`            | Relevance only  |
| `findMany({ vectorSearch })`     | **Required** `vectorIndex`                    | `vectorSearch.limit` only                   | Similarity only |
| `select()` composition           | Schema + index per source                     | `cursor + limit` (+ `endCursor`, `maxScan`) | Stream-backed   |

### How to choose

1. Need relevance-ranked text search? → `search`
2. Need vector similarity? → `vectorSearch`
3. Need relation-aware filtering? → object `where`
4. Need Drizzle callback syntax? → callback `where`
5. Need custom JS predicate? → `predicate(...)` + `.withIndex(...)`
6. Need union/interleave/map/filter/flatMap/distinct before pagination? → `select()` composition

### Object `where` (Default)

```ts
const admins = await ctx.orm.query.users.findMany({
  where: {
    role: "admin",
    age: { gt: 18 },
  },
});
```

See [Operators](#operators-1) in API Reference.

Index-compiled: `eq`, `ne`, `in`, `notIn`, `isNull`, `isNotNull`, `between`, `notBetween`, `startsWith`, `like('prefix%')`.
Post-fetch: everything else. Require `.withIndex(...)` in typed API to make scan scope deliberate.

### Relation filters

```ts
// Users with posts
await ctx.orm.query.users.findMany({ where: { posts: true } });

// Users with no posts
await ctx.orm.query.users.findMany({ where: { NOT: { posts: true } } });

// Nested relation filter
await ctx.orm.query.users.findMany({
  where: { posts: { title: { like: "A%" } } },
});
```

### Logical combinators

```ts
await ctx.orm.query.users.findMany({
  where: {
    OR: [{ role: "admin" }, { role: "premium" }],
    NOT: { email: { isNull: true } },
  },
});
```

### Callback `where` (Drizzle Style)

```ts
const admins = await ctx.orm.query.users.findMany({
  where: (users, { and, eq, isNotNull }) =>
    and(eq(users.role, "admin"), isNotNull(users.email)),
});
```

Same planner as object `where` — can use indexes when possible.

### Predicate `where` (Explicit Index Required)

For complex JS logic. Must call `.withIndex(...)` first.

```ts
return await ctx.orm.query.characters
  .withIndex("private", (q) => q.eq("private", false))
  .findMany({
    where: (_characters, { predicate }) =>
      predicate((char) => {
        if (input.category && !char.categories?.includes(input.category))
          return false;
        if (input.minScore && char.score < input.minScore) return false;
        return true;
      }),
    cursor: input.cursor,
    limit: input.limit,
    maxScan: 500,
  });
```

Use `maxScan` (cursor mode only) to cap scan size.

### Mutation `where` (Filter Expressions)

Mutation builders use operator helpers with column builders:

```ts
import { and, eq, gt } from "kitcn/orm";

await ctx.orm
  .update(users)
  .set({ role: "admin" })
  .where(and(eq(users.role, "member"), gt(users.age, 18)));
```

Helpers: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `inArray`, `notInArray`, `and`, `or`, `not`, `isNull`, `isNotNull`.

## Full-Text Search

Each search index searches ONE field with optional equality filter fields.

### Search schema

```ts
import {
  convexTable,
  defineSchema,
  searchIndex,
  text,
} from "kitcn/orm";

export const articles = convexTable(
  "articles",
  {
    title: text().notNull(),
    content: text().notNull(),
    author: text().notNull(),
    category: text().notNull(),
  },
  (t) => [
    searchIndex("search_content").on(t.content).filter(t.category, t.author),
    searchIndex("search_title").on(t.title),
  ]
);
```

### Basic search

```ts
const results = await ctx.orm.query.articles.findMany({
  search: { index: "search_content", query: input.query },
  limit: input.limit,
});
```

### Search with filters

```ts
const results = await ctx.orm.query.articles.findMany({
  search: {
    index: "search_content",
    query: input.query,
    filters: {
      category: input.category,
      ...(input.author ? { author: input.author } : {}),
    },
  },
  limit: 20,
});
```

### Paginated search

```ts
return await ctx.orm.query.articles.findMany({
  search: {
    index: "search_content",
    query: input.query,
    filters: input.category ? { category: input.category } : undefined,
  },
  cursor: input.cursor,
  limit: input.limit,
});
```

### Search constraints

- `orderBy` not allowed (Convex relevance ordering)
- Callback `where` not allowed
- Relation `where` not allowed
- Object `where` on base table fields is allowed (post-search filter)
- `with:` allowed for eager loading

## Select Composition (Advanced)

`select()` is the stream-style composition API. Use when you need pre-pagination transforms.

### Union + interleave (merged-stream equivalent)

```ts
return await ctx.orm.query.messages
  .withIndex("by_from_to")
  .select()
  .union([
    { where: { from: input.me, to: input.them } },
    { where: { from: input.them, to: input.me } },
  ])
  .interleaveBy(["createdAt", "id"])
  .filter(async (m) => !m.deletedAt)
  .map(async (m) => ({ ...m, body: m.body.slice(0, 240) }))
  .paginate({
    cursor: input.cursor,
    limit: input.limit,
    maxScan: 500,
  });
```

### Union with index ranges

```ts
const page = await ctx.orm.query.messages
  .select()
  .union([
    {
      index: {
        name: "by_from_to",
        range: (q) => q.eq("from", me).eq("to", them),
      },
    },
    {
      index: {
        name: "by_from_to",
        range: (q) => q.eq("from", them).eq("to", me),
      },
    },
  ])
  .interleaveBy(["createdAt", "id"])
  .paginate({ cursor: null, limit: 20 });
```

### Pre-pagination transforms

```ts
const page = await ctx.orm.query.messages
  .select()
  .filter(async (m) => !m.deletedAt)
  .map(async (m) => ({ ...m, preview: m.body.slice(0, 120) }))
  .distinct({ fields: ["channelId"] })
  .paginate({ cursor: null, limit: 20, maxScan: 500 });
```

### flatMap (relation join)

```ts
const page = await ctx.orm.query.users
  .select()
  .flatMap("posts", { includeParent: true })
  .paginate({ cursor: null, limit: 20 });
```

See [Select Composition Limitations](#select-composition-limitations) in API Reference.

## Pagination Modes

| Mode        | API                                      | Best for                                 |
| ----------- | ---------------------------------------- | ---------------------------------------- |
| Offset      | `findMany({ offset, limit })`            | Page-number UIs, small datasets          |
| Cursor      | `findMany({ cursor, limit })`            | Infinite scroll, large lists             |
| Composition | `select()...paginate({ cursor, limit })` | Stream-like transforms before pagination |
| Key-based   | `findMany({ pageByKey })`                | Deterministic key boundaries             |

### Cursor pagination

```ts
const page1 = await ctx.orm.query.posts.findMany({
  where: { published: true },
  orderBy: { createdAt: "desc" },
  cursor: null,
  limit: 20,
});

// Next page
const page2 = await ctx.orm.query.posts.findMany({
  where: { published: true },
  orderBy: { createdAt: "desc" },
  cursor: page1.continueCursor,
  limit: 20,
});
```

Return: `{ page, continueCursor, isDone, pageStatus?, splitCursor? }`

### Boundary pinning with `endCursor`

```ts
const refreshed = await ctx.orm.query.posts.findMany({
  where: { published: true },
  orderBy: { createdAt: "desc" },
  cursor: null,
  endCursor: page1.continueCursor,
  limit: 20,
});
```

### Key-based paging (`pageByKey`)

```ts
const first = await ctx.orm.query.messages.findMany({
  pageByKey: {
    index: "by_channel",
    order: "asc",
    targetMaxRows: 100,
  },
});

const second = await ctx.orm.query.messages.findMany({
  pageByKey: {
    index: "by_channel",
    order: "asc",
    startKey: first.indexKeys[99],
    targetMaxRows: 100,
  },
});
```

Return: `{ page, indexKeys, hasMore }`

### Combining Search and Complex Filters

Search mode supports `search.filters` plus base-table object `where`. For predicate/relation `where`:

**Option 1: Add more filterFields** (recommended)

```ts
searchIndex("search_content")
  .on(t.content)
  .filter(t.category, t.author, t.status, t.dateGroup);
```

**Option 2: Separate query paths**

```ts
if (input.query) {
  // Search path — limited filtering
  return await ctx.orm.query.articles.findMany({
    search: { index: 'search_content', query: input.query, filters: ... },
    cursor: input.cursor,
    limit: input.limit,
  });
}

// Predicate path — full filtering with explicit .withIndex(...)
return await ctx.orm.query.articles
  .withIndex('by_creation_time')
  .findMany({
    where: (_articles, { predicate }) =>
      predicate((article) => {
        if (input.category && article.category !== input.category) return false;
        if (input.startDate && article.publishedAt < input.startDate) return false;
        return true;
      }),
    cursor: input.cursor,
    limit: input.limit,
  });
```

**Option 3: Post-process** (small datasets only)

```ts
const results = await ctx.orm.query.articles.findMany({
  search: { index: "search_content", query },
  limit: 100,
});
const filtered = results.filter((a) => a.publishedAt >= startDate);
```

### Performance

1. **Index first** — constrain leading index fields. Compound indexes follow prefix rules.
2. **Bound scans** — use `maxScan` for predicate `where` (cursor mode only).
3. **Limit results** — always use `limit` or cursor pagination.
4. **Cursor stability** — keep same `where`/`orderBy` between page requests.
5. **`allowFullScan`** — non-cursor only. Cursor mode uses `maxScan` instead.
6. **Strict mode** — `strict: true` throws on missing `maxScan` for scan-fallback plans; `strict: false` warns.
7. **Search overhead** — don't over-index. Use `filterFields` to narrow before text matching.

See [Full-Scan Operator Workarounds](#full-scan-operator-workarounds-1) in API Reference.

## Mutations

### Insert

```ts
import { user } from "./schema";

// Basic
await ctx.orm.insert(user).values({ name: "Ada", email: "ada@domain.test" });

// Multi-row
await ctx.orm.insert(user).values([
  { name: "A", email: "a@domain.test" },
  { name: "B", email: "b@domain.test" },
]);

// Returning
const [row] = await ctx.orm
  .insert(user)
  .values({ name: "Ada", email: "ada@domain.test" })
  .returning(); // all fields

const [partial] = await ctx.orm
  .insert(user)
  .values({ name: "Ada", email: "ada@domain.test" })
  .returning({ id: user.id, email: user.email });

// Upsert: onConflictDoUpdate
await ctx.orm
  .insert(user)
  .values({ email: "ada@domain.test", name: "Ada" })
  .onConflictDoUpdate({ target: user.email, set: { name: "Ada Lovelace" } });

// Skip on conflict
await ctx.orm
  .insert(user)
  .values({ email: "ada@domain.test", name: "Ada" })
  .onConflictDoNothing({ target: user.email });
```

### Update

```ts
import { eq } from "kitcn/orm";
import { user } from "./schema";

// Basic
await ctx.orm
  .update(user)
  .set({ name: "Updated" })
  .where(eq(user.id, input.id));

// Returning
const [updated] = await ctx.orm
  .update(user)
  .set({ name: "New" })
  .where(eq(user.id, input.id))
  .returning();

// Unset a field
import { unsetToken } from "kitcn/orm";
await ctx.orm
  .update(user)
  .set({ nickname: unsetToken })
  .where(eq(user.id, input.id));

// Update without .where() throws — use .allowFullScan() to opt in
await ctx.orm.update(user).set({ role: "member" }).allowFullScan();
```

### Delete

```ts
await ctx.orm.delete(user).where(eq(user.id, input.id));

// Returning
const [deleted] = await ctx.orm
  .delete(user)
  .where(eq(user.id, input.id))
  .returning();

// Delete all (use with care)
await ctx.orm.delete(user).allowFullScan();
```

### Delete Modes

```ts
// Table-level default
import { deletion } from "kitcn/orm";
const user = convexTable(
  "user",
  {
    slug: text().notNull(),
    deletionTime: integer(),
  },
  () => [deletion("scheduled", { delayMs: 60_000 })]
);

// Per-query overrides
await ctx.orm.delete(user).where(eq(user.id, id)).hard(); // immediate
await ctx.orm.delete(user).where(eq(user.id, id)).soft(); // mark deleted
await ctx.orm
  .delete(user)
  .where(eq(user.id, id))
  .scheduled({ delayMs: 60_000 });

// Cancel scheduled delete: clear/change deletionTime before worker runs
```

### Paginated Mutations

For large workloads exceeding safety limits:

```ts
// Requires index on filtered field: index('by_role').on(t.role)
const page1 = await ctx.orm
  .update(user)
  .set({ role: "member" })
  .where(eq(user.role, "pending"))
  .paginate({ cursor: null, limit: 100 });
// Returns: { continueCursor, isDone, numAffected }
```

### Async Batched Mutations

Async is the default — first batch runs inline, remaining auto-scheduled. Customize per call:

```ts
await ctx.orm
  .update(user)
  .set({ role: "member" })
  .where(eq(user.role, "pending"))
  .execute({ batchSize: 200, delayMs: 0 });
```

To force sync (all rows in one transaction): `.execute({ mode: 'sync' })` or `defineSchema(tables, { defaults: { mutationExecutionMode: "sync" } })`.

## RLS (Row-Level Security)

### Define policies

```ts
import { convexTable, rlsPolicy, text, id, eq } from "kitcn/orm";

export const secrets = convexTable.withRLS(
  "secrets",
  {
    value: text().notNull(),
    ownerId: id("users").notNull(),
  },
  (t) => [
    rlsPolicy("read_own", {
      for: "select",
      using: (ctx) => eq(t.ownerId, ctx.viewerId),
    }),
    rlsPolicy("insert_own", {
      for: "insert",
      withCheck: (ctx) => eq(t.ownerId, ctx.viewerId),
    }),
    rlsPolicy("update_own", {
      for: "update",
      using: (ctx) => eq(t.ownerId, ctx.viewerId),
      withCheck: (ctx) => eq(t.ownerId, ctx.viewerId),
    }),
    rlsPolicy("delete_own", {
      for: "delete",
      using: (ctx) => eq(t.ownerId, ctx.viewerId),
    }),
  ]
);
```

### Policy operations

| Operation | Clause                | When                            |
| --------- | --------------------- | ------------------------------- |
| `select`  | `using`               | Filters rows after fetch        |
| `insert`  | `withCheck`           | Validates new rows before write |
| `update`  | `using` + `withCheck` | Filters existing, validates new |
| `delete`  | `using`               | Filters rows before delete      |

### Bypass RLS

```ts
await ctx.orm.skipRules.query.secrets.findMany();
```

### Roles

```ts
import { rlsRole } from "kitcn/orm";
const admin = rlsRole("admin");
rlsPolicy("admin_only", {
  for: "select",
  to: admin,
  using: (ctx, t) => eq(t.ownerId, ctx.viewerId),
});

// Provide roleResolver
const ormDb = orm.db(ctx, {
  rls: { ctx, roleResolver: (ctx) => ctx.roles ?? [] },
});
```

**Important:** `ctx.db` bypasses RLS. Only `ctx.orm` enforces policies. FK cascade fan-out also bypasses child-table RLS.

## Triggers

Schema-level hooks live on the default schema export via `.triggers(...)`. Trigger definitions are schema-level only; `convexTable(..., extraConfig)` no longer accepts trigger callbacks.

```ts
export default defineSchema({ comments, posts })
  .relations((r) => ({
    comments: {
      post: r.one.posts({ from: r.comments.postId, to: r.posts.id }),
    },
    posts: {
      comments: r.many.comments(),
    },
  }))
  .triggers({
  comments: {
    create: {
      after: async (doc, ctx) => {
        await ctx.orm
          .update(posts)
          .set({ lastCommentAt: new Date() })
          .where(eq(posts.id, doc.postId));
      },
    },
    delete: {
      after: async (doc, ctx) => {
        await ctx.orm
          .update(posts)
          .set({ lastCommentAt: new Date() })
          .where(eq(posts.id, doc.postId));
      },
    },
  },
});
```

### change payload

```ts
export default defineSchema({ comments })
  .relations(() => ({
    comments: {},
  }))
  .triggers({
  comments: {
    change: async (change, ctx) => {
      change.id; // always present
      change.operation; // 'insert' | 'update' | 'delete'
      change.oldDoc; // null on insert
      change.newDoc; // null on delete
    },
  },
});
```

### Aggregate triggers

```ts
import { aggregatePostLikes } from "./aggregates";

export default defineSchema({ postLikes })
  .relations(() => ({
    postLikes: {},
  }))
  .triggers({
  postLikes: {
    change: aggregatePostLikes.trigger,
  },
});
```

### `withoutTriggers`

Bypass all trigger hooks for a block of operations (bulk resets, migrations, seeding):

```ts
await ctx.orm.withoutTriggers(async (orm) => {
  await orm.delete(todosTable).allowFullScan();
});
```

### Trigger safety checklist

1. Idempotent logic.
2. Bounded writes (no full-scan loops).
3. No recursive ping-pong between tables.
4. Expensive work → keep triggers thin; enqueue background work from procedure layer via `caller.schedule.*`.
5. Auth checks in procedure layer; triggers focus on data invariants.

### Auth triggers vs DB triggers

Auth triggers (`triggers: { user, session }` in `defineAuth`) are separate from DB triggers. For DB-level side effects, use schema triggers. When your schema exports `relations`, generated runtime automatically wires ORM context for auth handlers.

## Complete Schema Template

```ts
import {
  boolean,
  check,
  convexTable,
  defineRelations,
  defineSchema,
  deletion,
  eq,
  id,
  index,
  integer,
  json,
  searchIndex,
  text,
  textEnum,
  timestamp,
  uniqueIndex,
} from "kitcn/orm";

export const user = convexTable("user", {
  name: text().notNull(),
  email: text().notNull().unique(),
  role: textEnum(["admin", "user"] as const)
    .notNull()
    .default("user"),
  plan: text(),
  banned: boolean(),
  createdAt: timestamp().notNull().defaultNow(),
  updatedAt: timestamp()
    .notNull()
    .defaultNow()
    .$onUpdateFn(() => new Date()),
  metadata: json<Record<string, unknown>>(),
});

export const post = convexTable(
  "post",
  {
    title: text().notNull(),
    content: text().notNull(),
    published: boolean().notNull().default(false),
    authorId: id("user")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    deletionTime: integer(),
    createdAt: timestamp().notNull().defaultNow(),
  },
  (t) => [
    index("by_author").on(t.authorId),
    index("by_author_created").on(t.authorId, t.createdAt),
    searchIndex("search_title").on(t.title).filter(t.authorId),
    deletion("scheduled", { delayMs: 60_000 }),
  ]
);

const tables = { user, post };
export default defineSchema(tables, {
  strict: false,
})
  .relations((r) => ({
    user: {
      posts: r.many.post(),
    },
    post: {
      author: r.one.user({
        from: r.post.authorId,
        to: r.user.id,
        optional: false,
      }),
    },
  }))
  .triggers({
    post: {
      create: {
        after: async (doc) => {
          console.log("post created", doc._id);
        },
      },
    },
  });
```

## Related References

- Aggregates: `./aggregates.md`
- Migrations: `./migrations.md`
- Scheduling: `./scheduling.md`
- HTTP: `./http.md`
- React/RSC: `./react.md`

## API Reference

### Column Types

All from `kitcn/orm`:

| Builder                         | TS Type       | Convex                 | Notes                                      |
| ------------------------------- | ------------- | ---------------------- | ------------------------------------------ |
| `text()`                        | `string`      | `v.string()`           |                                            |
| `textEnum(['a','b'] as const)`  | `'a' \| 'b'`  | `v.string()`           | Runtime-validated                          |
| `integer()`                     | `number`      | `v.number()`           | Float64                                    |
| `boolean()`                     | `boolean`     | `v.boolean()`          |                                            |
| `bigint()`                      | `bigint`      | `v.int64()`            |                                            |
| `timestamp()`                   | `Date`        | `v.number()`           | `.defaultNow()` for createdAt              |
| `timestamp({ mode: 'string' })` | `string`      | `v.number()`           |                                            |
| `date()`                        | `string`      | `v.string()`           | YYYY-MM-DD, or `{ mode: 'date' }` → `Date` |
| `id('table')`                   | `Id<'table'>` | `v.id('table')`        | Typed reference                            |
| `vector(dims)`                  | `number[]`    | `v.array(v.float64())` | For vectorIndex                            |
| `bytes()`                       | `ArrayBuffer` | `v.bytes()`            |                                            |
| `unionOf(text(), integer())`    | `string \| number` | `v.union(...)`    | Builder-only scalar union sugar            |
| `objectOf(text().notNull())`    | `Record<string, string>` | `v.record(...)` | Homogeneous record values                  |
| `json<T>()`                     | `T`           | `v.any()`              | Type-only, no runtime validation           |
| `custom(validator)`             | inferred      | any `v.*`              | Full Convex validator                      |

### Operators

| Category            | Operators                                                                    |
| ------------------- | ---------------------------------------------------------------------------- |
| Comparison          | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`                                         |
| Range               | `between` (inclusive), `notBetween` (strict outside)                         |
| Set                 | `in`, `notIn`                                                                |
| Null                | `isNull`, `isNotNull`                                                        |
| Logical             | `AND`, `OR`, `NOT`                                                           |
| String (post-fetch) | `like`, `ilike`, `notLike`, `notIlike`, `startsWith`, `endsWith`, `contains` |
| Array (post-fetch)  | `arrayContains`, `arrayContained`, `arrayOverlaps`                           |

### Select Composition Limitations

| Combination               | Status        |
| ------------------------- | ------------- |
| `select() + search`       | Not supported |
| `select() + vectorSearch` | Not supported |
| `select() + offset`       | Not supported |
| `select() + with`         | Not supported |
| `select() + extras`       | Not supported |
| `select() + columns`      | Not supported |

### Full-Scan Operator Workarounds

| Operator                           | Scalable workaround                                |
| ---------------------------------- | -------------------------------------------------- |
| `arrayContains/Contained/Overlaps` | Inverted/join table keyed by element               |
| `contains`                         | `withSearchIndex` or tokenized denormalized field  |
| `endsWith`                         | Store reversed column, use `startsWith`            |
| `ilike`/`notIlike`                 | Lowercase column + `startsWith`/`like('prefix%')`  |
| `notLike`                          | Indexed positive pre-filter + `notLike` post-fetch |
| `NOT` (general)                    | Rewrite to positive predicates; cap with `maxScan` |
