kitcn 0.0.1 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/intent.js +3 -0
- package/dist/aggregate/index.d.ts +388 -0
- package/dist/aggregate/index.js +37 -0
- package/dist/api-entry-BckXqaLb.js +66 -0
- package/dist/auth/client/index.d.ts +37 -0
- package/dist/auth/client/index.js +217 -0
- package/dist/auth/config/index.d.ts +45 -0
- package/dist/auth/config/index.js +24 -0
- package/dist/auth/generated/index.d.ts +2 -0
- package/dist/auth/generated/index.js +3 -0
- package/dist/auth/http/index.d.ts +64 -0
- package/dist/auth/http/index.js +461 -0
- package/dist/auth/index.d.ts +221 -0
- package/dist/auth/index.js +1398 -0
- package/dist/auth/nextjs/index.d.ts +50 -0
- package/dist/auth/nextjs/index.js +81 -0
- package/dist/auth-store-Cljlmdmi.js +197 -0
- package/dist/builder-CBdG5W6A.js +1974 -0
- package/dist/caller-factory-cTXNvYdz.js +216 -0
- package/dist/cli.mjs +13264 -0
- package/dist/codegen-lF80HSWu.mjs +3416 -0
- package/dist/context-utils-HPC5nXzx.d.ts +17 -0
- package/dist/create-schema-odyF4kCy.js +156 -0
- package/dist/create-schema-orm-DOyiNDCx.js +246 -0
- package/dist/crpc/index.d.ts +105 -0
- package/dist/crpc/index.js +169 -0
- package/dist/customFunctions-C0voKmtx.js +144 -0
- package/dist/error-BZEnI7Sq.js +41 -0
- package/dist/generated-contract-disabled-Cih4eITO.js +50 -0
- package/dist/generated-contract-disabled-D-sOFy92.d.ts +354 -0
- package/dist/http-types-DqJubRPJ.d.ts +292 -0
- package/dist/meta-utils-0Pu0Nrap.js +117 -0
- package/dist/middleware-BUybuv9n.d.ts +34 -0
- package/dist/middleware-C2qTZ3V7.js +84 -0
- package/dist/orm/index.d.ts +17 -0
- package/dist/orm/index.js +10713 -0
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.js +3 -0
- package/dist/procedure-caller-DtxLmGwA.d.ts +1467 -0
- package/dist/procedure-caller-MWcxhQDv.js +349 -0
- package/dist/query-context-B8o6-8kC.js +1518 -0
- package/dist/query-context-CFZqIvD7.d.ts +42 -0
- package/dist/query-options-Dw7cOyXl.js +121 -0
- package/dist/ratelimit/index.d.ts +269 -0
- package/dist/ratelimit/index.js +856 -0
- package/dist/ratelimit/react/index.d.ts +76 -0
- package/dist/ratelimit/react/index.js +183 -0
- package/dist/react/index.d.ts +1284 -0
- package/dist/react/index.js +2526 -0
- package/dist/rsc/index.d.ts +276 -0
- package/dist/rsc/index.js +233 -0
- package/dist/runtime-CtvJPkur.js +2453 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +6 -0
- package/dist/solid/index.d.ts +1221 -0
- package/dist/solid/index.js +2940 -0
- package/dist/transformer-DtDhR3Lc.js +194 -0
- package/dist/types-BTb_4BaU.d.ts +42 -0
- package/dist/types-BiJE7qxR.d.ts +4 -0
- package/dist/types-DEJpkIhw.d.ts +88 -0
- package/dist/types-HhO_R6pd.d.ts +213 -0
- package/dist/validators-B7oIJCAp.js +279 -0
- package/dist/validators-vzRKjBJC.d.ts +88 -0
- package/dist/watcher.mjs +96 -0
- package/dist/where-clause-compiler-DdjN63Io.d.ts +4756 -0
- package/package.json +107 -34
- package/skills/convex/SKILL.md +486 -0
- package/skills/convex/references/features/aggregates.md +353 -0
- package/skills/convex/references/features/auth-admin.md +446 -0
- package/skills/convex/references/features/auth-organizations.md +1141 -0
- package/skills/convex/references/features/auth-polar.md +579 -0
- package/skills/convex/references/features/auth.md +470 -0
- package/skills/convex/references/features/create-plugins.md +153 -0
- package/skills/convex/references/features/http.md +676 -0
- package/skills/convex/references/features/migrations.md +162 -0
- package/skills/convex/references/features/orm.md +1166 -0
- package/skills/convex/references/features/react.md +657 -0
- package/skills/convex/references/features/scheduling.md +267 -0
- package/skills/convex/references/features/testing.md +209 -0
- package/skills/convex/references/setup/auth.md +501 -0
- package/skills/convex/references/setup/biome.md +190 -0
- package/skills/convex/references/setup/doc-guidelines.md +145 -0
- package/skills/convex/references/setup/index.md +761 -0
- package/skills/convex/references/setup/next.md +116 -0
- package/skills/convex/references/setup/react.md +175 -0
- package/skills/convex/references/setup/server.md +473 -0
- package/skills/convex/references/setup/start.md +67 -0
- package/LICENSE +0 -21
- package/README.md +0 -0
- package/dist/index.d.mts +0 -5
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -6
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
# ORM Reference
|
|
2
|
+
|
|
3
|
+
Complete ORM API for feature work. Prerequisites: `setup/server.md`.
|
|
4
|
+
|
|
5
|
+
## Core Rules
|
|
6
|
+
|
|
7
|
+
1. `ctx.orm.query.*` for reads, `ctx.orm.insert/update/delete` for writes.
|
|
8
|
+
2. Keep list queries bounded (`limit`/cursor) and index-aware.
|
|
9
|
+
3. Use relations (`with`) for loading related data.
|
|
10
|
+
4. Put cross-row side effects in schema triggers.
|
|
11
|
+
5. Constraints (unique, FK, check) enforced by ORM mutations only — `ctx.db` bypasses them.
|
|
12
|
+
|
|
13
|
+
## Column Types
|
|
14
|
+
|
|
15
|
+
All from `kitcn/orm`. See [Column Types](#column-types-1) in API Reference.
|
|
16
|
+
|
|
17
|
+
### Column Modifiers
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
text().notNull(); // required on select, required on insert
|
|
21
|
+
text().default("draft"); // optional on insert, uses default
|
|
22
|
+
text().notNull().unique(); // unique constraint (runtime-enforced)
|
|
23
|
+
timestamp().defaultNow(); // shorthand for $defaultFn(() => new Date())
|
|
24
|
+
timestamp().$onUpdateFn(() => new Date()); // runs on update when field not explicitly set
|
|
25
|
+
json<T>().$type<T>(); // type-only override
|
|
26
|
+
text().$defaultFn(() => crypto.randomUUID()); // custom default
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Type Inference
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
type Post = typeof posts.$inferSelect; // Select type (fields are T | null unless .notNull())
|
|
33
|
+
type NewPost = typeof posts.$inferInsert; // Insert type (required if .notNull() + no default)
|
|
34
|
+
|
|
35
|
+
// Or with helpers:
|
|
36
|
+
import { InferSelectModel, InferInsertModel } from "kitcn/orm";
|
|
37
|
+
type Post = InferSelectModel<typeof posts>;
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Constraints
|
|
41
|
+
|
|
42
|
+
### Unique
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// Column-level
|
|
46
|
+
email: text().notNull().unique();
|
|
47
|
+
|
|
48
|
+
// Table-level unique index
|
|
49
|
+
import { uniqueIndex } from "kitcn/orm";
|
|
50
|
+
(t) => [uniqueIndex("users_email_unique").on(t.email)];
|
|
51
|
+
|
|
52
|
+
// Compound unique
|
|
53
|
+
import { unique } from "kitcn/orm";
|
|
54
|
+
(t) => [unique("full_name").on(t.firstName, t.lastName)];
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Foreign Keys
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// Column-level (.references)
|
|
61
|
+
authorId: id("users")
|
|
62
|
+
.notNull()
|
|
63
|
+
.references(() => users.id);
|
|
64
|
+
|
|
65
|
+
// With cascading actions
|
|
66
|
+
authorId: id("users")
|
|
67
|
+
.notNull()
|
|
68
|
+
.references(() => users.id, {
|
|
69
|
+
onDelete: "cascade", // cascade | set null | set default | restrict | no action
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Self-referencing (use AnyColumn return type)
|
|
73
|
+
import { type AnyColumn } from "kitcn/orm";
|
|
74
|
+
parentId: text().references((): AnyColumn => commentsTable.id, {
|
|
75
|
+
onDelete: "cascade",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Table-level (foreignKey builder, for non-id references)
|
|
79
|
+
import { foreignKey } from "kitcn/orm";
|
|
80
|
+
(t) => [foreignKey({ columns: [t.userSlug], foreignColumns: [users.slug] })];
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Check Constraints
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { check, gt, isNotNull } from "kitcn/orm";
|
|
87
|
+
(t) => [
|
|
88
|
+
check("age_over_18", gt(t.age, 18)),
|
|
89
|
+
check("email_present", isNotNull(t.email)),
|
|
90
|
+
];
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Indexes
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { index, searchIndex, vectorIndex } from 'kitcn/orm';
|
|
97
|
+
|
|
98
|
+
// Standard index
|
|
99
|
+
(t) => [index('by_author').on(t.authorId)]
|
|
100
|
+
|
|
101
|
+
// Search index (full-text)
|
|
102
|
+
(t) => [searchIndex('by_title').on(t.title).filter(t.authorId)]
|
|
103
|
+
|
|
104
|
+
// Vector index
|
|
105
|
+
(t) => [vectorIndex('embedding_vec').on(t.embedding).dimensions(1536).filter(t.authorId)]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Relations
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { defineSchema } from "kitcn/orm";
|
|
112
|
+
|
|
113
|
+
export default defineSchema({ users, posts, tags, postsTags }).relations(
|
|
114
|
+
(r) => ({
|
|
115
|
+
users: {
|
|
116
|
+
posts: r.many.posts(),
|
|
117
|
+
},
|
|
118
|
+
posts: {
|
|
119
|
+
author: r.one.users({ from: r.posts.authorId, to: r.users.id }),
|
|
120
|
+
// optional: false → non-nullable return type
|
|
121
|
+
// alias: 'author' → disambiguate multiple relations to same table
|
|
122
|
+
// where: { published: true } → predefined filter
|
|
123
|
+
},
|
|
124
|
+
// Many-to-many via join table
|
|
125
|
+
postsTags: {
|
|
126
|
+
post: r.one.posts({ from: r.postsTags.postId, to: r.posts.id }),
|
|
127
|
+
tag: r.one.tags({ from: r.postsTags.tagId, to: r.tags.id }),
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Plugin relation composition:
|
|
134
|
+
|
|
135
|
+
1. Extensions can expose `relations(...)`.
|
|
136
|
+
2. `defineSchema` merges extension relations first, then app `relations`.
|
|
137
|
+
3. Duplicate relation fields (`table.field`) throw.
|
|
138
|
+
|
|
139
|
+
### Many-to-many with `.through()`
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
users: {
|
|
143
|
+
groups: r.many.groups({
|
|
144
|
+
from: r.users.id.through(r.usersToGroups.userId),
|
|
145
|
+
to: r.groups.id.through(r.usersToGroups.groupId),
|
|
146
|
+
alias: 'users-groups-direct',
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Self-referencing
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
users: {
|
|
155
|
+
manager: r.one.users({ from: r.users.managerId, to: r.users.id, alias: 'manager' }),
|
|
156
|
+
reports: r.many.users({ from: r.users.id, to: r.users.managerId, alias: 'manager' }),
|
|
157
|
+
},
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Split relations (`defineRelationsPart`)
|
|
161
|
+
|
|
162
|
+
For large schemas, split relation definitions across modules and merge:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { defineRelationsPart } from "kitcn/orm";
|
|
166
|
+
const userRelations = defineRelationsPart({ users, posts }, (r) => ({
|
|
167
|
+
users: { posts: r.many.posts({ from: r.users.id, to: r.posts.authorId }) },
|
|
168
|
+
}));
|
|
169
|
+
// Merge into defineRelations
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Polymorphic associations
|
|
173
|
+
|
|
174
|
+
Polymorphism is schema-first via a discriminator column builder.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { boolean, convexTable, discriminator, id, index, integer, text } from 'kitcn/orm';
|
|
178
|
+
|
|
179
|
+
const auditLogs = convexTable(
|
|
180
|
+
'audit_logs',
|
|
181
|
+
{
|
|
182
|
+
timestamp: integer().notNull(),
|
|
183
|
+
actionType: discriminator({
|
|
184
|
+
as: 'details', // optional, default "details"
|
|
185
|
+
variants: {
|
|
186
|
+
role_change: {
|
|
187
|
+
targetUserId: id('users'),
|
|
188
|
+
oldRole: text().notNull(),
|
|
189
|
+
newRole: text().notNull(),
|
|
190
|
+
},
|
|
191
|
+
document_update: {
|
|
192
|
+
documentId: id('documents'),
|
|
193
|
+
version: integer().notNull(),
|
|
194
|
+
changes: text().notNull(),
|
|
195
|
+
},
|
|
196
|
+
security_alert: {
|
|
197
|
+
severity: text().notNull(),
|
|
198
|
+
errorCode: text().notNull(),
|
|
199
|
+
isResolved: boolean().notNull(),
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
(t) => [
|
|
205
|
+
index('by_action_ts').on(t.actionType, t.timestamp),
|
|
206
|
+
index('by_role_target').on(t.actionType, t.targetUserId),
|
|
207
|
+
index('by_doc').on(t.actionType, t.documentId),
|
|
208
|
+
]
|
|
209
|
+
);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Behavior:
|
|
213
|
+
- Storage and writes are flat (`actionType`, `targetUserId`, `documentId`, ...)
|
|
214
|
+
- Reads synthesize nested discriminated data at `details` (or custom `as`)
|
|
215
|
+
- `withVariants: true` auto-loads all `one()` relations on discriminator tables
|
|
216
|
+
- Generated variant fields are normal top-level refs for indexes/filters (`t.targetUserId`)
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const rows = await ctx.orm.query.audit_logs.findMany({
|
|
220
|
+
limit: 20,
|
|
221
|
+
withVariants: true,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
if (row.actionType === 'role_change') {
|
|
226
|
+
row.details.targetUserId;
|
|
227
|
+
row.details.oldRole;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Rules:
|
|
233
|
+
- One `discriminator(...)` discriminator column per table (current limit)
|
|
234
|
+
- Variant keys become discriminator literals
|
|
235
|
+
- Variant fields are generated as nullable physical columns
|
|
236
|
+
- Variant `.notNull()` means required in that branch only
|
|
237
|
+
- Duplicate field names across variants require identical builder signatures
|
|
238
|
+
- Alias (`as`) cannot collide with columns, relations, `with`, or `extras`
|
|
239
|
+
- Query config does not include a `polymorphic` option; polymorphism is defined in schema columns.
|
|
240
|
+
|
|
241
|
+
### Relation indexing requirements
|
|
242
|
+
|
|
243
|
+
- `many()` → index child FK field (e.g., `posts.userId`)
|
|
244
|
+
- `.through()` → index junction table FK fields (both directions)
|
|
245
|
+
- `one()` with `to: ...id` → uses `db.get()` (no extra index)
|
|
246
|
+
- Missing index throws unless `allowFullScan` on parent query
|
|
247
|
+
|
|
248
|
+
## Schema Definition
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
import {
|
|
252
|
+
defineSchema,
|
|
253
|
+
} from "kitcn/orm";
|
|
254
|
+
|
|
255
|
+
// defineSchema takes tables map (not relations)
|
|
256
|
+
export default defineSchema(tables, {
|
|
257
|
+
strict: false, // false = warn instead of throw on missing indexes
|
|
258
|
+
defaults: {
|
|
259
|
+
defaultLimit: 100, // default limit for findMany
|
|
260
|
+
mutationBatchSize: 100, // page size for mutation row collection
|
|
261
|
+
mutationMaxRows: 1000, // sync-mode hard cap
|
|
262
|
+
mutationLeafBatchSize: 900, // async FK fan-out batch size
|
|
263
|
+
mutationMaxBytesPerBatch: 2_097_152, // async measured-byte budget
|
|
264
|
+
mutationScheduleCallCap: 100, // async schedule calls per mutation
|
|
265
|
+
mutationExecutionMode: "async", // default when codegen wiring present; use 'sync' to opt out
|
|
266
|
+
mutationAsyncDelayMs: 0,
|
|
267
|
+
relationFanOutMaxKeys: 1000,
|
|
268
|
+
},
|
|
269
|
+
}).relations((r) => ({
|
|
270
|
+
users: {
|
|
271
|
+
posts: r.many.posts(),
|
|
272
|
+
},
|
|
273
|
+
posts: {
|
|
274
|
+
author: r.one.users({ from: r.posts.authorId, to: r.users.id }),
|
|
275
|
+
},
|
|
276
|
+
}));
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Queries
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// findMany with full options
|
|
283
|
+
const posts = await ctx.orm.query.posts.findMany({
|
|
284
|
+
where: { authorId: ctx.userId, status: "published" },
|
|
285
|
+
orderBy: { createdAt: "desc" },
|
|
286
|
+
limit: 20,
|
|
287
|
+
columns: { id: true, title: true, createdAt: true },
|
|
288
|
+
with: { author: true, tags: { limit: 5 } },
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// findFirst / findFirstOrThrow
|
|
292
|
+
const post = await ctx.orm.query.posts.findFirst({ where: { id: input.id } });
|
|
293
|
+
const post = await ctx.orm.query.posts.findFirstOrThrow({
|
|
294
|
+
where: { id: input.id },
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Cursor pagination
|
|
298
|
+
const page = await ctx.orm.query.posts.findMany({
|
|
299
|
+
where: { published: true },
|
|
300
|
+
orderBy: { createdAt: "desc" },
|
|
301
|
+
cursor: input.cursor ?? null,
|
|
302
|
+
limit: 20,
|
|
303
|
+
});
|
|
304
|
+
// Returns: { page, continueCursor, isDone }
|
|
305
|
+
|
|
306
|
+
// Extras (computed fields, post-fetch)
|
|
307
|
+
const users = await ctx.orm.query.users.findMany({
|
|
308
|
+
extras: { emailDomain: (row) => row.email.split("@")[1]! },
|
|
309
|
+
limit: 50,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// System tables (raw Convex, not ORM)
|
|
313
|
+
const job = await ctx.orm.system.get(jobId);
|
|
314
|
+
const files = await ctx.orm.system.query("_storage").take(20);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### allowFullScan
|
|
318
|
+
|
|
319
|
+
Non-paginated `findMany()` requires sizing: `limit`, `cursor + limit`, `allowFullScan`, or `defaults.defaultLimit`.
|
|
320
|
+
|
|
321
|
+
### distinct (`findMany` unsupported)
|
|
322
|
+
|
|
323
|
+
`findMany({ distinct })` is not available to preserve strict no-scan/index-backed guarantees.
|
|
324
|
+
|
|
325
|
+
Use select-pipeline distinct instead:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
const page = await ctx.orm.query.todos
|
|
329
|
+
.select()
|
|
330
|
+
.where({ projectId })
|
|
331
|
+
.distinct({ fields: ['status'] })
|
|
332
|
+
.paginate({ cursor: null, limit: 100 });
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Filtering + Pagination
|
|
336
|
+
|
|
337
|
+
| Query mode | Index required? | Pagination | Ordering |
|
|
338
|
+
| -------------------------------- | --------------------------------------------- | ------------------------------------------- | --------------- |
|
|
339
|
+
| `findMany({ where: object })` | Optional (planner uses indexes when possible) | `limit/offset`, `cursor + limit` | `orderBy` |
|
|
340
|
+
| `findMany({ where: callback })` | Optional (planner uses indexes when possible) | `limit/offset`, `cursor + limit` | `orderBy` |
|
|
341
|
+
| `findMany({ where: predicate })` | **Required** `.withIndex(name, range?)` | `cursor + limit`, optional `maxScan` | Index-backed |
|
|
342
|
+
| `findMany({ search })` | **Required** `searchIndex` | `limit/offset`, `cursor + limit` | Relevance only |
|
|
343
|
+
| `findMany({ vectorSearch })` | **Required** `vectorIndex` | `vectorSearch.limit` only | Similarity only |
|
|
344
|
+
| `select()` composition | Schema + index per source | `cursor + limit` (+ `endCursor`, `maxScan`) | Stream-backed |
|
|
345
|
+
|
|
346
|
+
### How to choose
|
|
347
|
+
|
|
348
|
+
1. Need relevance-ranked text search? → `search`
|
|
349
|
+
2. Need vector similarity? → `vectorSearch`
|
|
350
|
+
3. Need relation-aware filtering? → object `where`
|
|
351
|
+
4. Need Drizzle callback syntax? → callback `where`
|
|
352
|
+
5. Need custom JS predicate? → `predicate(...)` + `.withIndex(...)`
|
|
353
|
+
6. Need union/interleave/map/filter/flatMap/distinct before pagination? → `select()` composition
|
|
354
|
+
|
|
355
|
+
### Object `where` (Default)
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
const admins = await ctx.orm.query.users.findMany({
|
|
359
|
+
where: {
|
|
360
|
+
role: "admin",
|
|
361
|
+
age: { gt: 18 },
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
See [Operators](#operators-1) in API Reference.
|
|
367
|
+
|
|
368
|
+
Index-compiled: `eq`, `ne`, `in`, `notIn`, `isNull`, `isNotNull`, `between`, `notBetween`, `startsWith`, `like('prefix%')`.
|
|
369
|
+
Post-fetch: everything else. Require `.withIndex(...)` in typed API to make scan scope deliberate.
|
|
370
|
+
|
|
371
|
+
### Relation filters
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
// Users with posts
|
|
375
|
+
await ctx.orm.query.users.findMany({ where: { posts: true } });
|
|
376
|
+
|
|
377
|
+
// Users with no posts
|
|
378
|
+
await ctx.orm.query.users.findMany({ where: { NOT: { posts: true } } });
|
|
379
|
+
|
|
380
|
+
// Nested relation filter
|
|
381
|
+
await ctx.orm.query.users.findMany({
|
|
382
|
+
where: { posts: { title: { like: "A%" } } },
|
|
383
|
+
});
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Logical combinators
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
await ctx.orm.query.users.findMany({
|
|
390
|
+
where: {
|
|
391
|
+
OR: [{ role: "admin" }, { role: "premium" }],
|
|
392
|
+
NOT: { email: { isNull: true } },
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Callback `where` (Drizzle Style)
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
const admins = await ctx.orm.query.users.findMany({
|
|
401
|
+
where: (users, { and, eq, isNotNull }) =>
|
|
402
|
+
and(eq(users.role, "admin"), isNotNull(users.email)),
|
|
403
|
+
});
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Same planner as object `where` — can use indexes when possible.
|
|
407
|
+
|
|
408
|
+
### Predicate `where` (Explicit Index Required)
|
|
409
|
+
|
|
410
|
+
For complex JS logic. Must call `.withIndex(...)` first.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
return await ctx.orm.query.characters
|
|
414
|
+
.withIndex("private", (q) => q.eq("private", false))
|
|
415
|
+
.findMany({
|
|
416
|
+
where: (_characters, { predicate }) =>
|
|
417
|
+
predicate((char) => {
|
|
418
|
+
if (input.category && !char.categories?.includes(input.category))
|
|
419
|
+
return false;
|
|
420
|
+
if (input.minScore && char.score < input.minScore) return false;
|
|
421
|
+
return true;
|
|
422
|
+
}),
|
|
423
|
+
cursor: input.cursor,
|
|
424
|
+
limit: input.limit,
|
|
425
|
+
maxScan: 500,
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Use `maxScan` (cursor mode only) to cap scan size.
|
|
430
|
+
|
|
431
|
+
### Mutation `where` (Filter Expressions)
|
|
432
|
+
|
|
433
|
+
Mutation builders use operator helpers with column builders:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
import { and, eq, gt } from "kitcn/orm";
|
|
437
|
+
|
|
438
|
+
await ctx.orm
|
|
439
|
+
.update(users)
|
|
440
|
+
.set({ role: "admin" })
|
|
441
|
+
.where(and(eq(users.role, "member"), gt(users.age, 18)));
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Helpers: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `inArray`, `notInArray`, `and`, `or`, `not`, `isNull`, `isNotNull`.
|
|
445
|
+
|
|
446
|
+
## Full-Text Search
|
|
447
|
+
|
|
448
|
+
Each search index searches ONE field with optional equality filter fields.
|
|
449
|
+
|
|
450
|
+
### Search schema
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
import {
|
|
454
|
+
convexTable,
|
|
455
|
+
defineSchema,
|
|
456
|
+
searchIndex,
|
|
457
|
+
text,
|
|
458
|
+
} from "kitcn/orm";
|
|
459
|
+
|
|
460
|
+
export const articles = convexTable(
|
|
461
|
+
"articles",
|
|
462
|
+
{
|
|
463
|
+
title: text().notNull(),
|
|
464
|
+
content: text().notNull(),
|
|
465
|
+
author: text().notNull(),
|
|
466
|
+
category: text().notNull(),
|
|
467
|
+
},
|
|
468
|
+
(t) => [
|
|
469
|
+
searchIndex("search_content").on(t.content).filter(t.category, t.author),
|
|
470
|
+
searchIndex("search_title").on(t.title),
|
|
471
|
+
]
|
|
472
|
+
);
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Basic search
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
const results = await ctx.orm.query.articles.findMany({
|
|
479
|
+
search: { index: "search_content", query: input.query },
|
|
480
|
+
limit: input.limit,
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### Search with filters
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
const results = await ctx.orm.query.articles.findMany({
|
|
488
|
+
search: {
|
|
489
|
+
index: "search_content",
|
|
490
|
+
query: input.query,
|
|
491
|
+
filters: {
|
|
492
|
+
category: input.category,
|
|
493
|
+
...(input.author ? { author: input.author } : {}),
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
limit: 20,
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### Paginated search
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
return await ctx.orm.query.articles.findMany({
|
|
504
|
+
search: {
|
|
505
|
+
index: "search_content",
|
|
506
|
+
query: input.query,
|
|
507
|
+
filters: input.category ? { category: input.category } : undefined,
|
|
508
|
+
},
|
|
509
|
+
cursor: input.cursor,
|
|
510
|
+
limit: input.limit,
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Search constraints
|
|
515
|
+
|
|
516
|
+
- `orderBy` not allowed (Convex relevance ordering)
|
|
517
|
+
- Callback `where` not allowed
|
|
518
|
+
- Relation `where` not allowed
|
|
519
|
+
- Object `where` on base table fields is allowed (post-search filter)
|
|
520
|
+
- `with:` allowed for eager loading
|
|
521
|
+
|
|
522
|
+
## Select Composition (Advanced)
|
|
523
|
+
|
|
524
|
+
`select()` is the stream-style composition API. Use when you need pre-pagination transforms.
|
|
525
|
+
|
|
526
|
+
### Union + interleave (merged-stream equivalent)
|
|
527
|
+
|
|
528
|
+
```ts
|
|
529
|
+
return await ctx.orm.query.messages
|
|
530
|
+
.withIndex("by_from_to")
|
|
531
|
+
.select()
|
|
532
|
+
.union([
|
|
533
|
+
{ where: { from: input.me, to: input.them } },
|
|
534
|
+
{ where: { from: input.them, to: input.me } },
|
|
535
|
+
])
|
|
536
|
+
.interleaveBy(["createdAt", "id"])
|
|
537
|
+
.filter(async (m) => !m.deletedAt)
|
|
538
|
+
.map(async (m) => ({ ...m, body: m.body.slice(0, 240) }))
|
|
539
|
+
.paginate({
|
|
540
|
+
cursor: input.cursor,
|
|
541
|
+
limit: input.limit,
|
|
542
|
+
maxScan: 500,
|
|
543
|
+
});
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### Union with index ranges
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
const page = await ctx.orm.query.messages
|
|
550
|
+
.select()
|
|
551
|
+
.union([
|
|
552
|
+
{
|
|
553
|
+
index: {
|
|
554
|
+
name: "by_from_to",
|
|
555
|
+
range: (q) => q.eq("from", me).eq("to", them),
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
index: {
|
|
560
|
+
name: "by_from_to",
|
|
561
|
+
range: (q) => q.eq("from", them).eq("to", me),
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
])
|
|
565
|
+
.interleaveBy(["createdAt", "id"])
|
|
566
|
+
.paginate({ cursor: null, limit: 20 });
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### Pre-pagination transforms
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
const page = await ctx.orm.query.messages
|
|
573
|
+
.select()
|
|
574
|
+
.filter(async (m) => !m.deletedAt)
|
|
575
|
+
.map(async (m) => ({ ...m, preview: m.body.slice(0, 120) }))
|
|
576
|
+
.distinct({ fields: ["channelId"] })
|
|
577
|
+
.paginate({ cursor: null, limit: 20, maxScan: 500 });
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### flatMap (relation join)
|
|
581
|
+
|
|
582
|
+
```ts
|
|
583
|
+
const page = await ctx.orm.query.users
|
|
584
|
+
.select()
|
|
585
|
+
.flatMap("posts", { includeParent: true })
|
|
586
|
+
.paginate({ cursor: null, limit: 20 });
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
See [Select Composition Limitations](#select-composition-limitations) in API Reference.
|
|
590
|
+
|
|
591
|
+
## Pagination Modes
|
|
592
|
+
|
|
593
|
+
| Mode | API | Best for |
|
|
594
|
+
| ----------- | ---------------------------------------- | ---------------------------------------- |
|
|
595
|
+
| Offset | `findMany({ offset, limit })` | Page-number UIs, small datasets |
|
|
596
|
+
| Cursor | `findMany({ cursor, limit })` | Infinite scroll, large lists |
|
|
597
|
+
| Composition | `select()...paginate({ cursor, limit })` | Stream-like transforms before pagination |
|
|
598
|
+
| Key-based | `findMany({ pageByKey })` | Deterministic key boundaries |
|
|
599
|
+
|
|
600
|
+
### Cursor pagination
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
const page1 = await ctx.orm.query.posts.findMany({
|
|
604
|
+
where: { published: true },
|
|
605
|
+
orderBy: { createdAt: "desc" },
|
|
606
|
+
cursor: null,
|
|
607
|
+
limit: 20,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Next page
|
|
611
|
+
const page2 = await ctx.orm.query.posts.findMany({
|
|
612
|
+
where: { published: true },
|
|
613
|
+
orderBy: { createdAt: "desc" },
|
|
614
|
+
cursor: page1.continueCursor,
|
|
615
|
+
limit: 20,
|
|
616
|
+
});
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Return: `{ page, continueCursor, isDone, pageStatus?, splitCursor? }`
|
|
620
|
+
|
|
621
|
+
### Boundary pinning with `endCursor`
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
const refreshed = await ctx.orm.query.posts.findMany({
|
|
625
|
+
where: { published: true },
|
|
626
|
+
orderBy: { createdAt: "desc" },
|
|
627
|
+
cursor: null,
|
|
628
|
+
endCursor: page1.continueCursor,
|
|
629
|
+
limit: 20,
|
|
630
|
+
});
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### Key-based paging (`pageByKey`)
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
const first = await ctx.orm.query.messages.findMany({
|
|
637
|
+
pageByKey: {
|
|
638
|
+
index: "by_channel",
|
|
639
|
+
order: "asc",
|
|
640
|
+
targetMaxRows: 100,
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const second = await ctx.orm.query.messages.findMany({
|
|
645
|
+
pageByKey: {
|
|
646
|
+
index: "by_channel",
|
|
647
|
+
order: "asc",
|
|
648
|
+
startKey: first.indexKeys[99],
|
|
649
|
+
targetMaxRows: 100,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Return: `{ page, indexKeys, hasMore }`
|
|
655
|
+
|
|
656
|
+
### Combining Search and Complex Filters
|
|
657
|
+
|
|
658
|
+
Search mode supports `search.filters` plus base-table object `where`. For predicate/relation `where`:
|
|
659
|
+
|
|
660
|
+
**Option 1: Add more filterFields** (recommended)
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
searchIndex("search_content")
|
|
664
|
+
.on(t.content)
|
|
665
|
+
.filter(t.category, t.author, t.status, t.dateGroup);
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
**Option 2: Separate query paths**
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
if (input.query) {
|
|
672
|
+
// Search path — limited filtering
|
|
673
|
+
return await ctx.orm.query.articles.findMany({
|
|
674
|
+
search: { index: 'search_content', query: input.query, filters: ... },
|
|
675
|
+
cursor: input.cursor,
|
|
676
|
+
limit: input.limit,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Predicate path — full filtering with explicit .withIndex(...)
|
|
681
|
+
return await ctx.orm.query.articles
|
|
682
|
+
.withIndex('by_creation_time')
|
|
683
|
+
.findMany({
|
|
684
|
+
where: (_articles, { predicate }) =>
|
|
685
|
+
predicate((article) => {
|
|
686
|
+
if (input.category && article.category !== input.category) return false;
|
|
687
|
+
if (input.startDate && article.publishedAt < input.startDate) return false;
|
|
688
|
+
return true;
|
|
689
|
+
}),
|
|
690
|
+
cursor: input.cursor,
|
|
691
|
+
limit: input.limit,
|
|
692
|
+
});
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
**Option 3: Post-process** (small datasets only)
|
|
696
|
+
|
|
697
|
+
```ts
|
|
698
|
+
const results = await ctx.orm.query.articles.findMany({
|
|
699
|
+
search: { index: "search_content", query },
|
|
700
|
+
limit: 100,
|
|
701
|
+
});
|
|
702
|
+
const filtered = results.filter((a) => a.publishedAt >= startDate);
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
### Performance
|
|
706
|
+
|
|
707
|
+
1. **Index first** — constrain leading index fields. Compound indexes follow prefix rules.
|
|
708
|
+
2. **Bound scans** — use `maxScan` for predicate `where` (cursor mode only).
|
|
709
|
+
3. **Limit results** — always use `limit` or cursor pagination.
|
|
710
|
+
4. **Cursor stability** — keep same `where`/`orderBy` between page requests.
|
|
711
|
+
5. **`allowFullScan`** — non-cursor only. Cursor mode uses `maxScan` instead.
|
|
712
|
+
6. **Strict mode** — `strict: true` throws on missing `maxScan` for scan-fallback plans; `strict: false` warns.
|
|
713
|
+
7. **Search overhead** — don't over-index. Use `filterFields` to narrow before text matching.
|
|
714
|
+
|
|
715
|
+
See [Full-Scan Operator Workarounds](#full-scan-operator-workarounds-1) in API Reference.
|
|
716
|
+
|
|
717
|
+
## Mutations
|
|
718
|
+
|
|
719
|
+
### Insert
|
|
720
|
+
|
|
721
|
+
```ts
|
|
722
|
+
import { user } from "./schema";
|
|
723
|
+
|
|
724
|
+
// Basic
|
|
725
|
+
await ctx.orm.insert(user).values({ name: "Ada", email: "ada@domain.test" });
|
|
726
|
+
|
|
727
|
+
// Multi-row
|
|
728
|
+
await ctx.orm.insert(user).values([
|
|
729
|
+
{ name: "A", email: "a@domain.test" },
|
|
730
|
+
{ name: "B", email: "b@domain.test" },
|
|
731
|
+
]);
|
|
732
|
+
|
|
733
|
+
// Returning
|
|
734
|
+
const [row] = await ctx.orm
|
|
735
|
+
.insert(user)
|
|
736
|
+
.values({ name: "Ada", email: "ada@domain.test" })
|
|
737
|
+
.returning(); // all fields
|
|
738
|
+
|
|
739
|
+
const [partial] = await ctx.orm
|
|
740
|
+
.insert(user)
|
|
741
|
+
.values({ name: "Ada", email: "ada@domain.test" })
|
|
742
|
+
.returning({ id: user.id, email: user.email });
|
|
743
|
+
|
|
744
|
+
// Upsert: onConflictDoUpdate
|
|
745
|
+
await ctx.orm
|
|
746
|
+
.insert(user)
|
|
747
|
+
.values({ email: "ada@domain.test", name: "Ada" })
|
|
748
|
+
.onConflictDoUpdate({ target: user.email, set: { name: "Ada Lovelace" } });
|
|
749
|
+
|
|
750
|
+
// Skip on conflict
|
|
751
|
+
await ctx.orm
|
|
752
|
+
.insert(user)
|
|
753
|
+
.values({ email: "ada@domain.test", name: "Ada" })
|
|
754
|
+
.onConflictDoNothing({ target: user.email });
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
### Update
|
|
758
|
+
|
|
759
|
+
```ts
|
|
760
|
+
import { eq } from "kitcn/orm";
|
|
761
|
+
import { user } from "./schema";
|
|
762
|
+
|
|
763
|
+
// Basic
|
|
764
|
+
await ctx.orm
|
|
765
|
+
.update(user)
|
|
766
|
+
.set({ name: "Updated" })
|
|
767
|
+
.where(eq(user.id, input.id));
|
|
768
|
+
|
|
769
|
+
// Returning
|
|
770
|
+
const [updated] = await ctx.orm
|
|
771
|
+
.update(user)
|
|
772
|
+
.set({ name: "New" })
|
|
773
|
+
.where(eq(user.id, input.id))
|
|
774
|
+
.returning();
|
|
775
|
+
|
|
776
|
+
// Unset a field
|
|
777
|
+
import { unsetToken } from "kitcn/orm";
|
|
778
|
+
await ctx.orm
|
|
779
|
+
.update(user)
|
|
780
|
+
.set({ nickname: unsetToken })
|
|
781
|
+
.where(eq(user.id, input.id));
|
|
782
|
+
|
|
783
|
+
// Update without .where() throws — use .allowFullScan() to opt in
|
|
784
|
+
await ctx.orm.update(user).set({ role: "member" }).allowFullScan();
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### Delete
|
|
788
|
+
|
|
789
|
+
```ts
|
|
790
|
+
await ctx.orm.delete(user).where(eq(user.id, input.id));
|
|
791
|
+
|
|
792
|
+
// Returning
|
|
793
|
+
const [deleted] = await ctx.orm
|
|
794
|
+
.delete(user)
|
|
795
|
+
.where(eq(user.id, input.id))
|
|
796
|
+
.returning();
|
|
797
|
+
|
|
798
|
+
// Delete all (use with care)
|
|
799
|
+
await ctx.orm.delete(user).allowFullScan();
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### Delete Modes
|
|
803
|
+
|
|
804
|
+
```ts
|
|
805
|
+
// Table-level default
|
|
806
|
+
import { deletion } from "kitcn/orm";
|
|
807
|
+
const user = convexTable(
|
|
808
|
+
"user",
|
|
809
|
+
{
|
|
810
|
+
slug: text().notNull(),
|
|
811
|
+
deletionTime: integer(),
|
|
812
|
+
},
|
|
813
|
+
() => [deletion("scheduled", { delayMs: 60_000 })]
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
// Per-query overrides
|
|
817
|
+
await ctx.orm.delete(user).where(eq(user.id, id)).hard(); // immediate
|
|
818
|
+
await ctx.orm.delete(user).where(eq(user.id, id)).soft(); // mark deleted
|
|
819
|
+
await ctx.orm
|
|
820
|
+
.delete(user)
|
|
821
|
+
.where(eq(user.id, id))
|
|
822
|
+
.scheduled({ delayMs: 60_000 });
|
|
823
|
+
|
|
824
|
+
// Cancel scheduled delete: clear/change deletionTime before worker runs
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
### Paginated Mutations
|
|
828
|
+
|
|
829
|
+
For large workloads exceeding safety limits:
|
|
830
|
+
|
|
831
|
+
```ts
|
|
832
|
+
// Requires index on filtered field: index('by_role').on(t.role)
|
|
833
|
+
const page1 = await ctx.orm
|
|
834
|
+
.update(user)
|
|
835
|
+
.set({ role: "member" })
|
|
836
|
+
.where(eq(user.role, "pending"))
|
|
837
|
+
.paginate({ cursor: null, limit: 100 });
|
|
838
|
+
// Returns: { continueCursor, isDone, numAffected }
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
### Async Batched Mutations
|
|
842
|
+
|
|
843
|
+
Async is the default — first batch runs inline, remaining auto-scheduled. Customize per call:
|
|
844
|
+
|
|
845
|
+
```ts
|
|
846
|
+
await ctx.orm
|
|
847
|
+
.update(user)
|
|
848
|
+
.set({ role: "member" })
|
|
849
|
+
.where(eq(user.role, "pending"))
|
|
850
|
+
.execute({ batchSize: 200, delayMs: 0 });
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
To force sync (all rows in one transaction): `.execute({ mode: 'sync' })` or `defineSchema(tables, { defaults: { mutationExecutionMode: "sync" } })`.
|
|
854
|
+
|
|
855
|
+
## RLS (Row-Level Security)
|
|
856
|
+
|
|
857
|
+
### Define policies
|
|
858
|
+
|
|
859
|
+
```ts
|
|
860
|
+
import { convexTable, rlsPolicy, text, id, eq } from "kitcn/orm";
|
|
861
|
+
|
|
862
|
+
export const secrets = convexTable.withRLS(
|
|
863
|
+
"secrets",
|
|
864
|
+
{
|
|
865
|
+
value: text().notNull(),
|
|
866
|
+
ownerId: id("users").notNull(),
|
|
867
|
+
},
|
|
868
|
+
(t) => [
|
|
869
|
+
rlsPolicy("read_own", {
|
|
870
|
+
for: "select",
|
|
871
|
+
using: (ctx) => eq(t.ownerId, ctx.viewerId),
|
|
872
|
+
}),
|
|
873
|
+
rlsPolicy("insert_own", {
|
|
874
|
+
for: "insert",
|
|
875
|
+
withCheck: (ctx) => eq(t.ownerId, ctx.viewerId),
|
|
876
|
+
}),
|
|
877
|
+
rlsPolicy("update_own", {
|
|
878
|
+
for: "update",
|
|
879
|
+
using: (ctx) => eq(t.ownerId, ctx.viewerId),
|
|
880
|
+
withCheck: (ctx) => eq(t.ownerId, ctx.viewerId),
|
|
881
|
+
}),
|
|
882
|
+
rlsPolicy("delete_own", {
|
|
883
|
+
for: "delete",
|
|
884
|
+
using: (ctx) => eq(t.ownerId, ctx.viewerId),
|
|
885
|
+
}),
|
|
886
|
+
]
|
|
887
|
+
);
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
### Policy operations
|
|
891
|
+
|
|
892
|
+
| Operation | Clause | When |
|
|
893
|
+
| --------- | --------------------- | ------------------------------- |
|
|
894
|
+
| `select` | `using` | Filters rows after fetch |
|
|
895
|
+
| `insert` | `withCheck` | Validates new rows before write |
|
|
896
|
+
| `update` | `using` + `withCheck` | Filters existing, validates new |
|
|
897
|
+
| `delete` | `using` | Filters rows before delete |
|
|
898
|
+
|
|
899
|
+
### Bypass RLS
|
|
900
|
+
|
|
901
|
+
```ts
|
|
902
|
+
await ctx.orm.skipRules.query.secrets.findMany();
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
### Roles
|
|
906
|
+
|
|
907
|
+
```ts
|
|
908
|
+
import { rlsRole } from "kitcn/orm";
|
|
909
|
+
const admin = rlsRole("admin");
|
|
910
|
+
rlsPolicy("admin_only", {
|
|
911
|
+
for: "select",
|
|
912
|
+
to: admin,
|
|
913
|
+
using: (ctx, t) => eq(t.ownerId, ctx.viewerId),
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// Provide roleResolver
|
|
917
|
+
const ormDb = orm.db(ctx, {
|
|
918
|
+
rls: { ctx, roleResolver: (ctx) => ctx.roles ?? [] },
|
|
919
|
+
});
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
**Important:** `ctx.db` bypasses RLS. Only `ctx.orm` enforces policies. FK cascade fan-out also bypasses child-table RLS.
|
|
923
|
+
|
|
924
|
+
## Triggers
|
|
925
|
+
|
|
926
|
+
Schema-level hooks live on the default schema export via `.triggers(...)`. Trigger definitions are schema-level only; `convexTable(..., extraConfig)` no longer accepts trigger callbacks.
|
|
927
|
+
|
|
928
|
+
```ts
|
|
929
|
+
export default defineSchema({ comments, posts })
|
|
930
|
+
.relations((r) => ({
|
|
931
|
+
comments: {
|
|
932
|
+
post: r.one.posts({ from: r.comments.postId, to: r.posts.id }),
|
|
933
|
+
},
|
|
934
|
+
posts: {
|
|
935
|
+
comments: r.many.comments(),
|
|
936
|
+
},
|
|
937
|
+
}))
|
|
938
|
+
.triggers({
|
|
939
|
+
comments: {
|
|
940
|
+
create: {
|
|
941
|
+
after: async (doc, ctx) => {
|
|
942
|
+
await ctx.orm
|
|
943
|
+
.update(posts)
|
|
944
|
+
.set({ lastCommentAt: new Date() })
|
|
945
|
+
.where(eq(posts.id, doc.postId));
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
delete: {
|
|
949
|
+
after: async (doc, ctx) => {
|
|
950
|
+
await ctx.orm
|
|
951
|
+
.update(posts)
|
|
952
|
+
.set({ lastCommentAt: new Date() })
|
|
953
|
+
.where(eq(posts.id, doc.postId));
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
### change payload
|
|
961
|
+
|
|
962
|
+
```ts
|
|
963
|
+
export default defineSchema({ comments })
|
|
964
|
+
.relations(() => ({
|
|
965
|
+
comments: {},
|
|
966
|
+
}))
|
|
967
|
+
.triggers({
|
|
968
|
+
comments: {
|
|
969
|
+
change: async (change, ctx) => {
|
|
970
|
+
change.id; // always present
|
|
971
|
+
change.operation; // 'insert' | 'update' | 'delete'
|
|
972
|
+
change.oldDoc; // null on insert
|
|
973
|
+
change.newDoc; // null on delete
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
### Aggregate triggers
|
|
980
|
+
|
|
981
|
+
```ts
|
|
982
|
+
import { aggregatePostLikes } from "./aggregates";
|
|
983
|
+
|
|
984
|
+
export default defineSchema({ postLikes })
|
|
985
|
+
.relations(() => ({
|
|
986
|
+
postLikes: {},
|
|
987
|
+
}))
|
|
988
|
+
.triggers({
|
|
989
|
+
postLikes: {
|
|
990
|
+
change: aggregatePostLikes.trigger,
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
### `withoutTriggers`
|
|
996
|
+
|
|
997
|
+
Bypass all trigger hooks for a block of operations (bulk resets, migrations, seeding):
|
|
998
|
+
|
|
999
|
+
```ts
|
|
1000
|
+
await ctx.orm.withoutTriggers(async (orm) => {
|
|
1001
|
+
await orm.delete(todosTable).allowFullScan();
|
|
1002
|
+
});
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### Trigger safety checklist
|
|
1006
|
+
|
|
1007
|
+
1. Idempotent logic.
|
|
1008
|
+
2. Bounded writes (no full-scan loops).
|
|
1009
|
+
3. No recursive ping-pong between tables.
|
|
1010
|
+
4. Expensive work → keep triggers thin; enqueue background work from procedure layer via `caller.schedule.*`.
|
|
1011
|
+
5. Auth checks in procedure layer; triggers focus on data invariants.
|
|
1012
|
+
|
|
1013
|
+
### Auth triggers vs DB triggers
|
|
1014
|
+
|
|
1015
|
+
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.
|
|
1016
|
+
|
|
1017
|
+
## Complete Schema Template
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
import {
|
|
1021
|
+
boolean,
|
|
1022
|
+
check,
|
|
1023
|
+
convexTable,
|
|
1024
|
+
defineRelations,
|
|
1025
|
+
defineSchema,
|
|
1026
|
+
deletion,
|
|
1027
|
+
eq,
|
|
1028
|
+
id,
|
|
1029
|
+
index,
|
|
1030
|
+
integer,
|
|
1031
|
+
json,
|
|
1032
|
+
searchIndex,
|
|
1033
|
+
text,
|
|
1034
|
+
textEnum,
|
|
1035
|
+
timestamp,
|
|
1036
|
+
uniqueIndex,
|
|
1037
|
+
} from "kitcn/orm";
|
|
1038
|
+
|
|
1039
|
+
export const user = convexTable("user", {
|
|
1040
|
+
name: text().notNull(),
|
|
1041
|
+
email: text().notNull().unique(),
|
|
1042
|
+
role: textEnum(["admin", "user"] as const)
|
|
1043
|
+
.notNull()
|
|
1044
|
+
.default("user"),
|
|
1045
|
+
plan: text(),
|
|
1046
|
+
banned: boolean(),
|
|
1047
|
+
createdAt: timestamp().notNull().defaultNow(),
|
|
1048
|
+
updatedAt: timestamp()
|
|
1049
|
+
.notNull()
|
|
1050
|
+
.defaultNow()
|
|
1051
|
+
.$onUpdateFn(() => new Date()),
|
|
1052
|
+
metadata: json<Record<string, unknown>>(),
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
export const post = convexTable(
|
|
1056
|
+
"post",
|
|
1057
|
+
{
|
|
1058
|
+
title: text().notNull(),
|
|
1059
|
+
content: text().notNull(),
|
|
1060
|
+
published: boolean().notNull().default(false),
|
|
1061
|
+
authorId: id("user")
|
|
1062
|
+
.notNull()
|
|
1063
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
1064
|
+
deletionTime: integer(),
|
|
1065
|
+
createdAt: timestamp().notNull().defaultNow(),
|
|
1066
|
+
},
|
|
1067
|
+
(t) => [
|
|
1068
|
+
index("by_author").on(t.authorId),
|
|
1069
|
+
index("by_author_created").on(t.authorId, t.createdAt),
|
|
1070
|
+
searchIndex("search_title").on(t.title).filter(t.authorId),
|
|
1071
|
+
deletion("scheduled", { delayMs: 60_000 }),
|
|
1072
|
+
]
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
const tables = { user, post };
|
|
1076
|
+
export default defineSchema(tables, {
|
|
1077
|
+
strict: false,
|
|
1078
|
+
})
|
|
1079
|
+
.relations((r) => ({
|
|
1080
|
+
user: {
|
|
1081
|
+
posts: r.many.post(),
|
|
1082
|
+
},
|
|
1083
|
+
post: {
|
|
1084
|
+
author: r.one.user({
|
|
1085
|
+
from: r.post.authorId,
|
|
1086
|
+
to: r.user.id,
|
|
1087
|
+
optional: false,
|
|
1088
|
+
}),
|
|
1089
|
+
},
|
|
1090
|
+
}))
|
|
1091
|
+
.triggers({
|
|
1092
|
+
post: {
|
|
1093
|
+
create: {
|
|
1094
|
+
after: async (doc) => {
|
|
1095
|
+
console.log("post created", doc._id);
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
});
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
## Related References
|
|
1103
|
+
|
|
1104
|
+
- Aggregates: `./aggregates.md`
|
|
1105
|
+
- Migrations: `./migrations.md`
|
|
1106
|
+
- Scheduling: `./scheduling.md`
|
|
1107
|
+
- HTTP: `./http.md`
|
|
1108
|
+
- React/RSC: `./react.md`
|
|
1109
|
+
|
|
1110
|
+
## API Reference
|
|
1111
|
+
|
|
1112
|
+
### Column Types
|
|
1113
|
+
|
|
1114
|
+
All from `kitcn/orm`:
|
|
1115
|
+
|
|
1116
|
+
| Builder | TS Type | Convex | Notes |
|
|
1117
|
+
| ------------------------------- | ------------- | ---------------------- | ------------------------------------------ |
|
|
1118
|
+
| `text()` | `string` | `v.string()` | |
|
|
1119
|
+
| `textEnum(['a','b'] as const)` | `'a' \| 'b'` | `v.string()` | Runtime-validated |
|
|
1120
|
+
| `integer()` | `number` | `v.number()` | Float64 |
|
|
1121
|
+
| `boolean()` | `boolean` | `v.boolean()` | |
|
|
1122
|
+
| `bigint()` | `bigint` | `v.int64()` | |
|
|
1123
|
+
| `timestamp()` | `Date` | `v.number()` | `.defaultNow()` for createdAt |
|
|
1124
|
+
| `timestamp({ mode: 'string' })` | `string` | `v.number()` | |
|
|
1125
|
+
| `date()` | `string` | `v.string()` | YYYY-MM-DD, or `{ mode: 'date' }` → `Date` |
|
|
1126
|
+
| `id('table')` | `Id<'table'>` | `v.id('table')` | Typed reference |
|
|
1127
|
+
| `vector(dims)` | `number[]` | `v.array(v.float64())` | For vectorIndex |
|
|
1128
|
+
| `bytes()` | `ArrayBuffer` | `v.bytes()` | |
|
|
1129
|
+
| `unionOf(text(), integer())` | `string \| number` | `v.union(...)` | Builder-only scalar union sugar |
|
|
1130
|
+
| `objectOf(text().notNull())` | `Record<string, string>` | `v.record(...)` | Homogeneous record values |
|
|
1131
|
+
| `json<T>()` | `T` | `v.any()` | Type-only, no runtime validation |
|
|
1132
|
+
| `custom(validator)` | inferred | any `v.*` | Full Convex validator |
|
|
1133
|
+
|
|
1134
|
+
### Operators
|
|
1135
|
+
|
|
1136
|
+
| Category | Operators |
|
|
1137
|
+
| ------------------- | ---------------------------------------------------------------------------- |
|
|
1138
|
+
| Comparison | `eq`, `ne`, `gt`, `gte`, `lt`, `lte` |
|
|
1139
|
+
| Range | `between` (inclusive), `notBetween` (strict outside) |
|
|
1140
|
+
| Set | `in`, `notIn` |
|
|
1141
|
+
| Null | `isNull`, `isNotNull` |
|
|
1142
|
+
| Logical | `AND`, `OR`, `NOT` |
|
|
1143
|
+
| String (post-fetch) | `like`, `ilike`, `notLike`, `notIlike`, `startsWith`, `endsWith`, `contains` |
|
|
1144
|
+
| Array (post-fetch) | `arrayContains`, `arrayContained`, `arrayOverlaps` |
|
|
1145
|
+
|
|
1146
|
+
### Select Composition Limitations
|
|
1147
|
+
|
|
1148
|
+
| Combination | Status |
|
|
1149
|
+
| ------------------------- | ------------- |
|
|
1150
|
+
| `select() + search` | Not supported |
|
|
1151
|
+
| `select() + vectorSearch` | Not supported |
|
|
1152
|
+
| `select() + offset` | Not supported |
|
|
1153
|
+
| `select() + with` | Not supported |
|
|
1154
|
+
| `select() + extras` | Not supported |
|
|
1155
|
+
| `select() + columns` | Not supported |
|
|
1156
|
+
|
|
1157
|
+
### Full-Scan Operator Workarounds
|
|
1158
|
+
|
|
1159
|
+
| Operator | Scalable workaround |
|
|
1160
|
+
| ---------------------------------- | -------------------------------------------------- |
|
|
1161
|
+
| `arrayContains/Contained/Overlaps` | Inverted/join table keyed by element |
|
|
1162
|
+
| `contains` | `withSearchIndex` or tokenized denormalized field |
|
|
1163
|
+
| `endsWith` | Store reversed column, use `startsWith` |
|
|
1164
|
+
| `ilike`/`notIlike` | Lowercase column + `startsWith`/`like('prefix%')` |
|
|
1165
|
+
| `notLike` | Indexed positive pre-filter + `notLike` post-fetch |
|
|
1166
|
+
| `NOT` (general) | Rewrite to positive predicates; cap with `maxScan` |
|