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.
Files changed (93) hide show
  1. package/bin/intent.js +3 -0
  2. package/dist/aggregate/index.d.ts +388 -0
  3. package/dist/aggregate/index.js +37 -0
  4. package/dist/api-entry-BckXqaLb.js +66 -0
  5. package/dist/auth/client/index.d.ts +37 -0
  6. package/dist/auth/client/index.js +217 -0
  7. package/dist/auth/config/index.d.ts +45 -0
  8. package/dist/auth/config/index.js +24 -0
  9. package/dist/auth/generated/index.d.ts +2 -0
  10. package/dist/auth/generated/index.js +3 -0
  11. package/dist/auth/http/index.d.ts +64 -0
  12. package/dist/auth/http/index.js +461 -0
  13. package/dist/auth/index.d.ts +221 -0
  14. package/dist/auth/index.js +1398 -0
  15. package/dist/auth/nextjs/index.d.ts +50 -0
  16. package/dist/auth/nextjs/index.js +81 -0
  17. package/dist/auth-store-Cljlmdmi.js +197 -0
  18. package/dist/builder-CBdG5W6A.js +1974 -0
  19. package/dist/caller-factory-cTXNvYdz.js +216 -0
  20. package/dist/cli.mjs +13264 -0
  21. package/dist/codegen-lF80HSWu.mjs +3416 -0
  22. package/dist/context-utils-HPC5nXzx.d.ts +17 -0
  23. package/dist/create-schema-odyF4kCy.js +156 -0
  24. package/dist/create-schema-orm-DOyiNDCx.js +246 -0
  25. package/dist/crpc/index.d.ts +105 -0
  26. package/dist/crpc/index.js +169 -0
  27. package/dist/customFunctions-C0voKmtx.js +144 -0
  28. package/dist/error-BZEnI7Sq.js +41 -0
  29. package/dist/generated-contract-disabled-Cih4eITO.js +50 -0
  30. package/dist/generated-contract-disabled-D-sOFy92.d.ts +354 -0
  31. package/dist/http-types-DqJubRPJ.d.ts +292 -0
  32. package/dist/meta-utils-0Pu0Nrap.js +117 -0
  33. package/dist/middleware-BUybuv9n.d.ts +34 -0
  34. package/dist/middleware-C2qTZ3V7.js +84 -0
  35. package/dist/orm/index.d.ts +17 -0
  36. package/dist/orm/index.js +10713 -0
  37. package/dist/plugins/index.d.ts +2 -0
  38. package/dist/plugins/index.js +3 -0
  39. package/dist/procedure-caller-DtxLmGwA.d.ts +1467 -0
  40. package/dist/procedure-caller-MWcxhQDv.js +349 -0
  41. package/dist/query-context-B8o6-8kC.js +1518 -0
  42. package/dist/query-context-CFZqIvD7.d.ts +42 -0
  43. package/dist/query-options-Dw7cOyXl.js +121 -0
  44. package/dist/ratelimit/index.d.ts +269 -0
  45. package/dist/ratelimit/index.js +856 -0
  46. package/dist/ratelimit/react/index.d.ts +76 -0
  47. package/dist/ratelimit/react/index.js +183 -0
  48. package/dist/react/index.d.ts +1284 -0
  49. package/dist/react/index.js +2526 -0
  50. package/dist/rsc/index.d.ts +276 -0
  51. package/dist/rsc/index.js +233 -0
  52. package/dist/runtime-CtvJPkur.js +2453 -0
  53. package/dist/server/index.d.ts +5 -0
  54. package/dist/server/index.js +6 -0
  55. package/dist/solid/index.d.ts +1221 -0
  56. package/dist/solid/index.js +2940 -0
  57. package/dist/transformer-DtDhR3Lc.js +194 -0
  58. package/dist/types-BTb_4BaU.d.ts +42 -0
  59. package/dist/types-BiJE7qxR.d.ts +4 -0
  60. package/dist/types-DEJpkIhw.d.ts +88 -0
  61. package/dist/types-HhO_R6pd.d.ts +213 -0
  62. package/dist/validators-B7oIJCAp.js +279 -0
  63. package/dist/validators-vzRKjBJC.d.ts +88 -0
  64. package/dist/watcher.mjs +96 -0
  65. package/dist/where-clause-compiler-DdjN63Io.d.ts +4756 -0
  66. package/package.json +107 -34
  67. package/skills/convex/SKILL.md +486 -0
  68. package/skills/convex/references/features/aggregates.md +353 -0
  69. package/skills/convex/references/features/auth-admin.md +446 -0
  70. package/skills/convex/references/features/auth-organizations.md +1141 -0
  71. package/skills/convex/references/features/auth-polar.md +579 -0
  72. package/skills/convex/references/features/auth.md +470 -0
  73. package/skills/convex/references/features/create-plugins.md +153 -0
  74. package/skills/convex/references/features/http.md +676 -0
  75. package/skills/convex/references/features/migrations.md +162 -0
  76. package/skills/convex/references/features/orm.md +1166 -0
  77. package/skills/convex/references/features/react.md +657 -0
  78. package/skills/convex/references/features/scheduling.md +267 -0
  79. package/skills/convex/references/features/testing.md +209 -0
  80. package/skills/convex/references/setup/auth.md +501 -0
  81. package/skills/convex/references/setup/biome.md +190 -0
  82. package/skills/convex/references/setup/doc-guidelines.md +145 -0
  83. package/skills/convex/references/setup/index.md +761 -0
  84. package/skills/convex/references/setup/next.md +116 -0
  85. package/skills/convex/references/setup/react.md +175 -0
  86. package/skills/convex/references/setup/server.md +473 -0
  87. package/skills/convex/references/setup/start.md +67 -0
  88. package/LICENSE +0 -21
  89. package/README.md +0 -0
  90. package/dist/index.d.mts +0 -5
  91. package/dist/index.d.mts.map +0 -1
  92. package/dist/index.mjs +0 -6
  93. 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` |