neoorm 0.9.3 → 0.9.6

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/llms-full.txt ADDED
@@ -0,0 +1,3457 @@
1
+ # NeoOrm
2
+
3
+ > NeoOrm is a TypeScript-first SQL ORM: schema → codegen → typed client for PostgreSQL, SQLite, MySQL, and MariaDB. You own the SQL; it handles the boilerplate.
4
+
5
+ ## Getting started
6
+
7
+ ## Scaffold a new project
8
+
9
+ ```bash
10
+ bunx neoorm init
11
+ ```
12
+
13
+ This creates `neoorm.config.ts`, `schema.ts`, and `.env.example`. Run `bunx neoorm migrate dev` after setting your database URL.
14
+
15
+ ```bash
16
+ cp .env.example .env
17
+ # edit DATABASE_URL in .env
18
+ bunx neoorm migrate dev
19
+ ```
20
+
21
+ The CLI loads `.env` automatically. You do not need to export `DATABASE_URL` in the shell first.
22
+
23
+ Browse and edit data with Studio:
24
+
25
+ ```bash
26
+ bunx neoorm studio --open
27
+ ```
28
+
29
+ See [Studio](studio.md).
30
+
31
+ Query:
32
+
33
+ ```ts
34
+ import { db } from "./neoorm/client.js";
35
+
36
+ const user = await db.users.findById(userId, {
37
+ with: { posts: { where: { published: true }, orderBy: { createdAt: "desc" }, take: 10 } },
38
+ });
39
+ ```
40
+
41
+ ## Manual setup
42
+
43
+ ### 1. Define a schema
44
+
45
+ ```ts
46
+ // schema.ts
47
+ import {
48
+ defineSchema,
49
+ fk,
50
+ id,
51
+ table,
52
+ text,
53
+ timestamps,
54
+ uuid,
55
+ } from "neoorm/schema";
56
+
57
+ export const schema = defineSchema({
58
+ users: table({
59
+ id: uuid().primary(),
60
+ email: text().notNull().unique(),
61
+ ...timestamps(),
62
+ }),
63
+
64
+ posts: table({
65
+ id: id(),
66
+ authorId: fk("users").notNull().index(),
67
+ title: text().notNull(),
68
+ }),
69
+ });
70
+ ```
71
+
72
+ - `table({ ... })` uses the accessor as the SQL table name.
73
+ - `fk("users")` references the **users accessor** (not a SQL string).
74
+ - Relation names are inferred (`authorId` → `author` on posts, `posts` on users).
75
+
76
+ See [Schema DSL](schema.md).
77
+
78
+ Browse [Examples](examples.md) for copy-paste patterns (CRUD, relations, pagination, transactions, `strip`, and more).
79
+
80
+ ### 2. Configure NeoOrm
81
+
82
+ ```ts
83
+ // neoorm.config.ts
84
+ import { defineConfig } from "neoorm";
85
+
86
+ export default defineConfig({
87
+ schema: "./schema.ts",
88
+ out: "./neoorm",
89
+ datasource: {
90
+ provider: "postgresql",
91
+ url: process.env.DATABASE_URL!,
92
+ schema: "public",
93
+ enum: "check",
94
+ },
95
+ });
96
+ ```
97
+
98
+ ### 3. Generate the client
99
+
100
+ ```bash
101
+ bunx neoorm generate
102
+ ```
103
+
104
+ To emit Zod, TypeBox, or Elysia Select/Create/Update schemas, install the library and set `generate.zod` / `generate.typebox` / `generate.elysia` in config:
105
+
106
+ ```bash
107
+ bun add zod
108
+ bun add typebox
109
+ bun add elysia
110
+ ```
111
+
112
+ ```ts
113
+ export default defineConfig({
114
+ schema: "./schema.ts",
115
+ out: "./neoorm",
116
+ datasource: {
117
+ provider: "postgresql",
118
+ url: process.env.DATABASE_URL!,
119
+ },
120
+ generate: {
121
+ zod: true,
122
+ typebox: true,
123
+ elysia: true,
124
+ },
125
+ });
126
+ ```
127
+
128
+ `neoorm generate` / `neoorm migrate dev` then writes `out/zod.ts`, `out/typebox.ts`, and/or `out/elysia.ts` (re-exported from `out/client.ts`). See [Zod schemas](zod.md), [TypeBox schemas](typebox.md), and [Elysia schemas](elysia.md). `neoorm init` leaves this commented in `neoorm.config.ts`.
129
+
130
+ ## SQLite
131
+
132
+ Set `provider: "sqlite"` and `url` to a file path or `:memory:`:
133
+
134
+ ```ts
135
+ datasource: {
136
+ provider: "sqlite",
137
+ url: "./dev.db",
138
+ },
139
+ ```
140
+
141
+ ```ts
142
+ import { createNeoOrmClient } from "neoorm";
143
+ import { manifest } from "./neoorm/manifest.js";
144
+
145
+ const db = createNeoOrmClient(manifest, {
146
+ provider: "sqlite",
147
+ databasePath: "./dev.db",
148
+ });
149
+ ```
150
+
151
+ See [SQLite](sqlite.md), [MySQL](mysql.md), and [MariaDB](mariadb.md).
152
+
153
+ ## Tenant-specific schemas
154
+
155
+ ```ts
156
+ const tenantDb = createNeoOrmClient(manifest, {
157
+ connectionString: process.env.DATABASE_URL!,
158
+ schema: "tenant_acme",
159
+ });
160
+ ```
161
+
162
+ Raw `db.sql` uses the same compiler as `neoorm/sql`. With PostgreSQL `schema: "tenant_acme"`, `db.sql` / `db.execute` qualify unqualified table names from your manifest (`FROM users` → `"tenant_acme"."users"`). `db.sqlId("users")` is schema-qualified; column `sqlId`s are not. Explicit `schema.table` refs are left alone.
163
+
164
+ Reuse an existing `pg` pool with `createNeoOrmClientFromPool(manifest, pool)`, or an existing SQLite handle with `createNeoOrmClientFromSqlite(manifest, database)`. `$disconnect()` does not close a borrowed pool or database — close it yourself when every consumer is done.
165
+
166
+ Log compiled SQL with `beforeQuery` / `afterQuery` on the same options object (see [Queries](queries.md#logging-sql)).
167
+
168
+ PostgreSQL data queries use named prepared statements by default. Set `preparedStatements: false` if you use transaction-mode PgBouncer (named prepares are not supported there).
169
+
170
+ PostgreSQL pool settings (`ssl`, timeouts, `application_name`, size) go on `pool` and are passed through to `pg.Pool`:
171
+
172
+ ```ts
173
+ const db = createNeoOrmClient(manifest, {
174
+ connectionString: process.env.DATABASE_URL!,
175
+ pool: {
176
+ max: 10,
177
+ connectionTimeoutMillis: 5_000,
178
+ statement_timeout: 15_000,
179
+ application_name: "api",
180
+ ssl: { rejectUnauthorized: true },
181
+ },
182
+ });
183
+ ```
184
+
185
+ For full `pg.Pool` control (custom host/user, event handlers), construct the pool yourself and pass it to `createNeoOrmClientFromPool`.
186
+
187
+ ## Examples
188
+
189
+ Copy-paste patterns for common NeoOrm tasks. The full runnable blog demo lives in [`examples/blog/`](https://github.com/xmreur/neoorm/tree/main/examples/blog) (`schema.ts` + `queries.example.ts`). PostGIS sample: [`examples/postgis/`](https://github.com/xmreur/neoorm/tree/main/examples/postgis).
190
+
191
+ Run `neoorm docs` to browse all documentation locally.
192
+
193
+ ## Blog schema
194
+
195
+ A small CMS-style schema used throughout these examples:
196
+
197
+ ```ts
198
+ import {
199
+ bool,
200
+ decimal,
201
+ defineSchema,
202
+ enumType,
203
+ fk,
204
+ id,
205
+ int,
206
+ jsonb,
207
+ many,
208
+ table,
209
+ text,
210
+ timestamps,
211
+ uuid,
212
+ } from "neoorm/schema";
213
+
214
+ export const schema = defineSchema({
215
+ users: table({
216
+ id: uuid().primary(),
217
+ email: text().notNull().unique(),
218
+ name: text(),
219
+ password: text().notNull().hidden(),
220
+ ...timestamps(),
221
+ }),
222
+
223
+ profiles: table({
224
+ id: id(),
225
+ userId: fk("users").notNull().unique().onDelete("cascade"),
226
+ bio: text(),
227
+ avatarUrl: text(),
228
+ }),
229
+
230
+ posts: table({
231
+ id: id(),
232
+ authorId: fk("users").notNull().index().onDelete("restrict"),
233
+ title: text().notNull(),
234
+ body: text().notNull(),
235
+ published: bool().notNull().default(false),
236
+ views: int().notNull().default(0),
237
+ status: enumType(["draft", "published", "archived"]).notNull().default("draft"),
238
+ metadata: jsonb<Record<string, unknown>>(),
239
+ price: decimal({ precision: 10, scale: 2 }),
240
+ ...timestamps(),
241
+ tags: many("tags"),
242
+ }),
243
+
244
+ comments: table({
245
+ id: id(),
246
+ postId: fk("posts").notNull().onDelete("cascade"),
247
+ authorId: fk("users").notNull(),
248
+ body: text().notNull(),
249
+ createdAt: timestamps().createdAt,
250
+ }),
251
+
252
+ tags: table({
253
+ id: id(),
254
+ slug: text().notNull().unique(),
255
+ name: text().notNull(),
256
+ }),
257
+ });
258
+ ```
259
+
260
+ Generate the client, then import it:
261
+
262
+ ```ts
263
+ import { db } from "./neoorm/client.js";
264
+ ```
265
+
266
+ ## CRUD basics
267
+
268
+ ```ts
269
+ // Create
270
+ const user = await db.users.create({
271
+ data: { email: "alice@example.com", name: "Alice", password: "secret" },
272
+ });
273
+
274
+ // Create and return full row
275
+ const post = await db.posts.create({
276
+ data: {
277
+ title: "Hello",
278
+ body: "World",
279
+ authorId: user.id,
280
+ },
281
+ returnCreated: true,
282
+ });
283
+
284
+ // Find by primary key
285
+ const found = await db.users.findById(user.id);
286
+
287
+ // Find first match
288
+ const draft = await db.posts.findFirst({
289
+ where: { published: false },
290
+ orderBy: { createdAt: "desc" },
291
+ });
292
+
293
+ // Update
294
+ await db.users.updateById(user.id, {
295
+ data: { name: "Alice Smith" },
296
+ });
297
+
298
+ // Update many
299
+ await db.posts.updateMany({
300
+ where: { published: false },
301
+ data: { views: 0 },
302
+ });
303
+
304
+ // Delete
305
+ await db.posts.delete({ where: { id: post.id } });
306
+ await db.users.deleteById(user.id);
307
+ ```
308
+
309
+ ## Nested reads (`with`)
310
+
311
+ ```ts
312
+ const user = await db.users.findById("user_1", {
313
+ with: {
314
+ profile: true,
315
+ posts: {
316
+ where: { published: true },
317
+ orderBy: { createdAt: "desc" },
318
+ take: 10,
319
+ with: {
320
+ comments: {
321
+ orderBy: { createdAt: "desc" },
322
+ take: 5,
323
+ with: { author: { select: { id: true, name: true } } },
324
+ },
325
+ tags: true,
326
+ },
327
+ },
328
+ },
329
+ });
330
+ ```
331
+
332
+ Select only specific columns:
333
+
334
+ ```ts
335
+ const users = await db.users.findMany({
336
+ select: { id: true, email: true },
337
+ with: {
338
+ posts: { select: { title: true, published: true } },
339
+ },
340
+ });
341
+ ```
342
+
343
+ Omit columns at query time (dropped from the `SELECT`):
344
+
345
+ ```ts
346
+ const user = await db.users.findById("user_1", {
347
+ omit: { createdAt: true, updatedAt: true },
348
+ });
349
+ ```
350
+
351
+ ## Where filters
352
+
353
+ ```ts
354
+ // Equality and operators
355
+ const posts = await db.posts.findMany({
356
+ where: {
357
+ published: true,
358
+ views: { gte: 100 },
359
+ title: { contains: "ORM" },
360
+ },
361
+ });
362
+
363
+ // Case-insensitive search
364
+ const hits = await db.posts.findMany({
365
+ where: { title: { contains: "orm", mode: "insensitive" } },
366
+ });
367
+
368
+ // Logical combinators
369
+ const filtered = await db.posts.findMany({
370
+ where: {
371
+ OR: [
372
+ { status: "published" },
373
+ { AND: [{ published: true }, { views: { gt: 50 } }] },
374
+ ],
375
+ },
376
+ });
377
+
378
+ // Filter through relations
379
+ const authors = await db.users.findMany({
380
+ where: {
381
+ posts: { some: { published: true, tags: { some: { slug: "typescript" } } } },
382
+ },
383
+ });
384
+
385
+ // JSONB containment
386
+ const featured = await db.posts.findMany({
387
+ where: {
388
+ metadata: {
389
+ jsonContains: { featured: true, category: "engineering" },
390
+ },
391
+ },
392
+ });
393
+
394
+ // Exists / count helpers
395
+ const taken = await db.users.exists({ where: { email: "alice@example.com" } });
396
+ const total = await db.posts.count({ where: { published: true } });
397
+ ```
398
+
399
+ ## Relation writes
400
+
401
+ Create with nested relations in one call:
402
+
403
+ ```ts
404
+ const post = await db.posts.create({
405
+ data: {
406
+ title: "NeoORM",
407
+ body: "Typed relations and JSONB.",
408
+ published: true,
409
+ status: "published",
410
+ author: { connect: { id: "user_1" } },
411
+ tags: {
412
+ connectOrCreate: [
413
+ {
414
+ where: { slug: "orm" },
415
+ create: { slug: "orm", name: "ORM" },
416
+ },
417
+ ],
418
+ },
419
+ },
420
+ with: { author: true, tags: true },
421
+ });
422
+ ```
423
+
424
+ Update relations on an existing row:
425
+
426
+ ```ts
427
+ await db.posts.update({
428
+ where: { id: post.id },
429
+ data: {
430
+ views: { increment: 1 },
431
+ comments: {
432
+ create: [
433
+ {
434
+ body: "Great post!",
435
+ author: { connect: { id: "user_1" } },
436
+ },
437
+ ],
438
+ },
439
+ tags: { set: [{ id: "tag_1" }, { id: "tag_2" }] },
440
+ },
441
+ });
442
+ ```
443
+
444
+ `connect`, `disconnect`, `set`, `create`, and `delete` work on to-one, one-to-many, and many-to-many relations. See [Relation writes](relations.md).
445
+
446
+ ## Pagination
447
+
448
+ Offset/limit:
449
+
450
+ ```ts
451
+ const page = await db.posts.findMany({
452
+ where: { published: true },
453
+ orderBy: { createdAt: "desc" },
454
+ take: 20,
455
+ skip: 40,
456
+ });
457
+ ```
458
+
459
+ Keyset (cursor) pagination for feeds:
460
+
461
+ ```ts
462
+ let cursor: { createdAt: Date; id: string } | null = null;
463
+
464
+ const page = await db.posts.paginate({
465
+ where: { published: true },
466
+ orderBy: { createdAt: "desc" },
467
+ take: 20,
468
+ omit: { body: true },
469
+ ...(cursor ? { after: cursor } : {}),
470
+ });
471
+
472
+ cursor = page.nextCursor;
473
+ // page.items, page.hasMore, page.hasPrevious
474
+ ```
475
+
476
+ ## Aggregations
477
+
478
+ ```ts
479
+ const byStatus = await db.posts.groupBy({
480
+ by: ["status"],
481
+ _count: true,
482
+ orderBy: { _count: "desc" },
483
+ });
484
+
485
+ const stats = await db.posts.aggregate({
486
+ _count: true,
487
+ _avg: { views: true },
488
+ where: { published: true },
489
+ });
490
+ ```
491
+
492
+ ## Transactions
493
+
494
+ Batch steps (single transaction):
495
+
496
+ ```ts
497
+ let authorId!: string;
498
+ const [author, post] = await db.$transaction([
499
+ async (tx) => {
500
+ const created = await tx.users.create({
501
+ data: {
502
+ email: "author@example.com",
503
+ name: "Author",
504
+ password: "secret",
505
+ },
506
+ });
507
+ authorId = created.id;
508
+ return created;
509
+ },
510
+ (tx) =>
511
+ tx.posts.create({
512
+ data: {
513
+ title: "Transactional post",
514
+ body: "...",
515
+ author: { connect: { id: authorId } },
516
+ },
517
+ }),
518
+ ]);
519
+ ```
520
+
521
+ Interactive callback with rollback:
522
+
523
+ ```ts
524
+ await db.$transaction(async (tx) => {
525
+ const user = await tx.users.create({
526
+ data: { email: "x@y.z", name: "Temp", password: "secret" },
527
+ });
528
+ await tx.posts.create({
529
+ data: {
530
+ title: "Draft",
531
+ body: "...",
532
+ author: { connect: { id: user.id } },
533
+ },
534
+ });
535
+ // throw to roll back everything
536
+ });
537
+ ```
538
+
539
+ Nested savepoints:
540
+
541
+ ```ts
542
+ await db.$transaction(async (tx) => {
543
+ await tx.users.create({
544
+ data: { email: "outer@x.y", name: "Outer", password: "secret" },
545
+ });
546
+
547
+ await tx.$transaction(async (nested) => {
548
+ await nested.users.create({
549
+ data: { email: "inner@x.y", name: "Inner", password: "secret" },
550
+ });
551
+ throw new Error("roll back inner only");
552
+ }).catch(() => undefined);
553
+ });
554
+ ```
555
+
556
+ ## Raw SQL
557
+
558
+ Use `db.sql` when you need full SQL control. It uses the same compiler as `neoorm/sql` (`sql`, `sqlId`, nested fragments). `sqlBuilder` covers select/join/where/group/order/limit with bound params — interpolate `.compile()` into `db.sql` for HAVING or other raw tails.
559
+
560
+ ```ts
561
+ import { sql, sqlBuilder, sqlId } from "neoorm/sql";
562
+
563
+ const rows = await db.sql`
564
+ SELECT u.id, u.email, count(p.id) AS post_count
565
+ FROM users u
566
+ LEFT JOIN posts p ON p.author_id = u.id
567
+ GROUP BY u.id, u.email
568
+ ORDER BY post_count DESC
569
+ `;
570
+
571
+ const email = "a@b.com";
572
+ const ident = sqlId("users");
573
+ const filter = sql`email = ${email}`;
574
+ const named = await db.sql`SELECT * FROM ${ident} WHERE ${filter}`;
575
+
576
+ const grouped = sqlBuilder
577
+ .selectFrom("users")
578
+ .leftJoin("posts", "posts.author_id", "users.id")
579
+ .select(["users.id", "users.email"])
580
+ .where("users.email", "=", email)
581
+ .groupBy("users.id", "users.email")
582
+ .limit(10)
583
+ .compile();
584
+ const dashboard = await db.sql`${grouped} HAVING count(posts.id) > ${0}`;
585
+ ```
586
+
587
+ ## API responses (`strip`)
588
+
589
+ Hidden columns are omitted from default queries. For login, use `includeHidden: true`, verify, then strip before sending JSON:
590
+
591
+ ```ts
592
+ const user = await db.users.findFirst({
593
+ where: { email: input.email },
594
+ includeHidden: true,
595
+ });
596
+
597
+ if (!user || !verifyPassword(input.password, user.password)) {
598
+ throw new Error("Invalid credentials");
599
+ }
600
+
601
+ return user.strip(); // plain object without password
602
+ return user.strip({ refreshToken: true }); // hidden + extra fields
603
+ ```
604
+
605
+ `.strip()` is non-enumerable and returns a plain object. See [Queries → API responses](queries.md#api-responses-strip).
606
+
607
+ ## Explicit junction table (many-to-many)
608
+
609
+ Auto-junction works for most cases. For a custom junction table:
610
+
611
+ ```ts
612
+ posts: table({
613
+ id: id(),
614
+ tags: many("tags", {
615
+ through: "postTags",
616
+ leftKey: "postId",
617
+ rightKey: "tagId",
618
+ }),
619
+ }),
620
+ postTags: table("post_tags", {
621
+ postId: fk("posts").primary(),
622
+ tagId: fk("tags").primary(),
623
+ }),
624
+ ```
625
+
626
+ `through` is the **accessor** (`postTags`), not the SQL name (`post_tags`).
627
+
628
+ ## SQLite
629
+
630
+ Same schema and client API — point config at a file:
631
+
632
+ ```ts
633
+ // neoorm.config.ts
634
+ datasource: {
635
+ provider: "sqlite",
636
+ url: "./dev.db",
637
+ },
638
+ ```
639
+
640
+ ```ts
641
+ import { createNeoOrmClient } from "neoorm";
642
+ import { manifest } from "./neoorm/manifest.js";
643
+
644
+ const db = createNeoOrmClient(manifest, {
645
+ provider: "sqlite",
646
+ databasePath: "./dev.db",
647
+ });
648
+ ```
649
+
650
+ See [SQLite](sqlite.md), [MySQL](mysql.md), and [MariaDB](mariadb.md) for type mapping and limitations.
651
+
652
+ Log compiled SQL:
653
+
654
+ ```ts
655
+ const db = createNeoOrmClient(manifest, {
656
+ afterQuery: ({ sql, durationMs }) => {
657
+ if (durationMs > 50) console.warn(`slow ${durationMs.toFixed(1)}ms`, sql);
658
+ },
659
+ });
660
+ ```
661
+
662
+ ## PostGIS
663
+
664
+ ```ts
665
+ import "neoorm/plugins/postgis";
666
+ import { geometry, point } from "neoorm/plugins/postgis";
667
+
668
+ places: table({
669
+ id: id(),
670
+ name: text().notNull(),
671
+ location: geometry({ subtype: "Point", srid: 4326 }).notNull(),
672
+ }),
673
+ ```
674
+
675
+ ```ts
676
+ const nearby = await db.places.findMany({
677
+ where: {
678
+ location: {
679
+ dWithin: {
680
+ geometry: { type: "Point", coordinates: [-122.4, 37.8] },
681
+ distance: 1000,
682
+ },
683
+ },
684
+ },
685
+ });
686
+ ```
687
+
688
+ See [Plugins](plugins.md).
689
+
690
+ ## Schema extras
691
+
692
+ Composite unique constraint and partial unique (soft-delete emails):
693
+
694
+ ```ts
695
+ import { bool, fk, id, table, text, unique } from "neoorm/schema";
696
+
697
+ posts: table(
698
+ {
699
+ id: id(),
700
+ authorId: fk("users").notNull(),
701
+ title: text().notNull(),
702
+ published: bool().notNull().default(false),
703
+ },
704
+ (t) => [
705
+ unique(t.authorId, t.title),
706
+ t.index(t.title).where({ published: true }),
707
+ ],
708
+ ),
709
+
710
+ users: table(
711
+ {
712
+ id: id(),
713
+ email: text().notNull(),
714
+ deleted: bool().notNull().default(false),
715
+ },
716
+ (t) => [t.unique(t.email).where({ deleted: false })],
717
+ ),
718
+ ```
719
+
720
+ SQL name override when accessor differs from table name:
721
+
722
+ ```ts
723
+ postTags: table("post_tags", {
724
+ postId: fk("posts").primary(),
725
+ tagId: fk("tags").primary(),
726
+ }),
727
+ ```
728
+
729
+ ## Next steps
730
+
731
+ | Topic | Doc |
732
+ |-------|-----|
733
+ | Schema DSL | [schema.md](schema.md) |
734
+ | Queries | [queries.md](queries.md) |
735
+ | Relation writes | [relations.md](relations.md) |
736
+ | Migrations | [migrations.md](migrations.md) |
737
+ | CLI | [cli.md](cli.md) |
738
+
739
+ ## Schema DSL
740
+
741
+ NeoOrm **0.8.0** and later uses accessor-based identity: schema keys, `fk("users")`, `many("tags")`, and `through: "postTags"` all refer to **accessors**, not raw SQL names. `schemaToManifest` resolves accessors to SQL before migrations and the typed client are generated.
742
+
743
+ ## Tables and accessors
744
+
745
+ `defineSchema` keys are TypeScript accessors. By default the SQL table name equals the accessor:
746
+
747
+ ```ts
748
+ import { defineSchema, fk, id, table, text, timestamps, uuid } from "neoorm/schema";
749
+
750
+ export const schema = defineSchema({
751
+ users: table({
752
+ id: uuid().primary(),
753
+ email: text().notNull().unique(),
754
+ ...timestamps(),
755
+ }),
756
+
757
+ postTags: table("post_tags", {
758
+ postId: fk("posts").primary(),
759
+ tagId: fk("tags").primary(),
760
+ }),
761
+ });
762
+ ```
763
+
764
+ - `table({ columns, extras? })` — SQL name is filled in from the accessor (`users` → `"users"`).
765
+ - `table("sql_name", { columns }, extras?)` — override the SQL name (`postTags` → `"post_tags"`).
766
+
767
+ Generated row types are the English singular of the accessor: `users` → `User`, `status` → `Status` (not `Statu`).
768
+
769
+ Column field names use camelCase in TypeScript. By default SQL column names are snake_case (`createdAt` → `created_at`). See [column naming](#column-naming-strategy).
770
+
771
+ ## Column types
772
+
773
+ | Builder | SQL type | TypeScript | Notes |
774
+ |---------|----------|------------|-------|
775
+ | `id()` | `TEXT` | `string` | App-generated `{prefix}_{uuid}` primary key |
776
+ | `uuid()` | `UUID` | `string` | Defaults to UUID v7; `{ version: 4 }` for v4 |
777
+ | `uuid().primary()` | `UUID` | `string` | Primary key; auto-generated on create if omitted |
778
+ | `serial()` | `INTEGER GENERATED BY DEFAULT AS IDENTITY` | `number` | Auto-increment |
779
+ | `serial().primary()` | same | `number` | Integer PK |
780
+ | `text()` | `TEXT` | `string \| null` | |
781
+ | `bool()` | `BOOLEAN` | `boolean \| null` | |
782
+ | `int()` | `INTEGER` | `number \| null` | |
783
+ | `bigint()` | `BIGINT` | `bigint \| null` | Maps to `TEXT` on SQLite |
784
+ | `timestamp()` | `TIMESTAMPTZ` | `Date \| null` | `.defaultNow()` / `.updatedAt()` on timestamp builders only. Maps to `TEXT` on SQLite |
785
+ | `json()` / `jsonb()` | `JSON` / `JSONB` | `unknown \| null` | Generic: `jsonb<MyType>()` |
786
+ | `decimal()` / `numeric()` | `NUMERIC` | `string \| null` | Use strings to avoid float loss |
787
+ | `enumType(["a", "b"])` | mode-dependent | union literals | See [Enum columns](#enum-columns) |
788
+ | `bytea()` | `BYTEA` | `Buffer \| null` | |
789
+ | `textArray()` / `intArray()` | arrays | arrays \| null | JSON on SQLite/MySQL |
790
+ | `uuidArray()` | `UUID[]` | `string[] \| null` | JSON on SQLite/MySQL |
791
+ | `enumArray(["a", "b"])` | `TEXT[]` or `name[]` | union arrays \| null | Same enum storage mode as `enumType`; JSON on SQLite/MySQL |
792
+ | `real()` / `float()` | `REAL` | `number \| null` | 32-bit. Prisma `Float` is `double()`. MySQL `FLOAT`, SQLite `REAL` |
793
+ | `double()` | `DOUBLE PRECISION` | `number \| null` | MySQL `DOUBLE`, SQLite `REAL` |
794
+ | `date()` | `DATE` | `string \| null` | `YYYY-MM-DD`, not `Date`. SQLite `TEXT`, MySQL `DATE` |
795
+ | `time()` | `TIME` | `string \| null` | SQLite `TEXT`, MySQL `TIME` |
796
+ | `interval()` | `INTERVAL` | `string \| null` | SQLite `TEXT`; rejected on MySQL/MariaDB |
797
+ | `inet()` / `cidr()` | `INET` / `CIDR` | `string \| null` | SQLite `TEXT`; rejected on MySQL/MariaDB |
798
+ | `xml()` | `XML` | `string \| null` | MySQL `LONGTEXT`, SQLite `TEXT` |
799
+ | `money()` | `MONEY` | `string \| null` | MySQL `DECIMAL(19,4)`, SQLite `TEXT` |
800
+ | `int4Range()` / `int8Range()` / `numRange()` / `tsRange()` / `tstzRange()` / `dateRange()` | matching PG ranges | `string \| null` | PostgreSQL only (rejected on SQLite and MySQL/MariaDB) |
801
+ | `citext()` | `CITEXT` | `string \| null` | Requires `citext` extension |
802
+ | `tsvector()` | `TSVECTOR` | `string \| null` | Full-text document; `TEXT` on SQLite/MySQL. See [Full-text search](#full-text-search) |
803
+
804
+ All column builders support `.notNull()`, `.unique()`, `.default(value)`, `.primary()`, `.map(name)`, `.hidden()`, `.index()`, and `.check("sql expression")`. Text columns add `.maxLength()`, `.minLength()`, `.notEmpty()`, `.email()`, and `.url()`. Numeric columns (`int`, `bigint`, `serial`, `decimal`, `real`, `double`) add `.min()`, `.max()`, and `.positive()`.
805
+
806
+ `.hidden()` marks a column as sensitive. It is omitted from default query output on the root table and on nested `with` includes. Pass `includeHidden: true` when the app needs the value (for example password verification on login). Use `.strip()` to remove any remaining sensitive fields before JSON responses.
807
+
808
+ ### Audit timestamps
809
+
810
+ ```ts
811
+ ...timestamps(),
812
+ ```
813
+
814
+ `timestamps()` expands to `createdAt` and `updatedAt` — both `notNull`, `defaultNow`, with `updatedAt` auto-updated by the ORM on writes. Or define columns manually:
815
+
816
+ ```ts
817
+ createdAt: timestamp().notNull().defaultNow(),
818
+ updatedAt: timestamp().notNull().defaultNow().updatedAt(),
819
+ ```
820
+
821
+ ### Full-text search
822
+
823
+ `tsvector()` declares a full-text document column (`TSVECTOR` on Postgres, `TEXT` elsewhere). It is maintained by the database — typically a trigger:
824
+
825
+ ```ts
826
+ docs: table({
827
+ id: id(),
828
+ title: text().notNull(),
829
+ body: tsvector(),
830
+ }),
831
+ ```
832
+
833
+ ```sql
834
+ CREATE OR REPLACE FUNCTION docs_body_tsvector() RETURNS trigger AS $$
835
+ BEGIN
836
+ NEW.body := to_tsvector('english', NEW.title);
837
+ RETURN NEW;
838
+ END $$ LANGUAGE plpgsql;
839
+
840
+ CREATE TRIGGER docs_body_tsvector_trigger
841
+ BEFORE INSERT OR UPDATE ON docs
842
+ FOR EACH ROW EXECUTE FUNCTION docs_body_tsvector();
843
+ ```
844
+
845
+ Index it with GIN and query it with the `searchTs` where operator (see [Where clauses](queries.md#column-filters)):
846
+
847
+ ```ts
848
+ (t) => [index(t.body).using("gin")],
849
+ // or over plain text without a tsvector column:
850
+ (t) => [index(expr("to_tsvector('english', title)")).using("gin")],
851
+ ```
852
+
853
+ ## Foreign keys
854
+
855
+ `fk()` targets are **schema accessors**, never SQL table names.
856
+
857
+ | Target | Example | Resolves to |
858
+ |--------|---------|-------------|
859
+ | accessor | `fk("users")` | target table PK |
860
+ | accessor + column | `fk("users.id")` | that column |
861
+ | hoisted table | `fk(users)` | target PK |
862
+ | hoisted column | `fk(users.id)` | that column |
863
+
864
+ ```ts
865
+ posts: table({
866
+ id: id(),
867
+ authorId: fk("users")
868
+ .notNull()
869
+ .index()
870
+ .onDelete("restrict"),
871
+ }),
872
+ ```
873
+
874
+ Relation names are inferred — no `.inverse()` needed on the happy path:
875
+
876
+ - `authorId` → `author` on `posts` (`with: { author: true }`)
877
+ - inverse on `users` → `posts` (`with: { posts: true }`)
878
+
879
+ Fluent modifiers (no options bag):
880
+
881
+ | Method | Effect |
882
+ |--------|--------|
883
+ | `.as("author")` | override relation name on this table (default: strip `Id` from column name) |
884
+ | `.inverse("articles")` | override relation name on the target table (rare — see defaults below) |
885
+ | `.onDelete("cascade" \| "restrict" \| "set null" \| "no action")` | FK `ON DELETE` |
886
+ | `.onUpdate("cascade" \| "restrict" \| "set null" \| "no action")` | FK `ON UPDATE` |
887
+ | `.deferrable("immediate" \| "deferred")` | `DEFERRABLE` (Postgres and SQLite; rejected on MySQL/MariaDB) |
888
+ | `.notNull()` | `NOT NULL` |
889
+ | `.unique()` | `UNIQUE` — unique FKs infer a **singular** inverse (`profiles.userId` → `users.profile`) |
890
+ | `.primary()` | part of composite PK |
891
+ | `.index()` | single-column index |
892
+
893
+ Default inference when `.as()` / `.inverse()` are omitted:
894
+
895
+ - **this table:** `authorId` → `author` (strip trailing `Id`)
896
+ - **target table (to-many):** source accessor (`posts.authorId` → `users.posts`)
897
+ - **target table (unique to-one):** singular of source accessor (`profiles.userId` → `users.profile`)
898
+
899
+ ### Composite primary keys with FKs
900
+
901
+ ```ts
902
+ postTags: table("post_tags", {
903
+ postId: fk("posts").primary(),
904
+ tagId: fk("tags").primary(),
905
+ }),
906
+ ```
907
+
908
+ Multiple `.primary()` columns infer a composite key. `primaryKey(t.colA, t.colB)` in extras is optional when you want to name the key without marking every column.
909
+
910
+ ### Composite foreign keys
911
+
912
+ Use table extras `foreignKey()` when several local columns form one constraint (and one relation). Do not also mark those columns `fk()`.
913
+
914
+ ```ts
915
+ orders: table(
916
+ {
917
+ id: id(),
918
+ tenantId: text().notNull(),
919
+ userId: text().notNull(),
920
+ },
921
+ (t) => [
922
+ foreignKey(t.tenantId, t.userId)
923
+ .references("users", "tenantId", "id")
924
+ .as("user")
925
+ .inverse("orders")
926
+ .onDelete("cascade")
927
+ .onUpdate("cascade"),
928
+ ],
929
+ ),
930
+ ```
931
+
932
+ The target column list must match a primary key or unique index. `.as()` and `.inverse()` are required. Connect still uses the target table's full primary key:
933
+
934
+ ```ts
935
+ await db.orders.create({
936
+ data: {
937
+ tenantId: "acme",
938
+ user: { connect: { tenantId: "acme", id: userId } },
939
+ },
940
+ });
941
+ ```
942
+
943
+ ### Self-relations
944
+
945
+ ```ts
946
+ comments: table({
947
+ id: id(),
948
+ parentId: fk("comments").as("parent").inverse("children"),
949
+ body: text().notNull(),
950
+ }),
951
+ ```
952
+
953
+ ### Hoisted table references
954
+
955
+ ```ts
956
+ const users = table({ id: uuid().primary() });
957
+
958
+ export const schema = defineSchema({
959
+ users,
960
+ posts: table({
961
+ authorId: fk(users).notNull().index(),
962
+ }),
963
+ });
964
+ ```
965
+
966
+ ## Column checks
967
+
968
+ Use `.check("sql expression")` for custom CHECK constraints, or the typed helpers below. Helpers compile to database CHECK constraints (and `VARCHAR(n)` for text length on Postgres). They are not runtime validators by themselves. With [`generate.zod`](zod.md), [`generate.typebox`](typebox.md), or [`generate.elysia`](elysia.md), the same helpers are copied into generated validation schemas at your API boundary.
969
+
970
+ ### Typed constraint helpers
971
+
972
+ | Method | Column types | Postgres | SQLite |
973
+ |--------|--------------|----------|--------|
974
+ | `.maxLength(n)` / `text({ maxLength: n })` | `text` | `VARCHAR(n)` | `TEXT` + `CHECK (length(col) <= n)` |
975
+ | `.minLength(n)` | `text`, `citext` | CHECK | CHECK |
976
+ | `.notEmpty()` | `text`, `citext` | CHECK | CHECK |
977
+ | `.email()` | `text`, `citext` | — | — |
978
+ | `.url()` | `text`, `citext` | — | — |
979
+ | `.min(n)` / `.max(n)` | `int`, `bigint`, `serial`, `decimal` | CHECK | CHECK |
980
+ | `.positive()` | same numeric types | CHECK | CHECK |
981
+
982
+ ```ts
983
+ email: text({ maxLength: 255 }).notNull().unique()
984
+ title: text().notNull().minLength(1).maxLength(200)
985
+ bio: text().notEmpty()
986
+ handle: text().notNull().email()
987
+ website: text().notNull().url()
988
+ views: int().notNull().min(0)
989
+ price: decimal({ precision: 10, scale: 2 }).notNull().positive()
990
+ ```
991
+
992
+ `.email()` and `.url()` are validation-only: they do not emit a SQL CHECK. With [`generate.zod`](zod.md), [`generate.typebox`](typebox.md), or [`generate.elysia`](elysia.md), columns named `email` or ending in `Email` (`contactEmail`) become email-format strings, and columns named `url` or ending in `Url` (`avatarUrl`) become URL-format strings. You can also mark any `text` / `citext` column with `.email()` or `.url()`. Foreign keys follow the **referenced** column (`authorEmail` pointing at a uuid PK stays a uuid).
993
+
994
+ `citext` length helpers use CHECK only (no `VARCHAR`). NULL still bypasses CHECK — pair `.notEmpty()` with `.notNull()` to reject empty strings.
995
+
996
+ Custom `.check()` expressions are combined with helpers and enum `check` mode using AND. Prefer helpers over raw SQL so constraints use the mapped SQL column name after `.map()`.
997
+
998
+ ```ts
999
+ price: decimal({ precision: 10, scale: 2 }).notNull().check("price >= 0"),
1000
+ ```
1001
+
1002
+ ## Indexes and composite keys
1003
+
1004
+ Single-column indexes: `.index()` on a column.
1005
+
1006
+ Composite constraints use an **array** extras callback:
1007
+
1008
+ ```ts
1009
+ posts: table(
1010
+ {
1011
+ id: id(),
1012
+ authorId: fk("users").notNull(),
1013
+ title: text().notNull(),
1014
+ views: int().notNull().default(0),
1015
+ },
1016
+ (t) => [unique(t.authorId, t.title), index(t.views)],
1017
+ ),
1018
+ ```
1019
+
1020
+ Helpers: `unique(...cols)`, `index(...cols)`, `primaryKey(...cols)`, `expr("sql")`. `unique()` and `index()` support `.using()`, `.ops()`, `.with()` (bloom only), and `.where()` for partial indexes.
1021
+
1022
+ ### Index methods and expressions
1023
+
1024
+ ```ts
1025
+ (t) => [
1026
+ index(t.tags).using("gin"),
1027
+ index(t.metadata).using("gin").ops("jsonb_path_ops"),
1028
+ index(t.location).using("gist"),
1029
+ index(t.createdAt).using("brin"),
1030
+ index(t.a, t.b).using("bloom").with({ length: 80, cols: [2, 2] }),
1031
+ unique(expr("lower(email)")),
1032
+ index(t.authorId, expr("date_trunc('day', created_at)")),
1033
+ ],
1034
+ ```
1035
+
1036
+ Default method is btree (`USING btree` is omitted in SQL). Postgres also emits `gin`, `gist`, `brin`, `hash`, and `bloom`. SQLite allows expression keys and partial `WHERE`, but not those access methods. MySQL/MariaDB allow `USING HASH` and functional `(expr)` keys; they reject GIN/GiST/BRIN/bloom and partial `WHERE`.
1037
+
1038
+ `unique()` cannot use `gin` / `gist` / `brin` / `bloom`. Expression unique indexes are not `findUnique` / `upsert` targets.
1039
+
1040
+ Bloom indexes (Postgres only) are tuned with `.with({ length, cols })`: `cols` maps positionally to the index keys (`col1`, `col2`, …) so its length must match the key count; omit `.with()` for server defaults. Using a bloom index automatically adds the `bloom` extension to the manifest (emitted as `CREATE EXTENSION IF NOT EXISTS "bloom"`).
1041
+
1042
+ ### Partial indexes
1043
+
1044
+ ```ts
1045
+ (t) => [
1046
+ t.index(t.title).where({ published: true }),
1047
+ t.unique(t.email).where({ deleted: false }),
1048
+ // Full predicate syntax, like query `where`:
1049
+ t.unique(t.email).where({
1050
+ OR: [{ status: "banned" }, { status: "suspended" }],
1051
+ }),
1052
+ t.unique(t.slug).where({
1053
+ AND: [{ deletedAt: { isNull: true } }, { score: { gt: 10 } }],
1054
+ NOT: { status: "trial" },
1055
+ }),
1056
+ ],
1057
+ ```
1058
+
1059
+ Use `t.index` / `t.unique` or the top-level `index()` / `unique()` with
1060
+ `t.col` keys — in both cases the `where` predicate is typed: keys, scalar
1061
+ values, and operators autocomplete from the table's columns. Raw-string keys
1062
+ (`unique("email")`) still accept `.where()` with an untyped predicate.
1063
+
1064
+ Flat equality (`{ published: true }`, `null` → `IS NULL`) plus `AND` / `OR` / `NOT` combinators and per-column operators: `equals`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`, `isNull`, `isNotNull`, `contains`, `startsWith`, `endsWith`, `search`, and `mode: "insensitive"` on string operators. Compiled to `WHERE "published" = true` (or `= 1` on SQLite). Partial uniques emit as `CREATE UNIQUE INDEX ... WHERE ...`, not table-level `UNIQUE (...)`. They are valid `findUnique` / `upsert` / `findOrCreate` targets: `where` uses the indexed columns, and the index predicate is applied automatically (`AND` on lookups, `ON CONFLICT (…) WHERE …` on Postgres and SQLite).
1065
+
1066
+ ## Many-to-many
1067
+
1068
+ Declare a virtual column on the source table:
1069
+
1070
+ ```ts
1071
+ posts: table({
1072
+ id: id(),
1073
+ title: text().notNull(),
1074
+ tags: many("tags"),
1075
+ }),
1076
+ tags: table({ id: id(), slug: text().notNull().unique() }),
1077
+ ```
1078
+
1079
+ - `"tags"` is the **target accessor**.
1080
+ - Junction table is auto-generated when `through` is omitted.
1081
+ - SQL name: alpha-sorted SQL table names joined with `_` (e.g. `posts_tags` — no leading `_`).
1082
+ - Accessor equals that identifier (`posts_tags`).
1083
+
1084
+ Reuse an existing junction by **accessor**:
1085
+
1086
+ ```ts
1087
+ posts: table({
1088
+ tags: many("tags", {
1089
+ through: "postTags",
1090
+ leftKey: "postId",
1091
+ rightKey: "tagId",
1092
+ }),
1093
+ }),
1094
+ postTags: table("post_tags", {
1095
+ postId: fk("posts").primary(),
1096
+ tagId: fk("tags").primary(),
1097
+ }),
1098
+ ```
1099
+
1100
+ `as` / `inverse` override relation names on source and target tables.
1101
+
1102
+ Virtual M2M columns are relations only — they appear in `with`, relation filters, and relation writes, not in scalar `select`/`where`/`create` data.
1103
+
1104
+ ## Column naming strategy
1105
+
1106
+ ```ts
1107
+ export const schema = defineSchema(
1108
+ {
1109
+ users: table({ emailAddress: text().notNull() }), // SQL: email_address
1110
+ legacyUsers: table(
1111
+ "legacy_users",
1112
+ { emailAddress: text().notNull() },
1113
+ { columnNaming: "camelCase" },
1114
+ ),
1115
+ },
1116
+ { columnNaming: "snakeCase" },
1117
+ );
1118
+ ```
1119
+
1120
+ ## Custom SQL column names (`.map()`)
1121
+
1122
+ ```ts
1123
+ emailAddress: text().notNull().map("email"),
1124
+ authorId: fk("users").as("author").map("author_ref"),
1125
+ ```
1126
+
1127
+ ## Enum columns
1128
+
1129
+ ```ts
1130
+ status: enumType(["draft", "published", "archived"])
1131
+ .notNull()
1132
+ .default("draft"),
1133
+ ```
1134
+
1135
+ `datasource.enum` in config: `check` (default), `union`, or `native` (PostgreSQL `CREATE TYPE` / MySQL and MariaDB column `ENUM`).
1136
+
1137
+ ## PostgreSQL extensions
1138
+
1139
+ ```ts
1140
+ export const schema = defineSchema(
1141
+ { /* tables */ },
1142
+ { extensions: ["uuid-ossp", "pg_trgm"] },
1143
+ );
1144
+ ```
1145
+
1146
+ Column type plugins still register required extensions automatically.
1147
+
1148
+ ## SQLite type mapping
1149
+
1150
+ Same DSL; see [SQLite](sqlite.md), [MySQL](mysql.md), and [MariaDB](mariadb.md) for storage types and limitations.
1151
+
1152
+ ## Zod schemas
1153
+
1154
+ `neoorm generate` can print Select, Create, and Update **scalar** Zod schemas next to the typed client. Codegen maps your schema to a validator-neutral IR first; Zod, [TypeBox](typebox.md), and [Elysia `t`](elysia.md) are the printers shipped today.
1155
+
1156
+ Generated schemas match the TypeScript shapes of row / insert / update scalars — not nested relation writes (`connect`, `create`, `set`). Those stay TypeScript-only.
1157
+
1158
+ ## Enable
1159
+
1160
+ Install Zod 4 in the app, then opt in:
1161
+
1162
+ ```bash
1163
+ bun add zod
1164
+ ```
1165
+
1166
+ ```ts
1167
+ // neoorm.config.ts
1168
+ import { defineConfig } from "neoorm";
1169
+
1170
+ export default defineConfig({
1171
+ schema: "./schema.ts",
1172
+ out: "./neoorm",
1173
+ datasource: {
1174
+ provider: "postgresql",
1175
+ url: process.env.DATABASE_URL!,
1176
+ },
1177
+ generate: {
1178
+ zod: true,
1179
+ },
1180
+ });
1181
+ ```
1182
+
1183
+ Re-run `neoorm generate` (or `neoorm migrate dev`). Output:
1184
+
1185
+ - `out/zod.ts` — generated schemas
1186
+ - `out/client.ts` re-exports them
1187
+
1188
+ Disable `generate.zod` and generate again to remove `zod.ts`.
1189
+
1190
+ `zod` is an optional peer of NeoOrm. The generated file imports `zod` directly; NeoOrm does not bundle it. If `generate.zod` is on and `zod` is not installed, `neoorm generate` still writes `zod.ts` and prints a warning (`bun add zod`).
1191
+
1192
+ ## Usage
1193
+
1194
+ ```ts
1195
+ import { db, UserCreateSchema, type UserCreate } from "./neoorm/client.js";
1196
+
1197
+ const data: UserCreate = UserCreateSchema.parse(body);
1198
+ await db.users.create({ data });
1199
+ ```
1200
+
1201
+ Per table (accessor `users` → model `User`):
1202
+
1203
+ | Export | Shape |
1204
+ |--------|--------|
1205
+ | `UserSchema` / `UserSelect` | Select row (default query output: no `.hidden()`, includes `timestamps()`) |
1206
+ | `UserCreateSchema` / `UserCreate` | Insert scalars (no primary / serial / `updatedAt`; `defaultNow` defaults optional; includes `.hidden()`) |
1207
+ | `UserUpdateSchema` / `UserUpdate` | Update scalars (no primary / `updatedAt`; all optional; includes `.hidden()`) |
1208
+
1209
+ `UserSelect` / `UserCreate` / `UserUpdate` are `z.infer` aliases. They are named that way so they do not collide with the model type `User` from `models.ts`. Hoisted enums also get a type (`PostStatus` from `PostStatusSchema`).
1210
+
1211
+ `schemas.users.select` / `.create` / `.update` are the same objects.
1212
+
1213
+ `.hidden()` columns (for example `password`) are omitted from select schemas so they match default query results. They stay on create and update so login/register and password-change payloads can still be parsed. Use `includeHidden: true` on the query when the app needs those fields internally.
1214
+
1215
+ `updatedAt` from `timestamps()` (and any `.updatedAt()` column) is ORM-managed. It appears on select schemas only — create and update parsers reject it so API clients cannot stamp it. A `timestamp().defaultNow()` without `.updatedAt()` is just a SQL default: optional on create, writable on update.
1216
+
1217
+ Create with `author: { connect: { id } }` is not in `PostCreateSchema`. Pass the FK scalar (`authorId`) or keep nested writes in TypeScript.
1218
+
1219
+ ## Junction (M2M) tables
1220
+
1221
+ Many-to-many through tables (auto `posts_tags` from `tags: many("tags")`, or an explicit `through` table) are not ordinary entities. Both FK columns are usually the composite primary key, so a naive Create/Update export would be `z.object({})`.
1222
+
1223
+ Codegen treats them as **link** tables:
1224
+
1225
+ | Export | Shape |
1226
+ |--------|--------|
1227
+ | `{Model}Schema` / `{Model}Select` | Junction row (both FK ids, plus extras like `priority`) |
1228
+ | `{Model}LinkCreateSchema` / `{Model}LinkCreate` | Both FK ids required; extra create-allowed columns |
1229
+ | `{Model}CreateSchema` / `{Model}Create` | Alias of `LinkCreateSchema` |
1230
+ | `{Model}UpdateSchema` | Extra scalar columns only. **Omitted** when the junction has no updatable fields |
1231
+
1232
+ Generated comments point at nested writes on the parent (`db.posts.update({ data: { tags: { connect: [{ id }] } } })`). Direct `db.posts_tags.create` is rarely needed. `schemas.posts_tags` has `select` and `create`; `update` is present only when the through table has extra columns.
1233
+
1234
+ Prefer validating parent payloads in TypeScript (`tags: { connect, set, … }`). Junction Zod is for the rare case you insert a link row yourself.
1235
+
1236
+ ## Types and constraints
1237
+
1238
+ Timestamp columns accept both ORM `Date` values and JSON ISO-8601 strings (`2020-01-01T00:00:00.000Z`, including offsets). `parse` always returns a `Date`, so the same schema works for HTTP bodies and query results.
1239
+
1240
+ `z.bigint()` and `Buffer` still match generated models, not JSON. Wrap those fields if the payload is a JSON number/string.
1241
+
1242
+ Typed schema helpers map into Zod:
1243
+
1244
+ | Schema | Zod |
1245
+ |--------|-----|
1246
+ | `.minLength()` / `.maxLength()` / `.notEmpty()` | `z.string().min()` / `.max()` |
1247
+ | `email` / `*Email` column names, or `.email()` on `text` / `citext` | `z.email()` |
1248
+ | `url` / `*Url` column names, or `.url()` on `text` / `citext` | `z.url()` |
1249
+ | `.min()` / `.max()` / `.positive()` on int/serial/bigint | matching number/bigint checks |
1250
+ | same helpers on `decimal()` | `z.string().refine(...)` |
1251
+ | `enumType([...])` | hoisted `z.enum` |
1252
+ | `json()` / `jsonb()` with no type arg | `z.record(z.string(), z.unknown())` |
1253
+ | `jsonb<Record<string, unknown>>()` | `z.record(z.string(), z.unknown())` |
1254
+ | `jsonb<{ featured: boolean }>()` (inline object type) | `z.object({ featured: z.boolean(), … })` |
1255
+ | `json()` / `jsonb()` with `.schema()` validation IR | same as IR (`z.object`, nested `z.record`, …); wins over generics |
1256
+ | `timestamp()` | `Date` or ISO datetime string → `Date` |
1257
+
1258
+ Raw `.check("sql")` is not mapped. With `generate.zod: true`, `generate.typebox: true`, or `generate.elysia: true`, codegen reads inline `json()` / `jsonb()` type arguments from `schema.ts` and maps object literals to `z.object`, `Record<K,V>` to `z.record`, and primitives to the matching Zod types. Type aliases and imported types are not resolved yet — use an inline type or `.schema()` for those.
1259
+
1260
+ ```ts
1261
+ metadata: jsonb<{ featured: boolean; category?: string }>(),
1262
+ // → metadata: z.object({ featured: z.boolean(), category: z.string().optional() }).nullable()
1263
+ ```
1264
+
1265
+ For shapes codegen cannot infer from the generic, use `.schema()`:
1266
+
1267
+ ```ts
1268
+ metadata: jsonb().schema({
1269
+ kind: "object",
1270
+ fields: [
1271
+ {
1272
+ name: "featured",
1273
+ type: { kind: "boolean" },
1274
+ nullable: false,
1275
+ optional: false,
1276
+ },
1277
+ ],
1278
+ }),
1279
+ ```
1280
+
1281
+ ## TypeBox schemas
1282
+
1283
+ `neoorm generate` can print Select, Create, and Update **scalar** TypeBox schemas next to the typed client. Codegen maps your schema to a validator-neutral IR first; TypeBox 1.x is one printer (alongside [Zod](zod.md) and [Elysia `t`](elysia.md)).
1284
+
1285
+ Generated schemas match the TypeScript shapes of row / insert / update scalars — not nested relation writes (`connect`, `create`, `set`). Those stay TypeScript-only.
1286
+
1287
+ Schemas target **TypeBox 1.x** (`typebox` on npm), not `@sinclair/typebox` 0.x. For Elysia 1.x route `body:`, use [`generate.elysia`](elysia.md) instead.
1288
+
1289
+ ## Enable
1290
+
1291
+ Install TypeBox 1.x in the app, then opt in:
1292
+
1293
+ ```bash
1294
+ bun add typebox
1295
+ ```
1296
+
1297
+ ```ts
1298
+ // neoorm.config.ts
1299
+ import { defineConfig } from "neoorm";
1300
+
1301
+ export default defineConfig({
1302
+ schema: "./schema.ts",
1303
+ out: "./neoorm",
1304
+ datasource: {
1305
+ provider: "postgresql",
1306
+ url: process.env.DATABASE_URL!,
1307
+ },
1308
+ generate: {
1309
+ typebox: true,
1310
+ },
1311
+ });
1312
+ ```
1313
+
1314
+ Re-run `neoorm generate` (or `neoorm migrate dev`). Output:
1315
+
1316
+ - `out/typebox.ts` — generated schemas
1317
+ - `out/client.ts` re-exports them
1318
+
1319
+ Disable `generate.typebox` and generate again to remove `typebox.ts`.
1320
+
1321
+ `typebox` is an optional peer of NeoOrm. The generated file imports `typebox` directly; NeoOrm does not bundle it. If `generate.typebox` is on and `typebox` is not installed, `neoorm generate` still writes `typebox.ts` and prints a warning (`bun add typebox`).
1322
+
1323
+ You can enable Zod, TypeBox, and Elysia together. When Zod is on, `client.ts` keeps Zod as a flat `export *` and namespaces TypeBox and Elysia (`export * as typebox` / `export * as elysia`). TypeBox + Elysia without Zod keeps TypeBox flat and namespaces Elysia. Import TypeBox from `./typebox.js` (or `typebox.UserCreateSchema` from the client) when it is namespaced. For Elysia 1.x `body:`, use [`generate.elysia`](elysia.md).
1324
+
1325
+ ## Usage
1326
+
1327
+ ```ts
1328
+ import Value from "typebox/value";
1329
+ import { db, UserCreateSchema, type UserCreate } from "./neoorm/client.js";
1330
+
1331
+ const data: UserCreate = Value.Decode(UserCreateSchema, body);
1332
+ await db.users.create({ data });
1333
+ ```
1334
+
1335
+ `Value.Check` returns a boolean and does not run codecs. Use `Value.Decode` when you need timestamp fields as `Date` (the TypeBox equivalent of Zod `parse`). Invalid data throws.
1336
+
1337
+ Per table (accessor `users` → model `User`):
1338
+
1339
+ | Export | Shape |
1340
+ |--------|--------|
1341
+ | `UserSchema` / `UserSelect` | Select row (default query output: no `.hidden()`, includes `timestamps()`) |
1342
+ | `UserCreateSchema` / `UserCreate` | Insert scalars (no primary / serial / `updatedAt`; `defaultNow` defaults optional; includes `.hidden()`) |
1343
+ | `UserUpdateSchema` / `UserUpdate` | Update scalars (no primary / `updatedAt`; all optional; includes `.hidden()`) |
1344
+
1345
+ `UserSelect` / `UserCreate` / `UserUpdate` are `Type.StaticDecode` aliases. They are named that way so they do not collide with the model type `User` from `models.ts`. Hoisted enums also get a type (`PostStatus` from `PostStatusSchema`).
1346
+
1347
+ `schemas.users.select` / `.create` / `.update` are the same objects.
1348
+
1349
+ `.hidden()` columns (for example `password`) are omitted from select schemas so they match default query results. They stay on create and update so login/register and password-change payloads can still be parsed. Use `includeHidden: true` on the query when the app needs those fields internally.
1350
+
1351
+ `updatedAt` from `timestamps()` (and any `.updatedAt()` column) is ORM-managed. It appears on select schemas only — create and update parsers reject it so API clients cannot stamp it. A `timestamp().defaultNow()` without `.updatedAt()` is just a SQL default: optional on create, writable on update.
1352
+
1353
+ Create with `author: { connect: { id } }` is not in `PostCreateSchema`. Pass the FK scalar (`authorId`) or keep nested writes in TypeScript.
1354
+
1355
+ ## Junction (M2M) tables
1356
+
1357
+ Many-to-many through tables (auto `posts_tags` from `tags: many("tags")`, or an explicit `through` table) are not ordinary entities. Both FK columns are usually the composite primary key, so a naive Create/Update export would be `Type.Object({})`.
1358
+
1359
+ Codegen treats them as **link** tables:
1360
+
1361
+ | Export | Shape |
1362
+ |--------|--------|
1363
+ | `{Model}Schema` / `{Model}Select` | Junction row (both FK ids, plus extras like `priority`) |
1364
+ | `{Model}LinkCreateSchema` / `{Model}LinkCreate` | Both FK ids required; extra create-allowed columns |
1365
+ | `{Model}CreateSchema` / `{Model}Create` | Alias of `LinkCreateSchema` |
1366
+ | `{Model}UpdateSchema` | Extra scalar columns only. **Omitted** when the junction has no updatable fields |
1367
+
1368
+ Generated comments point at nested writes on the parent (`db.posts.update({ data: { tags: { connect: [{ id }] } } })`). Direct `db.posts_tags.create` is rarely needed. `schemas.posts_tags` has `select` and `create`; `update` is present only when the through table has extra columns.
1369
+
1370
+ Prefer validating parent payloads in TypeScript (`tags: { connect, set, … }`). Junction TypeBox is for the rare case you insert a link row yourself.
1371
+
1372
+ ## Types and constraints
1373
+
1374
+ Timestamp columns accept both ORM `Date` values and JSON ISO-8601 strings (`2020-01-01T00:00:00.000Z`, including offsets). `Value.Decode` always returns a `Date`, so the same schema works for HTTP bodies and query results. `Value.Check` accepts either form without converting.
1375
+
1376
+ `Type.BigInt()` and `Buffer` still match generated models, not JSON. Wrap those fields if the payload is a JSON number/string.
1377
+
1378
+ The generated file registers `uuid`, `email`, `url`, and `date-time` on TypeBox’s `Format` registry when those formats appear, without overwriting a format you already set.
1379
+
1380
+ Typed schema helpers map into TypeBox:
1381
+
1382
+ | Schema | TypeBox |
1383
+ |--------|---------|
1384
+ | `.minLength()` / `.maxLength()` / `.notEmpty()` | `Type.String({ minLength, maxLength })` |
1385
+ | `email` / `*Email` column names, or `.email()` on `text` / `citext` | `Type.String({ format: "email" })` |
1386
+ | `url` / `*Url` column names, or `.url()` on `text` / `citext` | `Type.String({ format: "url" })` |
1387
+ | `.min()` / `.max()` / `.positive()` on int/serial | `Type.Integer({ minimum, maximum, exclusiveMinimum: 0 })` |
1388
+ | same helpers on `bigint` | `Type.BigInt({ minimum, maximum, exclusiveMinimum: 0n })` |
1389
+ | same helpers on `decimal()` | `Type.Refine(Type.String(), …)` |
1390
+ | `enumType([...])` | hoisted `Type.Enum` |
1391
+ | `json()` / `jsonb()` with no type arg | `Type.Record(Type.String(), Type.Unknown())` |
1392
+ | `jsonb<Record<string, unknown>>()` | `Type.Record(Type.String(), Type.Unknown())` |
1393
+ | `jsonb<{ featured: boolean }>()` (inline object type) | `Type.Object({ featured: Type.Boolean(), … })` |
1394
+ | `json()` / `jsonb()` with `.schema()` validation IR | same as IR (`Type.Object`, nested `Type.Record`, …); wins over generics |
1395
+ | `timestamp()` | `Date` or ISO datetime string → `Date` (`Type.Codec` + `Value.Decode`) |
1396
+ | `bytea()` | `Type.Refine` + `Buffer.isBuffer` |
1397
+
1398
+ Raw `.check("sql")` is not mapped. With `generate.typebox: true` (or `generate.zod` / `generate.elysia`), codegen reads inline `json()` / `jsonb()` type arguments from `schema.ts` and maps object literals to `Type.Object`, `Record<K,V>` to `Type.Record`, and primitives to the matching TypeBox types. Type aliases and imported types are not resolved yet — use an inline type or `.schema()` for those.
1399
+
1400
+ ```ts
1401
+ metadata: jsonb<{ featured: boolean; category?: string }>(),
1402
+ // → metadata: Type.Union([Type.Object({ featured: Type.Boolean(), category: Type.Optional(Type.String()) }), Type.Null()])
1403
+ ```
1404
+
1405
+ For shapes codegen cannot infer from the generic, use `.schema()`:
1406
+
1407
+ ```ts
1408
+ metadata: jsonb().schema({
1409
+ kind: "object",
1410
+ fields: [
1411
+ {
1412
+ name: "featured",
1413
+ type: { kind: "boolean" },
1414
+ nullable: false,
1415
+ optional: false,
1416
+ },
1417
+ ],
1418
+ }),
1419
+ ```
1420
+
1421
+ ## Elysia schemas
1422
+
1423
+ `neoorm generate` can print Select, Create, and Update **scalar** Elysia `t` schemas next to the typed client. Codegen maps your schema to a validator-neutral IR first; this printer emits `import { t } from "elysia"` so the objects drop into route `body:` / `query:` the same way as hand-written `t.Object({ title: t.String() })`.
1424
+
1425
+ Generated schemas match the TypeScript shapes of row / insert / update scalars — not nested relation writes (`connect`, `create`, `set`). Those stay TypeScript-only.
1426
+
1427
+ This is the printer to use with **Elysia 1.x**. [TypeBox 1.x](typebox.md) (`import Type from "typebox"`) is a different package and will not type-check as Elysia `body:`.
1428
+
1429
+ ## Enable
1430
+
1431
+ Install Elysia in the app, then opt in:
1432
+
1433
+ ```bash
1434
+ bun add elysia
1435
+ ```
1436
+
1437
+ ```ts
1438
+ // neoorm.config.ts
1439
+ import { defineConfig } from "neoorm";
1440
+
1441
+ export default defineConfig({
1442
+ schema: "./schema.ts",
1443
+ out: "./neoorm",
1444
+ datasource: {
1445
+ provider: "postgresql",
1446
+ url: process.env.DATABASE_URL!,
1447
+ },
1448
+ generate: {
1449
+ elysia: true,
1450
+ },
1451
+ });
1452
+ ```
1453
+
1454
+ Re-run `neoorm generate` (or `neoorm migrate dev`). Output:
1455
+
1456
+ - `out/elysia.ts` — generated schemas
1457
+ - `out/client.ts` re-exports them
1458
+
1459
+ Disable `generate.elysia` and generate again to remove `elysia.ts`.
1460
+
1461
+ `elysia` is an optional peer of NeoOrm. The generated file imports `elysia` directly; NeoOrm does not bundle it. If `generate.elysia` is on and `elysia` is not installed, `neoorm generate` still writes `elysia.ts` and prints a warning (`bun add elysia`).
1462
+
1463
+ When Zod or TypeBox 1.x is also enabled, `client.ts` keeps those as documented and re-exports Elysia as `export * as elysia from "./elysia.js"` so names do not collide. Import from `./elysia.js` (or `elysia.UserCreateSchema`) in that case.
1464
+
1465
+ ## Usage
1466
+
1467
+ ```ts
1468
+ import { Elysia } from "elysia";
1469
+ import { db, UserCreateSchema, UserUpdateSchema } from "./neoorm/client.js";
1470
+
1471
+ new Elysia()
1472
+ .post("/users", ({ body }) => db.users.create({ data: body }), {
1473
+ body: UserCreateSchema,
1474
+ })
1475
+ .patch("/users/:id", ({ params, body }) =>
1476
+ db.users.update({
1477
+ where: { id: params.id },
1478
+ data: body,
1479
+ }), {
1480
+ body: UserUpdateSchema,
1481
+ },
1482
+ )
1483
+ .listen(3000);
1484
+ ```
1485
+
1486
+ Elysia validates `body` against the `t.Object` schema. `t.Date()` accepts ISO-8601 strings, `Date` values, and epoch numbers, and decodes to `Date`.
1487
+
1488
+ Per table (accessor `users` → model `User`):
1489
+
1490
+ | Export | Shape |
1491
+ |--------|--------|
1492
+ | `UserSchema` / `UserSelect` | Select row (default query output: no `.hidden()`, includes `timestamps()`) |
1493
+ | `UserCreateSchema` / `UserCreate` | Insert scalars (no primary / serial / `updatedAt`; `defaultNow` defaults optional; includes `.hidden()`) |
1494
+ | `UserUpdateSchema` / `UserUpdate` | Update scalars (no primary / `updatedAt`; all optional; includes `.hidden()`) |
1495
+
1496
+ `UserSelect` / `UserCreate` / `UserUpdate` are `typeof Schema.static` aliases. They are named that way so they do not collide with the model type `User` from `models.ts`. Hoisted enums also get a type (`PostStatus` from `PostStatusSchema`).
1497
+
1498
+ `schemas.users.select` / `.create` / `.update` are the same objects.
1499
+
1500
+ `.hidden()` columns (for example `password`) are omitted from select schemas so they match default query results. They stay on create and update so login/register and password-change payloads can still be parsed. Use `includeHidden: true` on the query when the app needs those fields internally.
1501
+
1502
+ `updatedAt` from `timestamps()` (and any `.updatedAt()` column) is ORM-managed. It appears on select schemas only — create and update parsers reject it so API clients cannot stamp it. A `timestamp().defaultNow()` without `.updatedAt()` is just a SQL default: optional on create, writable on update.
1503
+
1504
+ Create with `author: { connect: { id } }` is not in `PostCreateSchema`. Pass the FK scalar (`authorId`) or keep nested writes in TypeScript.
1505
+
1506
+ ## Junction (M2M) tables
1507
+
1508
+ Many-to-many through tables (auto `posts_tags` from `tags: many("tags")`, or an explicit `through` table) are not ordinary entities. Both FK columns are usually the composite primary key, so a naive Create/Update export would be `t.Object({})`.
1509
+
1510
+ Codegen treats them as **link** tables:
1511
+
1512
+ | Export | Shape |
1513
+ |--------|--------|
1514
+ | `{Model}Schema` / `{Model}Select` | Junction row (both FK ids, plus extras like `priority`) |
1515
+ | `{Model}LinkCreateSchema` / `{Model}LinkCreate` | Both FK ids required; extra create-allowed columns |
1516
+ | `{Model}CreateSchema` / `{Model}Create` | Alias of `LinkCreateSchema` |
1517
+ | `{Model}UpdateSchema` | Extra scalar columns only. **Omitted** when the junction has no updatable fields |
1518
+
1519
+ Generated comments point at nested writes on the parent (`db.posts.update({ data: { tags: { connect: [{ id }] } } })`). Direct `db.posts_tags.create` is rarely needed. `schemas.posts_tags` has `select` and `create`; `update` is present only when the through table has extra columns.
1520
+
1521
+ Prefer validating parent payloads in TypeScript (`tags: { connect, set, … }`). Junction Elysia schemas are for the rare case you insert a link row yourself.
1522
+
1523
+ ## Types and constraints
1524
+
1525
+ `t.BigInt()` and `Buffer` still match generated models, not JSON. Wrap those fields if the payload is a JSON number/string.
1526
+
1527
+ Typed schema helpers map into Elysia `t`:
1528
+
1529
+ | Schema | Elysia |
1530
+ |--------|--------|
1531
+ | `.minLength()` / `.maxLength()` / `.notEmpty()` | `t.String({ minLength, maxLength })` |
1532
+ | `email` / `*Email` column names, or `.email()` on `text` / `citext` | `t.String({ format: "email" })` |
1533
+ | `url` / `*Url` column names, or `.url()` on `text` / `citext` | `t.String({ format: "uri" })` |
1534
+ | `.min()` / `.max()` / `.positive()` on int/serial | `t.Integer({ minimum, maximum, exclusiveMinimum: 0 })` |
1535
+ | same helpers on `bigint` | `t.BigInt({ minimum, maximum, exclusiveMinimum: 0n })` |
1536
+ | same helpers on `decimal()` | `t.Transform(t.String()).Decode(...)` |
1537
+ | `enumType([...])` | hoisted `t.UnionEnum` |
1538
+ | `json()` / `jsonb()` with no type arg | `t.Record(t.String(), t.Unknown())` |
1539
+ | `jsonb<Record<string, unknown>>()` | `t.Record(t.String(), t.Unknown())` |
1540
+ | `jsonb<{ featured: boolean }>()` (inline object type) | `t.Object({ featured: t.Boolean(), … })` |
1541
+ | `json()` / `jsonb()` with `.schema()` validation IR | same as IR (`t.Object`, nested `t.Record`, …); wins over generics |
1542
+ | `timestamp()` | `t.Date()` (ISO string / Date / epoch → `Date`) |
1543
+ | `bytea()` | `t.Transform(t.Any())` + `Buffer.isBuffer` |
1544
+ | nullable / optional | `t.Nullable` / `t.Optional` |
1545
+
1546
+ Raw `.check("sql")` is not mapped. With `generate.elysia: true` (or Zod/TypeBox), codegen reads inline `json()` / `jsonb()` type arguments from `schema.ts`. Type aliases and imported types are not resolved yet — use an inline type or `.schema()` for those.
1547
+
1548
+ ```ts
1549
+ metadata: jsonb<{ featured: boolean; category?: string }>(),
1550
+ // → metadata: t.Nullable(t.Object({ featured: t.Boolean(), category: t.Optional(t.String()) }))
1551
+ ```
1552
+
1553
+ ## Queries
1554
+
1555
+ ## Find
1556
+
1557
+ ```ts
1558
+ // By ID (scalar primary key — pass a string)
1559
+ const user = await db.users.findById(id);
1560
+
1561
+ // By composite primary key — pass an object with all PK columns
1562
+ const item = await db.items.findById({ tenantId: "t1", itemCode: "c1" });
1563
+
1564
+ // First match (null if not found)
1565
+ const user = await db.users.findFirst({ where: { email: "a@b.com" } });
1566
+
1567
+ // All matching
1568
+ const users = await db.users.findMany({
1569
+ where: { published: true },
1570
+ take: 20,
1571
+ skip: 40,
1572
+ });
1573
+
1574
+ // First match after skip, or with DISTINCT ON (null if not found)
1575
+ await db.users.findFirst({ orderBy: { email: "asc" }, skip: 1 });
1576
+ await db.users.findFirst({
1577
+ distinct: ["email"],
1578
+ orderBy: { email: "asc" },
1579
+ });
1580
+
1581
+ // Unique constraint lookup
1582
+ const user = await db.users.findUnique({ where: { slug: "hello" } });
1583
+ ```
1584
+
1585
+ Unique `where` (`findUnique`, `upsert`, `findOrCreate`) is scalar equality per unique field — `{ email: "a@b.com" }`, not a filter. `{ equals: value }` is accepted and unwrapped to the scalar. Other operators (`contains`, `in`, `mode: "insensitive"`) throw `unique_where_invalid`. Partial unique indexes are valid targets: pass the indexed columns; the index `WHERE` is applied automatically so the lookup stays unique. Singular `update`/`delete` start from the same unique identification but additionally accept extra filter fields, which are `AND`'d onto the statement (see below).
1586
+
1587
+ ## Create
1588
+
1589
+ ```ts
1590
+ const created = await db.users.create({ data: { email: "a@b.com" } });
1591
+ created.id; // primary key is always returned
1592
+
1593
+ // Pass returnCreated: true to get the full row (defaults, timestamps, …)
1594
+ const user = await db.users.create({
1595
+ data: { email: "a@b.com" },
1596
+ returnCreated: true,
1597
+ });
1598
+
1599
+ // `with` also returns the full row (plus relations)
1600
+ const post = await db.posts.create({
1601
+ data: { title: "Hello", body: "…", authorId: user.id },
1602
+ with: { author: true },
1603
+ });
1604
+
1605
+ // Bulk insert (returns count)
1606
+ const count = await db.users.createMany({
1607
+ data: [{ email: "a@b.com" }, { email: "b@b.com" }],
1608
+ });
1609
+
1610
+ // Skip unique / primary-key conflicts (`ON CONFLICT DO NOTHING`).
1611
+ // Does not ignore CHECK or foreign-key violations.
1612
+ const skipped = await db.users.createMany({
1613
+ data: [{ email: "a@b.com" }, { email: "a@b.com" }],
1614
+ skipDuplicates: true,
1615
+ });
1616
+
1617
+ // Bulk insert with returned rows (serial IDs, UUIDs, defaults materialized)
1618
+ const users = await db.users.createManyAndReturn({
1619
+ data: [{ email: "a@b.com" }, { email: "b@b.com" }],
1620
+ skipDuplicates: true,
1621
+ });
1622
+ ```
1623
+
1624
+ On MySQL (no `RETURNING`), serial-PK batches are hydrated with a follow-up re-select so DB-side defaults come back too; MariaDB uses `RETURNING` directly.
1625
+
1626
+ Unknown keys in `data` fail at compile time (`unknown_column`), same as `where`.
1627
+
1628
+ ## Update
1629
+
1630
+ Singular `update` requires a unique `where` (primary key, `@unique` column, composite unique index, or partial unique index). Extra filter fields are allowed and compiled as additional `AND` predicates, so conditional single-row writes stay atomic — if the extras do not match, the update touches 0 rows and returns `null`. Use `updateMany` when there is no unique identifier.
1631
+
1632
+ ```ts
1633
+ // Revoke a session only if it is still unrevoked (one statement)
1634
+ await db.sessions.update({
1635
+ where: { token, revokedAt: { isNull: true } },
1636
+ data: { revokedAt: new Date() },
1637
+ });
1638
+ ```
1639
+
1640
+ ```ts
1641
+ // Returns {} on success, null if no row matched
1642
+ const updated = await db.users.update({
1643
+ where: { id: userId },
1644
+ data: { email: "new@b.com" },
1645
+ });
1646
+
1647
+ // Pass returnUpdated: true to get the updated row back
1648
+ const user = await db.users.update({
1649
+ where: { id: userId },
1650
+ data: { email: "new@b.com" },
1651
+ returnUpdated: true,
1652
+ });
1653
+
1654
+ // By ID (scalar PK — string; composite PK — object)
1655
+ const user = await db.users.updateById(id, { data: { name: "New" } });
1656
+ const item = await db.items.updateById(
1657
+ { tenantId: "t1", itemCode: "c1" },
1658
+ { data: { name: "Updated" } },
1659
+ );
1660
+
1661
+ // Multiple records
1662
+ const count = await db.users.updateMany({
1663
+ where: { email: { contains: "@old" } },
1664
+ data: { status: "archived" },
1665
+ });
1666
+
1667
+ const updated = await db.users.updateManyAndReturn({
1668
+ where: { email: { contains: "@old" } },
1669
+ data: { status: "archived" },
1670
+ });
1671
+
1672
+ const post = await db.posts.update({
1673
+ where: { id: postId },
1674
+ data: { title: "New" },
1675
+ with: { author: true }, // full row plus relations; no returnUpdated needed
1676
+ });
1677
+ ```
1678
+
1679
+ Numeric columns (`int`, `serial`, `decimal`, `bigint`) also accept `{ increment }`, `{ decrement }`, `{ multiply }`, and `{ set }`. These compile to `col = col ±/* $n` so concurrent updates do not clobber each other. `{ set: value }` is the same assignment as a plain value. `NULL + 1` stays SQL NULL.
1680
+
1681
+ ```ts
1682
+ await db.posts.update({
1683
+ where: { id: postId },
1684
+ data: { views: { increment: 1 } },
1685
+ });
1686
+
1687
+ await db.posts.updateMany({
1688
+ where: { published: true },
1689
+ data: { views: { multiply: 2 } },
1690
+ });
1691
+ ```
1692
+
1693
+ On dialects without `RETURNING` (MySQL; MariaDB for updates), `returnUpdated` / `updateManyAndReturn` re-select the rows after writing: by primary key when the table has one (composite keys included), otherwise by the original predicate. PK-less re-selects return currently-matching rows, so if `data` changed a filtered column the result may differ from the pre-update set.
1694
+
1695
+ Top-level to-one relation filters in `updateMany` compile to `UPDATE..JOIN` on MySQL/MariaDB (automatic) instead of `EXISTS` subqueries; pass `useJoin: true` for the `UPDATE..FROM` form on PostgreSQL (`useJoin: false` forces `EXISTS`). To-many (`some`/`every`/`none`), M2M, and nested filters always use `EXISTS`. SQLite has no join form — `useJoin: true` fails there.
1696
+ ```
1697
+
1698
+ ## Delete
1699
+
1700
+ Singular `delete` requires a unique `where`. Like `update`, it accepts extra filter fields that are `AND`'d onto the statement — a miss deletes 0 rows and returns `null`. Use `deleteMany` for bulk deletes.
1701
+
1702
+ ```ts
1703
+ // Returns {} on success, null if no row matched
1704
+ await db.users.delete({ where: { id: userId } });
1705
+
1706
+ // Pass returnDeleted: true to get the deleted row back
1707
+ const deleted = await db.users.delete({
1708
+ where: { id: userId },
1709
+ returnDeleted: true,
1710
+ });
1711
+
1712
+ // `with` also returns the full row (plus relations), even without returnDeleted
1713
+ const post = await db.posts.delete({
1714
+ where: { id: postId },
1715
+ with: { author: true },
1716
+ });
1717
+
1718
+ // Scalar PK — pass a string
1719
+ await db.users.deleteById(userId);
1720
+
1721
+ // Composite PK — pass an object
1722
+ await db.items.deleteById({ tenantId: "t1", itemCode: "c1" });
1723
+
1724
+ const count = await db.users.deleteMany({
1725
+ where: { email: { contains: "@spam" } },
1726
+ });
1727
+
1728
+ const deletedRows = await db.users.deleteManyAndReturn({
1729
+ where: { email: { contains: "@spam" } },
1730
+ });
1731
+ ```
1732
+
1733
+ ## Upsert
1734
+
1735
+ ```ts
1736
+ await db.users.upsert({
1737
+ where: { email: "a@b.com" },
1738
+ create: { email: "a@b.com", name: "A" },
1739
+ update: { name: "A" },
1740
+ });
1741
+ ```
1742
+
1743
+ ## Find or create
1744
+
1745
+ Atomically insert a row or return an existing one when a unique constraint matches. Unlike `upsert`, existing rows are never updated.
1746
+
1747
+ `where` must identify a unique constraint (same rules as `findUnique` and `upsert`).
1748
+
1749
+ Postgres issues `INSERT … ON CONFLICT DO UPDATE` with a no-op assignment so `RETURNING` always includes the row — including when a concurrent session already inserted it. `created` is `(xmax = 0)` (inserted vs existing). A no-op update can fire `UPDATE` triggers; column values are not changed.
1750
+
1751
+ Under `SERIALIZABLE` (and sometimes `REPEATABLE READ`), a concurrent insert can still abort the transaction with a serialization failure. Retry the whole `$transaction`. SQLite, MySQL, and MariaDB use find-then-insert and retry the find after a unique violation.
1752
+
1753
+ ```ts
1754
+ const { record, created } = await db.tags.findOrCreate({
1755
+ where: { slug: "orm" },
1756
+ create: { slug: "orm", name: "ORM" },
1757
+ });
1758
+ // created: true if a new row was inserted, false if an existing row was returned
1759
+ ```
1760
+
1761
+ ## Where clauses
1762
+
1763
+ ### Column filters
1764
+
1765
+ Equality shorthand and explicit operators:
1766
+
1767
+ ```ts
1768
+ await db.posts.findMany({ where: { published: true } });
1769
+
1770
+ await db.posts.findFirst({
1771
+ where: {
1772
+ title: { contains: "ORM" },
1773
+ views: { gte: 100 },
1774
+ id: { in: ["post_1", "post_2"] },
1775
+ },
1776
+ });
1777
+ ```
1778
+
1779
+ Unknown column names and operators fail at compile time (`unknown_column`, with “did you mean” hints). JSON columns may still use a plain object as equality (`{ metadata: { featured: true } }`).
1780
+
1781
+ Operator bags are named filter interfaces (`StringFilter`, `ComparableFilter`, `JsonFilter` — also exported from `neoorm/schema`), so a wrong operator points at the filter instead of dumping the whole input type:
1782
+
1783
+ ```ts
1784
+ // error TS2353: 'contains' does not exist in type 'ComparableFilter<string>'.
1785
+ await db.posts.findMany({ where: { price: { contains: "9" } } });
1786
+ ```
1787
+
1788
+ | Type | Operators |
1789
+ |------|-----------|
1790
+ | String | `equals`, `contains`, `startsWith`, `endsWith`, `search`, `searchTs`, `in`, `notIn`, `mode` |
1791
+ | Numeric / Date | `equals`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn` |
1792
+ | JSON | `jsonContains`, `hasKey`, `hasAnyKeys`, `hasAllKeys`, `path` |
1793
+ | PostGIS (`geometry`, `geography`, `point`) | `intersects`, `within`, `dWithin` |
1794
+ | All nullable | `isNull`, `isNotNull` |
1795
+
1796
+ `contains`, `startsWith`, `endsWith`, and `equals` compile to `LIKE` / `=`. Pass sibling `mode: "insensitive"` for case-folding (`ILIKE` on Postgres, `LOWER(col) LIKE LOWER($n)` on SQLite/MySQL/MariaDB). SQLite `LIKE` is ASCII case-insensitive even in default mode. `%` and `_` in the search string are matched literally (`ESCAPE '\'` on Postgres/SQLite, `ESCAPE '\\'` on MySQL/MariaDB).
1797
+
1798
+ `search` is POSIX regex on PostgreSQL (`~`, or `~*` with `mode: "insensitive"`), `REGEXP_LIKE` on MySQL 8, `REGEXP` on MariaDB, and JavaScript `RegExp` (`REGEXP` / `regexp_i`) on SQLite.
1799
+
1800
+ `searchTs` is full-text search: `to_tsvector(...) @@ plainto_tsquery(...)` on PostgreSQL, `MATCH ... AGAINST (... IN NATURAL LANGUAGE MODE)` on MySQL/MariaDB, and unsupported on SQLite (it fails at query-build time — SQLite FTS5 needs virtual tables). It works on `text` / `citext` columns and directly on `tsvector()` columns (no `to_tsvector` wrapper):
1801
+
1802
+ ```ts
1803
+ await db.posts.findMany({
1804
+ where: { title: { searchTs: "orm tutorial" } },
1805
+ });
1806
+
1807
+ await db.posts.findMany({
1808
+ where: {
1809
+ title: {
1810
+ searchTs: { query: "orm tutorial", language: "german", parser: "phrase" },
1811
+ },
1812
+ },
1813
+ });
1814
+ ```
1815
+
1816
+ `parser` is `plain` (`plainto_tsquery`, the default), `phrase` (`phraseto_tsquery`), or `websearch` (`websearch_to_tsquery`). `language` defaults to `english` and must match `/^[a-z_]+$/` since it is interpolated as the `regconfig`. On MySQL/MariaDB the `language` and `parser` options are accepted but ignored.
1817
+
1818
+ JSON operators on PostgreSQL use `@>`, `?`, and `#>` / `#>>`. On SQLite they compile to `json_patch` (object containment), `json_each` (key existence), and `json_extract` (path). On MySQL and MariaDB they compile to `JSON_CONTAINS`, `JSON_CONTAINS_PATH`, and `JSON_EXTRACT`.
1819
+
1820
+ ```ts
1821
+ await db.posts.findMany({
1822
+ where: { title: { contains: "orm", mode: "insensitive" } },
1823
+ });
1824
+
1825
+ await db.users.findMany({
1826
+ where: { email: { equals: "Ada@Example.com", mode: "insensitive" } },
1827
+ });
1828
+
1829
+ await db.posts.findMany({
1830
+ where: { title: { search: "^Neo", mode: "insensitive" } },
1831
+ });
1832
+ ```
1833
+
1834
+ ### Logical combinators
1835
+
1836
+ ```ts
1837
+ await db.users.findMany({
1838
+ where: {
1839
+ OR: [
1840
+ { email: { contains: "@example.com" } },
1841
+ { email: { contains: "@test.com" } },
1842
+ ],
1843
+ NOT: { name: { isNull: true } },
1844
+ createdAt: { gte: new Date("2025-01-01") },
1845
+ },
1846
+ });
1847
+ ```
1848
+
1849
+ Empty `OR: []` matches no rows. Empty `AND: []` matches every row. Empty `in: []` is false; `{ OR: [{ id: { in: [] } }, { name: "Ada" }] }` still matches the other branch.
1850
+
1851
+ ### Relation filters
1852
+
1853
+ ```ts
1854
+ // users who have at least one published post
1855
+ await db.users.findMany({
1856
+ where: { posts: { some: { published: true } } },
1857
+ });
1858
+
1859
+ // posts whose author has a verified email
1860
+ await db.posts.findMany({
1861
+ where: { author: { email: { contains: "@" } } },
1862
+ });
1863
+
1864
+ // posts tagged "orm" (many-to-many via junction table)
1865
+ await db.posts.findMany({
1866
+ where: { tags: { some: { slug: "orm" } } },
1867
+ });
1868
+ ```
1869
+
1870
+ | Pattern | Use for |
1871
+ |---------|---------|
1872
+ | `authorId: "user_1"` | Filter by FK column value |
1873
+ | `author: { email: "a@b.c" }` | Filter by related record (to-one) |
1874
+ | `posts: { some: { ... } }` | At least one related record matches (to-many) |
1875
+ | `posts: { every: { ... } }` | All related records match (to-many). Compiled as `NOT EXISTS` of related rows that fail the nested filter. |
1876
+ | `posts: { none: { ... } }` | No related records match (to-many) |
1877
+
1878
+ Relation filters compile to SQL `EXISTS` subqueries, so they work with `findMany`, `count`, `updateMany`, and `deleteMany` without duplicate rows.
1879
+
1880
+ ### JSON and decimal filters
1881
+
1882
+ ```ts
1883
+ // jsonb — partial / subset match (`@>` on PostgreSQL, `json_patch` on SQLite)
1884
+ await db.posts.findMany({
1885
+ where: { metadata: { jsonContains: { featured: true } } },
1886
+ });
1887
+
1888
+ // jsonb — key existence and path filters
1889
+ await db.posts.findMany({
1890
+ where: {
1891
+ metadata: {
1892
+ hasKey: "featured",
1893
+ hasAnyKeys: ["category", "tags"],
1894
+ path: { segments: ["category"], equals: "engineering" },
1895
+ },
1896
+ },
1897
+ });
1898
+
1899
+ // decimal — compare as strings
1900
+ await db.posts.findMany({
1901
+ where: { price: { gte: "9.99", lte: "49.99" } },
1902
+ });
1903
+ ```
1904
+
1905
+ ### `distinct`
1906
+
1907
+ PostgreSQL `DISTINCT ON` — `orderBy` must lead with the same columns (key order is the ORDER BY prefix):
1908
+
1909
+ ```ts
1910
+ await db.users.findMany({
1911
+ distinct: ["email"],
1912
+ orderBy: { email: "asc" },
1913
+ });
1914
+ ```
1915
+
1916
+ Not supported on MySQL or MariaDB (`DISTINCT ON` is PostgreSQL-only; SQLite emulates it with `ROW_NUMBER()`). Use `groupBy({ by: [...] })` or a raw `db.sql` query instead.
1917
+
1918
+ ## Eager loading with `with`
1919
+
1920
+ Relations are eagerly loaded in `findMany`, `findFirst`, and `findById` via the `with` option.
1921
+
1922
+ ### Strategy
1923
+
1924
+ NeoORM uses a relation-loading planner that picks the fewest round-trips per `with` tree:
1925
+
1926
+ - **LEFT JOIN** — to-one relations where the parent table owns the foreign key are resolved in the main query. No additional round-trips.
1927
+ - **JOIN + `json_agg`** — a single inverse has-many include may use one collection `LEFT JOIN` with `GROUP BY`. A second collection is a correlated subquery so sizes N and M do not become N×M rows.
1928
+ - **Correlated `json_agg` subquery** — inverse has-many relations (and simple linear nested chains) are embedded in the main `SELECT` as correlated subqueries. Multiple top-level relations can be inlined in the same query alongside JOINs.
1929
+ - **Inline `COUNT` subquery** — simple `_count` on has-many relations is embedded in the main `SELECT`. At most one `_count` collection uses a `LEFT JOIN`; additional counts are correlated `COUNT(*)` subqueries.
1930
+ - **Batch query** — many-to-many, nested `with` on to-one, sibling-heavy trees, and other complex shapes fall back to batched `WHERE IN (...)` queries (one per relation level). Sibling batch queries at the same level run in parallel. Nested `take`/`skip` on those batches apply **per parent** (window ranking), not to the whole `IN` list.
1931
+
1932
+ Typical query counts:
1933
+
1934
+ - `with: { author: true }` → **1** query (JOIN)
1935
+ - `with: { posts: true }` → **1** query (`json_agg` subquery)
1936
+ - `with: { author: true, comments: true }` → **1** query (JOIN + `json_agg`)
1937
+ - `with: { posts: { with: { comments: { with: { author: true } } } } }` on a linear chain → **1** query (nested `json_agg`)
1938
+ - M2M or sibling nested relations → **1 + batch levels** (parallel per level)
1939
+
1940
+ ```ts
1941
+ // Single query — author via LEFT JOIN
1942
+ const posts = await db.posts.findMany({ with: { author: true } });
1943
+
1944
+ // Last 5 published comments per post
1945
+ const posts = await db.posts.findMany({
1946
+ with: {
1947
+ comments: {
1948
+ where: { published: true },
1949
+ orderBy: { createdAt: "desc" },
1950
+ take: 5,
1951
+ },
1952
+ },
1953
+ });
1954
+
1955
+ // Single query — author via JOIN, comments via correlated json_agg
1956
+ const posts = await db.posts.findMany({
1957
+ with: { author: true, comments: { with: { author: true } } },
1958
+ });
1959
+ ```
1960
+
1961
+ To-one includes with `where` load as a batch query: the relation is the matching row, or `null`.
1962
+
1963
+ ### Selective return types
1964
+
1965
+ Root `select` and `omit` project the parent row. Relation shapes still come from `with` (including nested `select`). `select` and `omit` cannot be used together.
1966
+
1967
+ ```ts
1968
+ const users = await db.users.findMany({
1969
+ select: { id: true, email: true },
1970
+ with: { posts: { select: { title: true } } },
1971
+ });
1972
+ // { id, email, posts: Array<{ title }> }
1973
+
1974
+ const publicUsers = await db.users.findMany({
1975
+ omit: { createdAt: true, updatedAt: true },
1976
+ });
1977
+ ```
1978
+
1979
+ Nested relation `select` also narrows the TypeScript return type:
1980
+
1981
+ ```ts
1982
+ const users = await db.users.findMany({
1983
+ with: { posts: { select: { title: true } } },
1984
+ });
1985
+ // users[0].posts[0].title is string; .body is excluded from the type
1986
+ ```
1987
+
1988
+ ### Sensitive columns (`.hidden()`) and API responses (`strip`)
1989
+
1990
+ Mark sensitive columns in the schema with `.hidden()`:
1991
+
1992
+ ```ts
1993
+ password: text().notNull().hidden(),
1994
+ ```
1995
+
1996
+ By default, queries **omit** hidden columns from the result (root table and nested `with` includes). Pass `includeHidden: true` when the app needs them (for example password verification on login):
1997
+
1998
+ ```ts
1999
+ const user = await db.users.findFirst({
2000
+ where: { email: input.email },
2001
+ includeHidden: true,
2002
+ });
2003
+ ```
2004
+
2005
+ Nested includes follow the same rule:
2006
+
2007
+ ```ts
2008
+ const posts = await db.posts.findMany({
2009
+ with: { author: true }, // author.password omitted
2010
+ });
2011
+
2012
+ const posts = await db.posts.findMany({
2013
+ with: { author: { includeHidden: true } },
2014
+ });
2015
+ ```
2016
+
2017
+ You can still use `select` to fetch a subset that includes hidden columns. Use query `omit` to skip non-hidden columns you do not want fetched. Use `.strip()` when you fetched sensitive fields internally but want a safe object for the response:
2018
+
2019
+ ```ts
2020
+ const user = await db.users.findById(id, { includeHidden: true });
2021
+ if (!user) return null;
2022
+ return user.strip(); // plain object without password
2023
+
2024
+ return user.strip({ refreshToken: true }); // hidden + extra column keys
2025
+ ```
2026
+
2027
+ `.strip()` is attached when the table has `.hidden()` columns (and on nested `with` rows that do). It is non-enumerable, recurses into nested relations, and returns a plain object (no `.strip` method) suitable for JSON responses.
2028
+
2029
+ ## Offset pagination
2030
+
2031
+ Page numbers, admin tables, and **Paginated Read** benchmarks should use `findMany({ take, skip, orderBy })`. That path is `LIMIT`/`OFFSET` on the same cached SELECT as find-all (no extra row, no cursor encoding).
2032
+
2033
+ ```ts
2034
+ const page = await db.posts.findMany({
2035
+ orderBy: { id: "asc" },
2036
+ take: 20,
2037
+ skip: 40,
2038
+ });
2039
+ ```
2040
+
2041
+ Do not use `paginate` for that comparison: `paginate` is keyset pagination (`take + 1`, cursors, `hasMore`).
2042
+
2043
+ ## Cursor pagination
2044
+
2045
+ For feeds, infinite scroll, and large tables, use `paginate` instead of `take`/`skip`. It uses **keyset pagination** on your `orderBy` columns plus the table primary key as a stable tiebreaker.
2046
+
2047
+ ```ts
2048
+ let cursor: { createdAt: Date; id: string } | null = null;
2049
+
2050
+ for (;;) {
2051
+ const page = await db.posts.paginate({
2052
+ where: { published: true },
2053
+ orderBy: { createdAt: "desc" },
2054
+ take: 20,
2055
+ ...(cursor ? { after: cursor } : {}),
2056
+ with: { author: true },
2057
+ });
2058
+
2059
+ for (const post of page.items) { /* render */ }
2060
+
2061
+ if (!page.hasMore) break;
2062
+ cursor = page.nextCursor;
2063
+ }
2064
+ ```
2065
+
2066
+ - `orderBy` is required; scalar `id` is appended automatically when omitted.
2067
+ - `select`, `omit`, and `includeHidden` work like `findMany`. Cursor fields used by `orderBy` stay in the SQL (and in `nextCursor` / `prevCursor`) even when omitted from `items`.
2068
+ - `take` is the page size; `hasMore` / `hasPrevious` use a `take + 1` probe in the query direction. A `before` page also probes once forward so `hasMore` is not assumed true. After an `after` page, `hasPrevious` is true because `after` was passed.
2069
+ - `after` is a typed cursor (`nextCursor` from the previous page). `before` walks the other way (`prevCursor`).
2070
+ - `after` and `before` can be combined as exclusive window bounds; pass both on every windowed call.
2071
+ - For HTTP APIs, encode cursors with `encodeCursor` / `decodeCursor` from `neoorm`.
2072
+ - On feed tables, add a composite index on the sort columns.
2073
+
2074
+ Walk backward (e.g. “load newer” on a descending feed):
2075
+
2076
+ ```ts
2077
+ const newer = await db.posts.paginate({
2078
+ where: { published: true },
2079
+ orderBy: { createdAt: "desc" },
2080
+ take: 20,
2081
+ before: page.prevCursor,
2082
+ });
2083
+ ```
2084
+
2085
+ ## Aggregates
2086
+
2087
+ ### Relation counts in `with`
2088
+
2089
+ ```ts
2090
+ const users = await db.users.findMany({
2091
+ with: {
2092
+ _count: { posts: true },
2093
+ profile: true,
2094
+ },
2095
+ });
2096
+ // users[0]._count.posts === number
2097
+ ```
2098
+
2099
+ Optional per-relation filter: `_count: { posts: { where: { published: true } } }`.
2100
+
2101
+ ### Table-level `aggregate()`
2102
+
2103
+ ```ts
2104
+ const stats = await db.posts.aggregate({
2105
+ where: { published: true },
2106
+ _count: { _all: true, authorId: true },
2107
+ _avg: { views: true },
2108
+ });
2109
+ // { _count: { _all: number, authorId: number }, _avg: { views: number | null } }
2110
+ ```
2111
+
2112
+ `_count: true` is still `COUNT(*)` and returns `_count: number`. A field map uses `COUNT(col)` (nulls are skipped) — not `DISTINCT`.
2113
+
2114
+ Single-table grouped stats use `groupBy()`. Cross-table dashboards still use `db.sql` (same compiler as `neoorm/sql`) or `sqlBuilder` composed into `db.sql`.
2115
+
2116
+ ### `groupBy()`
2117
+
2118
+ ```ts
2119
+ const byAuthor = await db.posts.groupBy({
2120
+ by: ["authorId"],
2121
+ where: { published: true },
2122
+ _count: { _all: true, authorId: true },
2123
+ _avg: { views: true },
2124
+ having: { _count: { _all: { gte: 5 }, authorId: { gt: 0 } } },
2125
+ orderBy: { _count: { _all: "desc" } },
2126
+ take: 10,
2127
+ });
2128
+ // { authorId: string, _count: { _all: number, authorId: number }, _avg: { views: number | null } }[]
2129
+ ```
2130
+
2131
+ Star `_count: true` still works with `having: { _count: { gte: 5 } }` and `orderBy: { _count: "desc" }`.
2132
+
2133
+ `where` filters rows before grouping. `having` filters groups (aggregates only). `by` alone lists distinct groups — useful when you need grouped aggregates rather than `findMany({ distinct })`.
2134
+
2135
+ ## Count
2136
+
2137
+ ```ts
2138
+ const total = await db.users.count({ where: { active: true } });
2139
+ const uniqueEmails = await db.users.count({ distinct: "email" });
2140
+ const fieldCounts = await db.users.count({
2141
+ select: { _all: true, email: true },
2142
+ });
2143
+ // { _all: number, email: number }
2144
+
2145
+ const taken = await db.users.exists({ where: { email: "a@b.com" } });
2146
+ ```
2147
+
2148
+ `count()` returns a number unless `select` is set. `distinct` is `COUNT(DISTINCT col)` on one column and cannot be combined with `select`. Field `select` / `_count` maps are non-null `COUNT(col)`, not distinct.
2149
+
2150
+ `exists()` runs `SELECT 1 … LIMIT 1` and returns a boolean. Use `count()` when you need the actual number of matches.
2151
+
2152
+ ## Raw SQL
2153
+
2154
+ `db.sql` and `import { sql } from "neoorm/sql"` share one compiler. Interpolate values, `sqlId("table")`, nested `sql\`...\`` fragments, or `sqlBuilder.compile()`. `sqlBuilder` covers select/join/where/group/order/limit with bound params; use `db.sql` for HAVING and other raw tails.
2155
+
2156
+ ```ts
2157
+ import { sql, sqlBuilder, sqlId } from "neoorm/sql";
2158
+
2159
+ const ident = sqlId("users");
2160
+ const filter = sql`email = ${"a@b.com"}`;
2161
+ const rows = await db.sql`SELECT * FROM ${ident} WHERE ${filter}`;
2162
+
2163
+ const grouped = sqlBuilder
2164
+ .selectFrom("users")
2165
+ .select(["id", "email"])
2166
+ .where("email", "=", "a@b.com")
2167
+ .orWhere(sql`role = ${"admin"}`)
2168
+ .groupBy("id", "email")
2169
+ .limit(10)
2170
+ .compile();
2171
+ await db.sql`${grouped} HAVING count(*) > ${0}`;
2172
+ ```
2173
+
2174
+ `db.execute({ text, params })` runs already-compiled SQL. With PostgreSQL tenant `schema`, `db.sql` / `db.execute` qualify unqualified manifest table names (same as `db.sqlId("users")`). Already-qualified `schema.table` identifiers are unchanged.
2175
+
2176
+ ## Logging SQL
2177
+
2178
+ Pass `beforeQuery` / `afterQuery` when creating the client. They run for repository methods and `db.sql` / `db.execute`, including queries inside `$transaction`. BEGIN/COMMIT/SAVEPOINT are not included.
2179
+
2180
+ ```ts
2181
+ const db = createNeoOrmClient(manifest, {
2182
+ afterQuery: ({ sql, params, durationMs, error }) => {
2183
+ if (error || durationMs > 100) {
2184
+ console.warn({ sql, params, durationMs, error });
2185
+ }
2186
+ },
2187
+ });
2188
+ ```
2189
+
2190
+ ## Relation writes
2191
+
2192
+ `create` and `update` accept nested relation writes alongside scalar fields. Relation-only updates are supported (no scalar `SET` required). Nested to-one `create` on `update` (and `create`) runs in the same transaction as the parent row so a failed parent write does not leave an orphan.
2193
+
2194
+ ## Supported operations
2195
+
2196
+ | Relation kind | Operations |
2197
+ |---------------|------------|
2198
+ | **To-one** (outgoing FK) | `connect`, `connectOrCreate`, `create`, `disconnect` (nullable FK only) |
2199
+ | **One-to-many** (inverse) | `create`, `connect`, `disconnect`, `set`, `delete` |
2200
+ | **Many-to-many** | `connect`, `connectOrCreate`, `disconnect`, `set`, `delete` |
2201
+
2202
+ `connect`, `set`, `disconnect`, and `delete` identify related rows by the **target table's primary key**: `{ id: ... }` or `{ userId: ... }` for a scalar PK, or an object with **every** PK column for a composite key. A single-column `fk()` still stores one referenced value; a composite `foreignKey()` extra assigns **each** local column from the matching target field (never a concatenated PK string).
2203
+
2204
+ A relation field must be a **pure write bag**: every key is one of those operations. Mixing a write op with extra fields (`{ create: { name: "A" }, foo: 1 }`), passing a scalar, or passing `{}` throws `invalid_nested_write` — the parent row is not written. To-one `connectOrCreate` cannot be mixed with `connect` or `create`.
2205
+
2206
+ ## Examples
2207
+
2208
+ ### To-one on update
2209
+
2210
+ ```ts
2211
+ await db.posts.update({
2212
+ where: { id: postId },
2213
+ data: {
2214
+ author: { connect: { id: userId } },
2215
+ // or create the author if missing:
2216
+ // author: { connectOrCreate: { where: { email }, create: { email, password } } },
2217
+ },
2218
+ });
2219
+ ```
2220
+
2221
+ Composite primary key:
2222
+
2223
+ ```ts
2224
+ await db.orders.update({
2225
+ where: { id: orderId },
2226
+ data: {
2227
+ lines: { connect: [{ tenantId: "acme", lineNo: 1 }] },
2228
+ },
2229
+ });
2230
+ ```
2231
+
2232
+ ### Nested create on one-to-many
2233
+
2234
+ ```ts
2235
+ await db.posts.update({
2236
+ where: { id: postId },
2237
+ data: {
2238
+ comments: {
2239
+ create: [{ body: "Hi", author: { connect: { id: userId } } }],
2240
+ connect: [{ id: commentId }],
2241
+ disconnect: [{ id: oldCommentId }], // or `true` to unlink all
2242
+ set: [{ id: commentId }], // replace all links
2243
+ delete: [{ id: commentId }], // or `true` to delete all linked children
2244
+ },
2245
+ },
2246
+ });
2247
+ ```
2248
+
2249
+ ### Many-to-many on create or update
2250
+
2251
+ ```ts
2252
+ await db.posts.update({
2253
+ where: { id: postId },
2254
+ data: {
2255
+ tags: {
2256
+ connect: [{ id: tagId }],
2257
+ set: [{ id: tagId }],
2258
+ connectOrCreate: [
2259
+ { where: { slug: "orm" }, create: { slug: "orm", name: "ORM" } },
2260
+ ],
2261
+ delete: [{ id: tagId }],
2262
+ },
2263
+ },
2264
+ });
2265
+ ```
2266
+
2267
+ ### Relation writes in updateMany
2268
+
2269
+ `updateMany` applies scalar `SET` once, then nested writes per matched parent:
2270
+
2271
+ ```ts
2272
+ await db.posts.updateMany({
2273
+ where: { published: true },
2274
+ data: {
2275
+ status: "archived",
2276
+ tags: { connect: [{ id: tagId }] },
2277
+ },
2278
+ });
2279
+ ```
2280
+
2281
+ ## Operation order
2282
+
2283
+ Within a single relation field, operations run in order: `delete` → `disconnect` → `set` → `connect` / `connectOrCreate` → `create`. Mixing `set` with `connect` or `create` is discouraged — `set` replaces the full link set.
2284
+
2285
+ ## Transactions
2286
+
2287
+ ## Interactive callback
2288
+
2289
+ ```ts
2290
+ await db.$transaction(async (tx) => {
2291
+ const user = await tx.users.create({ data: { email: "a@b.com" } });
2292
+ await tx.posts.create({
2293
+ data: { title: "Hello", authorId: user.id },
2294
+ });
2295
+ });
2296
+ ```
2297
+
2298
+ ## Batch steps
2299
+
2300
+ Sequential, one transaction:
2301
+
2302
+ ```ts
2303
+ const [user, post] = await db.$transaction([
2304
+ (tx) => tx.users.create({ data: { email: "a@b.com" } }),
2305
+ (tx) => tx.posts.create({ data: { title: "Hello" } }),
2306
+ ]);
2307
+ ```
2308
+
2309
+ ## Options
2310
+
2311
+ ```ts
2312
+ await db.$transaction(fn, {
2313
+ isolationLevel: "Serializable", // ReadUncommitted | ReadCommitted | RepeatableRead | Serializable
2314
+ readOnly: true,
2315
+ });
2316
+ ```
2317
+
2318
+ ## Nested transactions
2319
+
2320
+ Uses database savepoints on the same connection. A nested failure rolls back only that block; the outer transaction can continue.
2321
+
2322
+ ```ts
2323
+ await db.$transaction(async (tx) => {
2324
+ await tx.users.create({ data: { email: "outer@example.com" } });
2325
+
2326
+ await tx.$transaction(async (nested) => {
2327
+ await nested.posts.create({
2328
+ data: { title: "Nested", body: "...", authorId: "user_1" },
2329
+ });
2330
+ }).catch(() => undefined);
2331
+
2332
+ // Outer writes are kept even if the nested block failed.
2333
+ });
2334
+ ```
2335
+
2336
+ Nested `$transaction` uses a savepoint on the same connection, not a new `BEGIN`. `readOnly` and `isolationLevel` are only valid on the outermost `$transaction`; passing them on a nested call throws.
2337
+
2338
+ ### SQLite
2339
+
2340
+ SQLite has no `BEGIN READ ONLY` and no SQL isolation levels. Outer `$transaction` options map as follows:
2341
+
2342
+ | Option | SQLite |
2343
+ |--------|--------|
2344
+ | `readOnly: true` | `PRAGMA query_only = ON` for the duration of the transaction (writes fail) |
2345
+ | `isolationLevel: "RepeatableRead"` or `"Serializable"` | `BEGIN IMMEDIATE` (reserved lock before the first statement) |
2346
+ | `isolationLevel: "ReadUncommitted"` or `"ReadCommitted"` | `BEGIN` (deferred; default) |
2347
+ | nested `$transaction` | `SAVEPOINT` — `readOnly` / `isolationLevel` are rejected |
2348
+
2349
+ After the transaction commits or rolls back, `query_only` is turned off so later writes succeed.
2350
+
2351
+ ## Migrations
2352
+
2353
+ ## Commands
2354
+
2355
+ | Command | Description |
2356
+ |---------|-------------|
2357
+ | `neoorm init` | Scaffold `neoorm.config.ts`, `schema.ts`, `.env.example` only (no codegen or migrations; run `neoorm migrate dev` after) |
2358
+ | `neoorm generate [--name <name>]` | Emit manifest, typed client, models, includes, and migrations (`--name` only when a migration is created) |
2359
+ | `neoorm migrate dev [--name <name>]` | Apply pending migrations (or record colliding CREATE TABLE files against the live schema), then generate a new one if `schema.ts` changed (`--name` only when a new migration is created) |
2360
+ | `neoorm migrate diff [--accept-data-loss] [--json]` | Preview the SQL a new migration would contain — no files written, no `--name` needed |
2361
+ | `neoorm migrate deploy` | Apply pending migrations (throws `migration_drift` if a pending `CREATE TABLE` targets a relation that already exists) |
2362
+ | `neoorm migrate status` | List applied vs pending migrations |
2363
+ | `neoorm migrate down [--steps N]` | Roll back the last N applied migrations (default 1) |
2364
+ | `neoorm migrate reset --force` | Drop the `public` schema (PostgreSQL) or all tables (SQLite / MySQL / MariaDB) and re-apply migrations (local dev) |
2365
+ | `neoorm db push` | Push the current `schema.ts` to the database (no migration file) |
2366
+ | `neoorm db pull` | Introspect the database into a schema file |
2367
+
2368
+ ## Generate outcomes
2369
+
2370
+ `neoorm generate` always refreshes generated TypeScript files. It prints one of four outcomes:
2371
+
2372
+ | Outcome | Meaning |
2373
+ |---------|---------|
2374
+ | Schema unchanged | Snapshot hash matches — no manifest or migration changes |
2375
+ | Client regenerated | Manifest changed but no database DDL needed |
2376
+ | Migration created | New `migrations/<timestamp>_<name>/migration.sql` written |
2377
+ | Migration blocked | Destructive or manual changes prevented writing SQL |
2378
+
2379
+ Migration names are slugified (`Add Users` → `add_users`, capped at 50 chars) and prefixed with a timestamp so folders sort in apply order. `--name` is only needed when a migration is created: the CLI prompts on a TTY at that point (after pending migrations are applied in `dev`) and errors in non-interactive runs. Runs with no schema changes (client regeneration only) and pending-apply-only `dev` runs succeed without `--name` and never prompt. Older `*_migration` folders still apply in order.
2380
+
2381
+ When migration is blocked, the CLI explains why — for example unsupported type casts (`alter_column_type_manual`), enum value changes, or destructive drops. Re-run with `--accept-data-loss` to include destructive DDL:
2382
+
2383
+ ```bash
2384
+ neoorm generate --name add_users --accept-data-loss
2385
+ ```
2386
+
2387
+ To inspect the SQL before naming the migration, preview it first (read-only, needs no `--name`):
2388
+
2389
+ ```bash
2390
+ neoorm migrate diff
2391
+ neoorm migrate diff --json
2392
+ ```
2393
+
2394
+ ## Existing tables vs pending CREATE TABLE
2395
+
2396
+ `generate` diffs `snapshot.json`. If that file is missing, the first migration is a full `CREATE TABLE` script. `migrate deploy` will not run that SQL when those relations already exist (for example after `db push`). It throws `migration_drift` before any user statement.
2397
+
2398
+ `neoorm migrate dev` detects the same collision, diffs the live database to `schema.ts`, applies that catch-up SQL (often none, sometimes `ALTER`), records the pending files in `_neoorm_migrations` using the on-disk checksums, and writes `snapshot.json`. Destructive catch-up still requires `--accept-data-loss`. Empty databases still run `CREATE TABLE` as before.
2399
+
2400
+ ## Deploy locking and checksums
2401
+
2402
+ `neoorm migrate deploy` serializes concurrent runs and records a SHA-256 checksum of each applied `migration.sql`:
2403
+
2404
+ | Dialect | Lock |
2405
+ |---------|------|
2406
+ | PostgreSQL | `pg_advisory_xact_lock` on the deploy transaction |
2407
+ | SQLite | `BEGIN IMMEDIATE` around the whole deploy (after `PRAGMA foreign_keys = OFF`) |
2408
+ | MySQL | `GET_LOCK('neoorm.migrate.<db>', timeout)` / `RELEASE_LOCK` on the deploy connection |
2409
+ | MariaDB | `GET_LOCK('neoorm.migrate.<db>', timeout)` / `RELEASE_LOCK` on the deploy connection |
2410
+
2411
+ The ledger table `_neoorm_migrations` stores `name` and `checksum`. Editing an already-applied `migration.sql` is rejected (`migration_guard`) — restore the original file or add a new migration. Pending migrations in one deploy run apply in that locked transaction; if a later statement fails, none of that run's new ledger rows remain.
2412
+
2413
+ ## Status
2414
+
2415
+ ```bash
2416
+ neoorm migrate status
2417
+ ```
2418
+
2419
+ Shows applied migrations (with timestamps), pending folders on disk, and warnings for drift (applied in DB but missing on disk).
2420
+
2421
+ ## Reset
2422
+
2423
+ ```bash
2424
+ neoorm migrate reset --force
2425
+ ```
2426
+
2427
+ Drops the `public` schema (PostgreSQL) or all tables (SQLite, MySQL, and MariaDB) and re-applies all migrations from disk. Requires `--force`. Use `--skip-apply` to only drop without re-applying.
2428
+
2429
+ PostgreSQL reset recreates the schema owned by the connecting role. It does not `GRANT ALL ON SCHEMA … TO PUBLIC`.
2430
+
2431
+ ## Rollback
2432
+
2433
+ `down.sql` and `snapshot.before.json` are written automatically when `neoorm generate` or `neoorm migrate dev` creates a migration. The down SQL is the reverse schema diff (`next → prev`), with destructive changes accepted so rollbacks can drop columns or tables added in the forward migration.
2434
+
2435
+ ```bash
2436
+ neoorm migrate down
2437
+ neoorm migrate down --steps 2
2438
+ ```
2439
+
2440
+ Rolls back the most recently applied migration(s) by running each migration folder's `down.sql`, removing the ledger entry from `_neoorm_migrations`, and restoring `snapshot.json` from `snapshot.before.json`.
2441
+
2442
+ Legacy migrations without `down.sql` cannot be rolled back — re-generate the migration or add `down.sql` manually.
2443
+
2444
+ Migration folders are not deleted on rollback (same as Prisma). Re-run `neoorm migrate deploy` to re-apply rolled-back migrations.
2445
+
2446
+ After rollback, `schema.ts` may still describe a newer schema than the restored snapshot; `neoorm migrate dev` may generate a new forward migration. `db push` and `migrate down` are independent — push ignores the migration ledger. `db push` compiles `schema.ts` (not `snapshot.json`) and updates `snapshot.json` after a successful push so a later `generate` does not emit DDL already applied to the database.
2447
+
2448
+ For a full wipe during local development, use `neoorm migrate reset --force` instead.
2449
+
2450
+ ## Common errors
2451
+
2452
+ NeoOrm surfaces structured errors with dialect-agnostic codes, optional subclasses for constraint violations, and fix suggestions. Catch them programmatically or read the CLI output after `neoorm generate` / query failures.
2453
+
2454
+ ## Error types
2455
+
2456
+ - **`NeoOrmError`** — base class for all NeoOrm errors
2457
+ - **`NeoOrmSchemaError`** — schema compilation (`neoorm generate`) or migration failures
2458
+ - **`NeoOrmQueryError`** — query builder mistakes (compile phase) or database runtime failures
2459
+ - **Constraint subclasses** — `UniqueViolationError`, `ForeignKeyViolationError`, `NotNullViolationError`, `CheckViolationError`, `InvalidInputError`, `SchemaDriftError`
2460
+ - **`QueryCompileError`** — compile-time query builder mistakes (`phase: "compile"`)
2461
+ - **`NeoOrmDriverError`** — unclassified driver/SQL failures (`driver_error`)
2462
+
2463
+ Every error exposes **`err.code`** (typed string) and **`err.context`** with `detail`, `suggestions`, and optional table/column metadata.
2464
+
2465
+ ```ts
2466
+ import {
2467
+ UniqueViolationError,
2468
+ ForeignKeyViolationError,
2469
+ NeoOrmQueryError,
2470
+ QueryErrorCode,
2471
+ isUniqueViolation,
2472
+ } from "neoorm";
2473
+
2474
+ try {
2475
+ await db.users.create({ data: { email: "a@b.com" } });
2476
+ } catch (err) {
2477
+ if (err instanceof UniqueViolationError) {
2478
+ return { status: 409, column: err.context.columnTsName };
2479
+ }
2480
+ if (err instanceof ForeignKeyViolationError) {
2481
+ return { status: 400 };
2482
+ }
2483
+ if (err instanceof NeoOrmQueryError) {
2484
+ switch (err.code) {
2485
+ case QueryErrorCode.not_null_violation:
2486
+ case QueryErrorCode.invalid_input:
2487
+ return { status: 400 };
2488
+ default:
2489
+ throw err;
2490
+ }
2491
+ }
2492
+ throw err;
2493
+ }
2494
+ ```
2495
+
2496
+ Type guards (`isUniqueViolation`, `isQueryCompileError`, `isNeoOrmError`, …) are exported for bundle-boundary safety.
2497
+
2498
+ ## Query error codes
2499
+
2500
+ Codes are **dialect-agnostic** — the same `unique_violation` code is used for PostgreSQL, SQLite, MySQL, and MariaDB.
2501
+
2502
+ | Code | Subclass | Typical HTTP | When |
2503
+ |------|----------|--------------|------|
2504
+ | `unique_violation` | `UniqueViolationError` | 409 | Duplicate unique key |
2505
+ | `foreign_key_violation` | `ForeignKeyViolationError` | 400 | Missing parent row |
2506
+ | `not_null_violation` | `NotNullViolationError` | 400 | Required column null |
2507
+ | `check_violation` | `CheckViolationError` | 400 | Check/enum rejected |
2508
+ | `invalid_input` | `InvalidInputError` | 400 | Type mismatch (PG `22P02`) |
2509
+ | `relation_not_found` | `SchemaDriftError` | 500 | Table missing (PG `42P01`) |
2510
+ | `column_not_found` | `SchemaDriftError` | 500 | Column missing (PG `42703`) |
2511
+ | `empty_returning` | — | 500 | INSERT … RETURNING returned no row |
2512
+ | `connection_error` | — | 503 | `$connect` failed |
2513
+ | `driver_error` | `NeoOrmDriverError` | 500 | Unclassified driver/SQL failure |
2514
+ | `unknown_table` | `QueryCompileError` | 400 | Bad table accessor |
2515
+ | `unknown_column` | `QueryCompileError` | 400 | Bad column in where/select/omit/groupBy |
2516
+ | `unknown_relation` | `QueryCompileError` | 400 | Bad relation name on `with` or nested writes |
2517
+ | `invalid_args` | `QueryCompileError` | 400 | Unsupported where/having operator |
2518
+ | `invalid_nested_write` | `QueryCompileError` | 400 | Relation field is not a pure nested write bag |
2519
+ | `invalid_cursor` | `QueryCompileError` | 400 | Malformed or mismatched pagination cursor |
2520
+ | `unique_where_invalid` | `QueryCompileError` | 400 | unique where is not unique, or uses filter operators |
2521
+ | `where_required` | `QueryCompileError` | 400 | update/delete missing where |
2522
+ | `missing_primary_key` | `QueryCompileError` | 400 | Table or RETURNING row is missing a primary key |
2523
+ | `unsupported_operation` | `QueryCompileError` | 400 | Operator not supported on this dialect (e.g. SQLite `search`) |
2524
+
2525
+ PostgreSQL SQLSTATE is preserved on `err.context.pgCode` when available.
2526
+
2527
+ `findUnique` / `findFirst` return `null` when no row is found — they do not throw a not-found error.
2528
+
2529
+ ## Schema: use accessors, not SQL names
2530
+
2531
+ Foreign keys and `many()` targets refer to **schema accessors** (the keys in `defineSchema({ ... })`), not SQL table names.
2532
+
2533
+ ```ts
2534
+ // Wrong — "server_members" is the SQL table name
2535
+ userId: fk("server_members")
2536
+
2537
+ // Right — "serverMembers" is the accessor
2538
+ userId: fk("serverMembers")
2539
+ ```
2540
+
2541
+ If you see `Foreign key references unknown table accessor "server_members"`, check the suggestions block for the correct camelCase accessor.
2542
+
2543
+ ## Queries: use TypeScript column names
2544
+
2545
+ Filters, `select`, `omit`, `orderBy`, `groupBy`, and `create`/`update` `data` use **TypeScript property names** from your schema, not SQL `snake_case` names. Unknown keys fail at compile time (`unknown_column`).
2546
+
2547
+ ```ts
2548
+ // Wrong
2549
+ db.users.findMany({ where: { created_at: { gt: date } } });
2550
+
2551
+ // Right
2552
+ db.users.findMany({ where: { createdAt: { gt: date } } });
2553
+ ```
2554
+
2555
+ ## Schema drift
2556
+
2557
+ Runtime errors like missing tables/columns often mean the database is behind your schema:
2558
+
2559
+ 1. Run `neoorm migrate dev` (or `neoorm migrate deploy` in production)
2560
+ 2. Regenerate the client with `neoorm generate`
2561
+
2562
+ `NeoOrmQueryError` may include a **Migration** line with pending migration info when schema drift is detected.
2563
+
2564
+ ## After changing the schema
2565
+
2566
+ Always regenerate after editing `schema.ts`:
2567
+
2568
+ ```bash
2569
+ neoorm generate
2570
+ ```
2571
+
2572
+ If migrations are enabled, create and apply them:
2573
+
2574
+ ```bash
2575
+ neoorm migrate dev
2576
+ ```
2577
+
2578
+ ## CLI reference
2579
+
2580
+ Commands that read `neoorm.config.ts` load `.env` from the current working directory first. Existing environment variables take precedence.
2581
+
2582
+ ## `neoorm init`
2583
+
2584
+ Scaffold a new NeoOrm project.
2585
+
2586
+ ```
2587
+ neoorm init [options]
2588
+ ```
2589
+
2590
+ Creates `neoorm.config.ts`, `schema.ts`, and `.env.example` only — no codegen or migrations. Run `neoorm migrate dev` afterwards to generate the client and first migration.
2591
+
2592
+ On a TTY, `neoorm init` opens a Clack wizard (provider, database URL, schema path, output directory). If any scaffold files already exist, it asks before overwriting. Ctrl+C cancels. Flags skip the matching step. In CI / non-TTY, it uses defaults and fails if files already exist unless `--force` is passed.
2593
+
2594
+ Options:
2595
+ - `--provider <provider>` — `postgresql` (default), `sqlite`, `mysql`, or `mariadb`. `postgres` and `pg` are aliases of `postgresql`. If omitted on a TTY, prompts to choose.
2596
+ - `--database-url <url>` — override database URL / file path (default: `postgresql://postgres:postgres@localhost:5432/myapp` for postgres, `./dev.db` for sqlite, `mysql://root@localhost:3306/myapp` for mysql, `mariadb://root@localhost:3306/myapp` for mariadb)
2597
+ - `--schema <path>` — schema file path (default: `./schema.ts`). Must end in `.ts`, `.mts`, or `.cts`.
2598
+ - `--out <dir>` — generated output directory (default: `./neoorm`). Cannot be the same path as the schema file.
2599
+ - `--force` — overwrite existing scaffold files without confirming
2600
+
2601
+ ## `neoorm generate`
2602
+
2603
+ Generate manifest, typed client, models, includes, and migrations from your schema. With `generate.zod` in config, also writes `out/zod.ts` (install `zod` `^4`; generate warns if it is missing). With `generate.typebox`, also writes `out/typebox.ts` (install `typebox` `^1`; generate warns if it is missing). With `generate.elysia`, also writes `out/elysia.ts` (install `elysia` `>=1.2`; generate warns if it is missing).
2604
+
2605
+ ```
2606
+ neoorm generate [options]
2607
+ ```
2608
+
2609
+ Options:
2610
+ - `-n, --name <name>` — migration name (e.g. `--name add_users`). Only needed when a migration is created: prompts on a TTY at that point, errors in CI. Folders are `<timestamp>_<slug>` with the slug capped at 50 chars. No-change runs (client regeneration only) and pending-apply-only `dev` runs never prompt and succeed without `--name`.
2611
+ - `--accept-data-loss` — include destructive DDL changes (column drops, type changes)
2612
+
2613
+ ## `neoorm migrate dev`
2614
+
2615
+ Apply pending migrations, then generate a new migration if the schema changed. If a pending `CREATE TABLE` targets a relation that already exists, catch the live database up to `schema.ts` and record those files as applied instead of re-running the create.
2616
+
2617
+ ```
2618
+ neoorm migrate dev --name <name>
2619
+ ```
2620
+
2621
+ - `-n, --name <name>` — same as `generate`: only needed when `dev` creates a migration, prompts on a TTY at that point, errors in CI. Applying pending migrations never requires `--name`. Not used by `deploy`, `status`, `reset`, or `down`.
2622
+
2623
+ ## `neoorm migrate diff`
2624
+
2625
+ Preview the migration SQL `generate` / `migrate dev` would write, without writing anything.
2626
+
2627
+ ```
2628
+ neoorm migrate diff [--accept-data-loss] [--json]
2629
+ ```
2630
+
2631
+ Read-only: diffs `schema.ts` against `snapshot.json` only. No database connection, no files written, and `--name` is never needed (ignored if passed). Shows the exact statements that would go into `migration.sql`, so you can inspect a change before naming it.
2632
+
2633
+ - `--accept-data-loss` — include destructive DDL in the preview (by default destructive changes are reported as blocked, same as `generate`)
2634
+ - `--json` — print `{ schemaChanged, sql, blocked, destructiveBlocked, warnings }` instead of human-readable text
2635
+
2636
+ Exit code is `1` when destructive changes would block the migration (mirrors `generate`), `0` otherwise. For already-written but unapplied migrations, use `migrate status`.
2637
+
2638
+ ## `neoorm migrate deploy`
2639
+
2640
+ Apply all pending migrations. Throws `migration_drift` if a pending `CREATE TABLE` would hit an existing relation.
2641
+
2642
+ ```
2643
+ neoorm migrate deploy
2644
+ ```
2645
+
2646
+ ## `neoorm migrate status`
2647
+
2648
+ List applied vs pending migrations.
2649
+
2650
+ ```
2651
+ neoorm migrate status
2652
+ ```
2653
+
2654
+ Shows timestamps, pending folders on disk, and warnings for applied migrations missing on disk.
2655
+
2656
+ ## `neoorm migrate down`
2657
+
2658
+ Roll back the most recently applied migration(s).
2659
+
2660
+ ```
2661
+ neoorm migrate down [--steps N]
2662
+ ```
2663
+
2664
+ - `--steps` — number of migrations to roll back (default: 1)
2665
+
2666
+ Requires `down.sql` and `snapshot.before.json` in each migration folder (written automatically by `neoorm generate` and `neoorm migrate dev`).
2667
+
2668
+ ## `neoorm migrate reset`
2669
+
2670
+ Drop the `public` schema (PostgreSQL) or all tables (SQLite, MySQL, and MariaDB) and re-apply all migrations.
2671
+
2672
+ ```
2673
+ neoorm migrate reset --force [--skip-apply]
2674
+ ```
2675
+
2676
+ - `--force` — required (safety guard)
2677
+ - `--skip-apply` — only drop, don't re-apply
2678
+
2679
+ PostgreSQL: the connecting role owns the recreated schema. `PUBLIC` is not granted.
2680
+
2681
+ ## `neoorm seed`
2682
+
2683
+ Run a seed script against the database inside a single transaction. Any failure rolls the whole seed back.
2684
+
2685
+ ```
2686
+ neoorm seed [--env <name>] [--file <path>]
2687
+ ```
2688
+
2689
+ - `--env <name>` — run `seeds/<name>.ts` (next to `schema.ts`) instead of `seed.ts`
2690
+ - `--file <path>` — run a specific seed file (wins over `--env` and config)
2691
+
2692
+ Resolution order: `--file` → config `seed.file` → `seeds/<env>.ts` → `seed.ts`. The client is compiled from `schema.ts`, so seeding works on a fresh checkout with no codegen. Write idempotent seeds (`upsert`, `findOrCreate`, `createMany` with `skipDuplicates`) — re-runs are by convention, there is no seed ledger. See [Seeding](seeding.md).
2693
+
2694
+ ## `neoorm docs`
2695
+
2696
+ Serve the NeoOrm documentation locally in your browser.
2697
+
2698
+ ```
2699
+ neoorm docs [options]
2700
+ ```
2701
+
2702
+ Options:
2703
+ - `-p, --port <port>` — port to listen on (default: `7583`)
2704
+ - `-H, --host <host>` — host to bind (default: `127.0.0.1`)
2705
+ - `--open` — open the docs site in your default browser
2706
+
2707
+ The sidebar includes a search box with live results across all documentation pages.
2708
+
2709
+ ## `neoorm studio`
2710
+
2711
+ Browse and edit data in a local Studio UI (spreadsheet grid, SQL console, query playground, schema explorer, ER graph, migrate status).
2712
+
2713
+ ```
2714
+ neoorm studio [options]
2715
+ ```
2716
+
2717
+ Options:
2718
+ - `-p, --port <port>` — port to listen on (default: `7584`)
2719
+ - `-H, --host <host>` — host to bind (default: `127.0.0.1`)
2720
+ - `--open` — open Studio in your default browser
2721
+ - `--read-only` — block row mutations and non-read SQL
2722
+ - `--verbose` — log SQL statements executed by Studio
2723
+ - `--token <token>` — require this token for API access (generated automatically when binding a non-loopback host)
2724
+ - `--er-layout <path>` — enable team-shared ER layout stored at `<path>` (e.g. `./neoorm.er-layout.json`); `PUT` is blocked under `--read-only`
2725
+
2726
+ See [Studio](studio.md) for features, keyboard shortcuts, and security notes.
2727
+
2728
+ ## `neoorm db push`
2729
+
2730
+ Push the current `schema.ts` to the database without going through the migration ledger. You do not need to run `generate` first — push compiles the schema file you just saved. After a successful push, `snapshot.json` is updated so later `generate` does not emit the same DDL again.
2731
+
2732
+ ```
2733
+ neoorm db push
2734
+ ```
2735
+
2736
+ ## `neoorm db pull`
2737
+
2738
+ Introspect the database and write a schema file.
2739
+
2740
+ ```
2741
+ neoorm db pull
2742
+ ```
2743
+
2744
+ ## Studio
2745
+
2746
+ `neoorm studio` opens a local web UI for browsing and editing your data — a spreadsheet grid, a SQL console, a query playground, a schema explorer, an ER graph, and migrate status.
2747
+
2748
+ ```bash
2749
+ bunx neoorm studio --open
2750
+ ```
2751
+
2752
+ Studio reads `neoorm.config.ts` (after `.env`, like the other commands) and compiles `schema.ts` live, so the tables you see always match the schema file you just saved. If `schema.ts` does not compile, Studio falls back to `snapshot.json` and shows a banner. It connects with the datasource URL from your config — the connection string is never sent to the browser.
2753
+
2754
+ ## Data grid
2755
+
2756
+ - Spreadsheet-like browsing with pagination, multi-column sort (Shift+click), column reorder (drag headers), column show/hide, and wrap/truncate.
2757
+ - Filters map to real NeoOrm `where` operators: strings (`contains`, `startsWith`, `endsWith`, `search`, insensitive via advanced JSON), numbers/dates (`gt`, `gte`, `lt`, `lte`, `in`), nulls, enums, JSON (`jsonContains`, `hasKey`, `path`), plus an advanced JSON box for relation filters (`some`/`every`/`none`), `AND`/`OR`/`NOT`.
2758
+ - Table-wide search runs `contains` across text-like columns.
2759
+ - Double-click a cell to edit. Edits are staged — press `Save` (or `⌘/Ctrl+S`) to write them, `Esc`-via-banner to discard. Cell editors are type-aware: booleans, enum selects, timestamps, JSON, arrays, UUID generation, `bytea` upload, decimals as strings.
2760
+ - Hidden columns (`.hidden()`) are visible in Studio but masked until you reveal them — useful for passwords and secrets, which are fetched with `includeHidden`.
2761
+ - `serial`/identity columns are omitted on create; `updatedAt` is read-only.
2762
+ - Foreign-key cells link to the referenced row. Trailing `View →` columns navigate has-many and many-to-many relations. Clicking a row opens a detail sheet with a Relations tab for nested writes (`connect`, `connectOrCreate`, `create`, `disconnect`, `set`, `delete`).
2763
+ - Select rows to copy as CSV, Markdown, JSON, or SQL `INSERT`s. Import CSV or JSON arrays (up to 5000 rows) with optional `skipDuplicates`. Export the current view in JSON, CSV, Markdown, or SQL.
2764
+ - Tables without a usable unique key are browse/create-only, and Studio tells you why.
2765
+
2766
+ ## SQL console
2767
+
2768
+ Run parameterized SQL with dialect-aware highlighting and schema autocomplete (`⌘/Ctrl+Enter` to run). Params are a JSON array (`$1`/`$2` on Postgres/SQLite, `?` on MySQL). Optional `EXPLAIN` / `EXPLAIN ANALYZE`. Query history is kept locally.
2769
+
2770
+ ## Query playground
2771
+
2772
+ Build `findMany`, `findFirst`, `findUnique`, `findById`, `count`, `exists`, `aggregate`, `groupBy`, and `paginate` calls visually — `where`, `with`, `orderBy`, `select`, cursors — run them through the same client your app uses, and copy any working query as TypeScript (`db.users.findMany({ … })`).
2773
+
2774
+ ## Schema, ER, migrate
2775
+
2776
+ - Schema explorer: columns, kinds, constraints, indexes (including partial `WHERE`), foreign keys, and relations per table. Read-only — schema changes belong in `schema.ts` plus `migrate dev`.
2777
+ - ER graph: tables as nodes, `1:1` / `1:n` / `n:n` edges, junction tables hidden by default. Click a node to open its data. Drag tables to rearrange — positions, the junction toggle, and the viewport (pan/zoom) persist in `localStorage` per schema. `Reset layout` restores the auto-grid; `Export`/`Import JSON` moves a layout between browsers.
2778
+ - Team-shared ER layout (opt-in): restart Studio with `neoorm studio --er-layout ./neoorm.er-layout.json`, switch Scope to `Team`, then `Push to team` / `Pull from team`. The file is committable so the whole team sees the same arrangement. Without the flag the server answers `404` and local layout still works.
2779
+ - Migrate status: applied, pending, and orphan migrations. Studio never applies, resets, or rolls back migrations — use the CLI.
2780
+
2781
+ ## Keyboard
2782
+
2783
+ - `⌘/Ctrl+K` — command palette (jump to tables, views, actions)
2784
+ - `⌘/Ctrl+S` in the grid — save staged edits
2785
+ - `Esc` in the grid — discard staged edits
2786
+ - `⌘/Ctrl+R` in the grid — refresh rows
2787
+ - `Alt+N` in the grid — new row
2788
+ - `⌘/Ctrl+Enter` in SQL — run query
2789
+
2790
+ Views are deep-linkable: `#/tables/users?where=…&orderBy=…&q=…`, `#/sql`, `#/query`, `#/schema[/table]`, `#/er`, `#/migrate`.
2791
+
2792
+ ## Security
2793
+
2794
+ - Studio binds `127.0.0.1` by default with no auth, like `neoorm docs`. Binding a non-loopback host generates a token (printed in the terminal) that every API call must send.
2795
+ - `--read-only` blocks row mutations, imports, nested writes, and non-read SQL (`SELECT`, `EXPLAIN`, `PRAGMA`, and friends still work).
2796
+ - The browser never receives your datasource URL, and hidden columns stay masked until revealed.
2797
+
2798
+ ## Configuration
2799
+
2800
+ The `neoorm.config.ts` file configures schema location, output directory, and datasource.
2801
+
2802
+ ```ts
2803
+ // neoorm.config.ts
2804
+ import { defineConfig } from "neoorm";
2805
+
2806
+ export default defineConfig({
2807
+ schema: "./schema.ts",
2808
+ out: "./neoorm",
2809
+ datasource: {
2810
+ provider: "postgresql",
2811
+ url: process.env.DATABASE_URL!,
2812
+ schema: "public",
2813
+ enum: "check",
2814
+ },
2815
+ });
2816
+ ```
2817
+
2818
+ CLI commands load `.env` from the project directory before evaluating this file, so `process.env.DATABASE_URL` is set after `cp .env.example .env`. Variables already present in the environment are not overwritten. The generated client does not load `.env` at query time — use your runtime or a loader such as `node --env-file=.env`.
2819
+
2820
+ ## Options
2821
+
2822
+ | Key | Type | Default | Description |
2823
+ |-----|------|---------|-------------|
2824
+ | `schema` | `string` | required | Path to the schema file |
2825
+ | `out` | `string` | required | Output directory for generated files |
2826
+ | `datasource.provider` | `"postgresql" \| "postgres" \| "sqlite" \| "mysql" \| "mariadb"` | required | Database provider. `"postgres"` is an alias of `"postgresql"`. |
2827
+ | `datasource.url` | `string` | required | Connection string (PostgreSQL / MySQL / MariaDB) or database file path / `:memory:` (SQLite) |
2828
+ | `datasource.schema` | `string` | `"public"` | PostgreSQL schema for migrations and queries (SQLite, MySQL, and MariaDB: not applicable) |
2829
+ | `datasource.enum` | `"check" \| "union" \| "native"` | `"check"` | How to store enum columns |
2830
+ | `generate.zod` | `boolean` | `false` | Emit Select/Create/Update Zod schemas to `out/zod.ts`. See [Zod schemas](zod.md). |
2831
+ | `generate.typebox` | `boolean` | `false` | Emit Select/Create/Update TypeBox schemas to `out/typebox.ts`. See [TypeBox schemas](typebox.md). |
2832
+ | `generate.elysia` | `boolean` | `false` | Emit Select/Create/Update Elysia `t` schemas to `out/elysia.ts`. See [Elysia schemas](elysia.md). |
2833
+ | `seed.file` | `string` | unset | Default seed file, project-root relative. CLI `--file` overrides it. See [Seeding](seeding.md). |
2834
+ | `seed.env` | `string` | unset | Default environment: runs `seeds/<env>.ts` next to the schema. CLI `--env` overrides it. |
2835
+
2836
+ ### Validation emit
2837
+
2838
+ Set `generate.zod` to write `out/zod.ts`, `generate.typebox` to write `out/typebox.ts`, and/or `generate.elysia` to write `out/elysia.ts` on `neoorm generate` / `neoorm migrate dev`. Install `zod` (`^4`), `typebox` (`^1`), and/or `elysia` (`>=1.2`) in the app. If a printer is enabled and its peer is missing, generate still writes the file and warns. Codegen uses a library-agnostic IR; Zod, TypeBox 1.x, and Elysia `t` are the shipped printers. See [Zod schemas](zod.md), [TypeBox schemas](typebox.md), and [Elysia schemas](elysia.md).
2839
+
2840
+ ### SQLite
2841
+
2842
+ Set `provider: "sqlite"` and `url` to a file path or `":memory:"`:
2843
+
2844
+ ```ts
2845
+ datasource: {
2846
+ provider: "sqlite",
2847
+ url: "./dev.db",
2848
+ },
2849
+ ```
2850
+
2851
+ `datasource.schema` is PostgreSQL-only (ignored on SQLite, MySQL, and MariaDB). `datasource.enum: "native"` is PostgreSQL `CREATE TYPE` or MySQL/MariaDB column `ENUM`. See [SQLite](sqlite.md), [MySQL](mysql.md), and [MariaDB](mariadb.md).
2852
+
2853
+ ### Enum modes
2854
+
2855
+ | Mode | SQL | DB enforcement |
2856
+ |------|-----|----------------|
2857
+ | `check` (default) | `TEXT` + `CHECK (...)` | yes |
2858
+ | `union` | `TEXT` | no (TypeScript union only) |
2859
+ | `native` | Postgres `CREATE TYPE ... AS ENUM` or MySQL/MariaDB column `ENUM` | yes |
2860
+
2861
+ ## SQLite
2862
+
2863
+ SQLite is a first-class dialect for local development, testing, and single-file deployments. Everything in the schema DSL, codegen, migrations, and query client works against SQLite with no extra database server.
2864
+
2865
+ ## Requirements
2866
+
2867
+ NeoOrm requires **Node.js 22.5+** or **Bun**. No SQLite package is required — auto-open uses the driver bundled with your runtime:
2868
+
2869
+ | Runtime | Driver |
2870
+ |---------|--------|
2871
+ | Node.js 22.5+ | built-in `node:sqlite` (`DatabaseSync`) |
2872
+ | Bun | `bun:sqlite` |
2873
+ | Custom | pass your own `db` instance (see [Custom driver](#custom-driver)) |
2874
+
2875
+ If none is available, creating a client throws: `No SQLite driver available. Provide a db instance, or run on Bun or Node.js 22.5+`.
2876
+
2877
+ ## Configuration
2878
+
2879
+ Set `provider: "sqlite"` and point `url` at a file path or `:memory:`:
2880
+
2881
+ ```ts
2882
+ // neoorm.config.ts
2883
+ import { defineConfig } from "neoorm";
2884
+
2885
+ export default defineConfig({
2886
+ schema: "./schema.ts",
2887
+ out: "./neoorm",
2888
+ datasource: {
2889
+ provider: "sqlite",
2890
+ url: "./dev.db", // or ":memory:"
2891
+ },
2892
+ });
2893
+ ```
2894
+
2895
+ `datasource.schema` and `datasource.enum: "native"` are PostgreSQL-only and ignored/unsupported on SQLite. Enums always use the `check` (or `union`) TEXT storage mode.
2896
+
2897
+ ## Runtime client
2898
+
2899
+ ```ts
2900
+ import { createNeoOrmClient } from "neoorm";
2901
+ import { manifest } from "./neoorm/manifest.js";
2902
+
2903
+ const db = createNeoOrmClient(manifest, {
2904
+ provider: "sqlite",
2905
+ databasePath: "./dev.db",
2906
+ });
2907
+ ```
2908
+
2909
+ Use `databasePath: ":memory:"` for an in-memory database (one connection, no persistence).
2910
+
2911
+ Wrapping a connection sets `PRAGMA foreign_keys = ON`, `busy_timeout = 5000`, and `journal_mode = WAL` (WAL is a no-op for `:memory:`). Opt out per client:
2912
+
2913
+ ```ts
2914
+ const db = createNeoOrmClient(manifest, {
2915
+ provider: "sqlite",
2916
+ databasePath: "./dev.db",
2917
+ sqlite: { wal: false, busyTimeout: false },
2918
+ });
2919
+ ```
2920
+
2921
+ `busyTimeout` can also be a millisecond wait (`sqlite: { busyTimeout: 10_000 }`).
2922
+
2923
+ ### Custom driver
2924
+
2925
+ Pass any object implementing `prepare(sql)`, `exec(sql)`, and `close()` — the `node:sqlite` and `bun:sqlite` APIs both match. Useful for sandboxed runtimes or existing database handles:
2926
+
2927
+ ```ts
2928
+ import { DatabaseSync } from "node:sqlite";
2929
+ import { createNeoOrmClientFromSqlite } from "neoorm";
2930
+ import { manifest } from "./neoorm/manifest.js";
2931
+
2932
+ const database = new DatabaseSync("./dev.db");
2933
+ const db = createNeoOrmClientFromSqlite(manifest, database);
2934
+ ```
2935
+
2936
+ `$disconnect()` does not close the handle — call `database.close()` yourself. `createNeoOrmClient(manifest, { db: database })` is the same ownership model.
2937
+
2938
+ `sqliteDialect` is exported from `neoorm` (alongside `postgresDialect`) for `dbPush`, migrate helpers, and custom executor wiring:
2939
+
2940
+ ```ts
2941
+ import { dbPush, sqliteDialect } from "neoorm";
2942
+
2943
+ await dbPush(client, sqliteDialect, manifest);
2944
+ ```
2945
+
2946
+ ## CLI
2947
+
2948
+ The full CLI works against SQLite:
2949
+
2950
+ ```bash
2951
+ bunx neoorm migrate deploy
2952
+ bunx neoorm db push
2953
+ bunx neoorm db pull
2954
+ bunx neoorm migrate status
2955
+ bunx neoorm migrate reset --force
2956
+ ```
2957
+
2958
+ - The migration ledger table is `_neoorm_migrations` (`id INTEGER PRIMARY KEY AUTOINCREMENT`, `name`, `checksum`, `applied_at`). `migrate deploy` wraps the run in `BEGIN IMMEDIATE` and refuses an edited `migration.sql` whose checksum no longer matches the ledger.
2959
+ - `migrate reset` drops all non-`sqlite_` tables (there is no schema concept).
2960
+ - `db pull` introspects `sqlite_master` back into a schema file.
2961
+
2962
+ ## Type mapping
2963
+
2964
+ | Schema builder | SQLite storage |
2965
+ |----------------|----------------|
2966
+ | `id`, `text`, `uuid`, `json`, `jsonb`, `decimal`, `textArray`, `intArray`, `uuidArray`, `enumArray`, `citext`, `enumType`, `date`, `time`, `interval`, `inet`, `cidr`, `xml`, `money` | `TEXT` |
2967
+ | `int`, `serial` | `INTEGER` |
2968
+ | `real`, `double` | `REAL` |
2969
+ | `bigint` | `TEXT` |
2970
+ | `int4Range` … `dateRange` | rejected at schema compile |
2971
+ | `serial().primary()` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
2972
+ | `bool` | `BOOLEAN` (stored as 0/1) |
2973
+ | `timestamp` | `TEXT` (ISO-8601) |
2974
+ | `bytea` | `BLOB` |
2975
+ | `fk` | the target column's type (default `TEXT`) |
2976
+
2977
+ `defaultNow()` compiles to `CURRENT_TIMESTAMP`. Structural table changes that SQLite cannot do in place (column type changes, FK changes, nullability) are applied via a table-rebuild strategy: `PRAGMA foreign_keys = OFF`, then in one transaction create `__neoorm_<table>_new`, copy rows, drop the old table, rename, and `PRAGMA foreign_keys = ON`. Foreign keys must be disabled *before* `BEGIN` — SQLite ignores that pragma inside a transaction. The client always enables `foreign_keys` at connect time, so inbound FKs (e.g. `posts.user_id` → `users`) would otherwise block `DROP TABLE` of the parent.
2978
+
2979
+ ## Differences from PostgreSQL
2980
+
2981
+ | Feature | PostgreSQL | SQLite |
2982
+ |---------|-----------|--------|
2983
+ | `distinct` (`DISTINCT ON`) | supported | `ROW_NUMBER()` per distinct columns (same `orderBy` prefix rule) |
2984
+ | `search` | POSIX `~` / `~*` | JavaScript `RegExp` via `REGEXP` / `regexp_i` |
2985
+ | `datasource.schema` | multi-schema | not applicable |
2986
+ | `enum: "native"` | `CREATE TYPE ... AS ENUM` | not applicable (TEXT + CHECK) |
2987
+ | range types | supported | rejected at schema compile |
2988
+ | transaction options (`readOnly`, `isolationLevel`) | `BEGIN READ ONLY` / `ISOLATION LEVEL` | `readOnly` → `PRAGMA query_only`; `RepeatableRead`/`Serializable` → `BEGIN IMMEDIATE`; other isolation → `BEGIN` |
2989
+ | JSON operators | `@>`, `?`, `#>` | `json_patch` / `json_each` / `json_extract` |
2990
+
2991
+ Everything else — relations, nested writes, cursor pagination, aggregates, `groupBy`, upsert, `findOrCreate`, savepoint-based nested transactions — behaves identically.
2992
+
2993
+ ## MySQL
2994
+
2995
+ MySQL 8.0+ is a first-class dialect via the optional `mysql2` driver. Schema DSL, codegen, migrations, and the query client work against MySQL the same way they do against PostgreSQL, with the storage and SQL differences below.
2996
+
2997
+ PlanetScale, MySQL 5.7, and GIS/PostGIS are out of scope.
2998
+
2999
+ ## Requirements
3000
+
3001
+ Install the driver next to NeoOrm:
3002
+
3003
+ ```bash
3004
+ bun add neoorm mysql2
3005
+ ```
3006
+
3007
+ If `mysql2` is missing, creating a client throws: `mysql2 is not installed. Run: bun add mysql2`.
3008
+
3009
+ ## Configuration
3010
+
3011
+ Set `provider: "mysql"` and a MySQL 8 connection URL:
3012
+
3013
+ ```ts
3014
+ // neoorm.config.ts
3015
+ import { defineConfig } from "neoorm";
3016
+
3017
+ export default defineConfig({
3018
+ schema: "./schema.ts",
3019
+ out: "./neoorm",
3020
+ datasource: {
3021
+ provider: "mysql",
3022
+ url: process.env.DATABASE_URL ?? "mysql://root@localhost:3306/myapp",
3023
+ },
3024
+ });
3025
+ ```
3026
+
3027
+ `datasource.schema` is ignored (the database comes from the URL). `datasource.enum: "native"` is allowed and emits column `ENUM('a','b')` — there is no `CREATE TYPE`.
3028
+
3029
+ ## Runtime client
3030
+
3031
+ ```ts
3032
+ import { createNeoOrmClient } from "neoorm";
3033
+ import { manifest } from "./neoorm/manifest.js";
3034
+
3035
+ const db = createNeoOrmClient(manifest, {
3036
+ provider: "mysql",
3037
+ connectionString: process.env.MYSQL_URL,
3038
+ });
3039
+ ```
3040
+
3041
+ Wrap an existing `mysql2/promise` pool with `createNeoOrmClientFromMysql(manifest, pool)`. `$disconnect()` does not call `pool.end()` in that case — you own the pool.
3042
+
3043
+ Data queries use mysql2 `execute()` (binary prepared statements, cached per connection). Transaction control (`START TRANSACTION`, `COMMIT`, `SAVEPOINT`) stays on `query()`. Pools you wrap without `execute` fall back to `query()`.
3044
+
3045
+ Owned pools from `createNeoOrmClient` accept the same `pool` object as PostgreSQL for shared fields (`max`, idle timeout, keep-alive). Default `max` is 10 (`connectionLimit`). PostgreSQL-only keys such as `statement_timeout` are ignored.
3046
+
3047
+ ```ts
3048
+ const db = createNeoOrmClient(manifest, {
3049
+ provider: "mysql",
3050
+ connectionString: process.env.MYSQL_URL,
3051
+ pool: {
3052
+ max: 10,
3053
+ idleTimeoutMillis: 10_000,
3054
+ keepAlive: true,
3055
+ },
3056
+ });
3057
+ ```
3058
+
3059
+ Standalone `neoorm/sql` (`sqlId`) stays ANSI-quoted (`"users"`). Use `db.sql` with `db.sqlId("users")` so identifiers are backticks on MySQL.
3060
+
3061
+ `mysqlDialect` is exported from `neoorm` for `dbPush`, migrate helpers, and custom executor wiring.
3062
+
3063
+ ## CLI
3064
+
3065
+ ```bash
3066
+ bunx neoorm init --provider mysql
3067
+ bunx neoorm migrate deploy
3068
+ bunx neoorm db push
3069
+ bunx neoorm db pull
3070
+ bunx neoorm migrate status
3071
+ bunx neoorm migrate reset --force
3072
+ ```
3073
+
3074
+ - Deploy lock is `GET_LOCK('neoorm.migrate.<db>', timeout)` / `RELEASE_LOCK` on the deploy transaction.
3075
+ - `migrate reset` sets `FOREIGN_KEY_CHECKS=0`, drops all tables in the current database, then re-enables checks and re-applies migrations.
3076
+ - `db pull` introspects `information_schema` back into a schema file.
3077
+
3078
+ ## Type mapping
3079
+
3080
+ | Schema builder | MySQL storage |
3081
+ |----------------|---------------|
3082
+ | `id()`, unique/PK `text` | `VARCHAR(191)` (InnoDB index prefix; `TEXT` cannot be a unique key) |
3083
+ | `text()` otherwise | `TEXT`, or `VARCHAR(n)` with `.maxLength()` |
3084
+ | `uuid()` | `CHAR(36)` |
3085
+ | `bool()` | `TINYINT(1)` |
3086
+ | `int()` | `INT` |
3087
+ | `bigint()` | `BIGINT` |
3088
+ | `real()` / `float()` | `FLOAT` |
3089
+ | `double()` | `DOUBLE` |
3090
+ | `serial()` | `INT NOT NULL AUTO_INCREMENT` |
3091
+ | `timestamp()` | `DATETIME(6)` + `CURRENT_TIMESTAMP(6)` |
3092
+ | `date()` | `DATE` |
3093
+ | `time()` | `TIME` |
3094
+ | `json()` / `jsonb()` | `JSON` |
3095
+ | `decimal()` | `DECIMAL(p,s)` |
3096
+ | `money()` | `DECIMAL(19,4)` |
3097
+ | `xml()` | `LONGTEXT` |
3098
+ | `bytea()` | `BLOB` |
3099
+ | arrays / `citext()` | `JSON` / `VARCHAR` + `utf8mb4_0900_ai_ci` |
3100
+ | `interval()`, `inet()`, `cidr()`, ranges | rejected at schema compile |
3101
+ | `enum: "check"` | `VARCHAR` + `CHECK` (8.0.16+) |
3102
+ | `enum: "native"` | column `ENUM('a','b')` |
3103
+
3104
+ ## Differences from PostgreSQL
3105
+
3106
+ | Feature | PostgreSQL | MySQL |
3107
+ |---------|------------|--------|
3108
+ | Identifier quoting | `"users"` | `` `users` `` |
3109
+ | `RETURNING` | native | follow-up `SELECT` (or `insertId` for serial) |
3110
+ | `upsert` | `ON CONFLICT … DO UPDATE` | `INSERT … AS new ON DUPLICATE KEY UPDATE` |
3111
+ | `skipDuplicates` | `ON CONFLICT DO NOTHING` | `INSERT IGNORE` |
3112
+ | `findOrCreate` | `xmax = 0` | SELECT → INSERT → retry on unique violation |
3113
+ | `in` / `notIn` | array bind | `IN (?, …)` up to 256 values; `JSON_TABLE` above that |
3114
+ | `search` | POSIX `~` | `REGEXP_LIKE` |
3115
+ | `ilike` | `ILIKE` | `LOWER(col) LIKE LOWER(?)` |
3116
+ | JSON operators | `@>`, `?`, `#>` | `JSON_CONTAINS` / `JSON_EXTRACT` / `JSON_CONTAINS_PATH` |
3117
+ | Nested includes | `json_agg … FILTER` | `JSON_ARRAYAGG(CASE WHEN …)` |
3118
+ | `distinct` (`DISTINCT ON`) | supported | throws |
3119
+ | Partial indexes `index({ where })` | supported | rejected at schema compile |
3120
+ | PostGIS | supported | rejected |
3121
+ | `interval` / `inet` / `cidr` / range types | supported | rejected at schema compile |
3122
+ | `datasource.schema` | multi-schema | ignored (URL database) |
3123
+
3124
+ `createMany` is a multi-value `INSERT` plus `affectedRows` and does not use `RETURNING`. `createManyAndReturn` for serial primary keys uses `LAST_INSERT_ID()` plus row count inside a transaction and assumes consecutive autoincrement values.
3125
+
3126
+ Offset pages (`findMany({ take, skip })`) are `LIMIT`/`OFFSET`; `paginate` stays cursor-based.
3127
+
3128
+ Everything else — relations, nested writes, cursor pagination, aggregates, `groupBy`, savepoint-based nested transactions — behaves the same as on PostgreSQL.
3129
+
3130
+ ## MariaDB
3131
+
3132
+ MariaDB 10.11 LTS is a first-class dialect via the optional official `mariadb` connector. Schema DSL, codegen, migrations, and the query client work the same way as PostgreSQL, with the storage and SQL differences below.
3133
+
3134
+ MariaDB versions below 10.11, GIS, Galera, and MaxScale specifics are out of scope. MySQL 8 uses a **separate** dialect and the `mysql2` driver — see [MySQL](mysql.md).
3135
+
3136
+ ## Requirements
3137
+
3138
+ Install the driver next to NeoOrm:
3139
+
3140
+ ```bash
3141
+ bun add neoorm mariadb
3142
+ ```
3143
+
3144
+ If `mariadb` is missing, creating a client throws: `mariadb is not installed. Run: bun add mariadb`.
3145
+
3146
+ ## Configuration
3147
+
3148
+ Set `provider: "mariadb"` and a MariaDB 10.11 connection URL:
3149
+
3150
+ ```ts
3151
+ // neoorm.config.ts
3152
+ import { defineConfig } from "neoorm";
3153
+
3154
+ export default defineConfig({
3155
+ schema: "./schema.ts",
3156
+ out: "./neoorm",
3157
+ datasource: {
3158
+ provider: "mariadb",
3159
+ url: process.env.DATABASE_URL ?? "mariadb://root@localhost:3306/myapp",
3160
+ },
3161
+ });
3162
+ ```
3163
+
3164
+ `datasource.schema` is ignored (the database comes from the URL). `datasource.enum: "native"` is allowed and emits column `ENUM('a','b')` — there is no `CREATE TYPE`.
3165
+
3166
+ ## Runtime client
3167
+
3168
+ ```ts
3169
+ import { createNeoOrmClient } from "neoorm";
3170
+ import { manifest } from "./neoorm/manifest.js";
3171
+
3172
+ const db = createNeoOrmClient(manifest, {
3173
+ provider: "mariadb",
3174
+ connectionString: process.env.MARIADB_URL,
3175
+ });
3176
+ ```
3177
+
3178
+ Wrap an existing `mariadb` pool with `createNeoOrmClientFromMariadb(manifest, pool)`. `$disconnect()` does not call `pool.end()` in that case — you own the pool.
3179
+
3180
+ Reads (and bulk `VALUES` / `RETURNING` writes) use text `query()` so owned-pool `pipelining: true` is not combined with prepare-then-execute. Singleton `INSERT`/`UPDATE`/`DELETE`/`REPLACE` without `RETURNING` use connector `execute()` (prepared statements, `prepareCacheLength` 256). MariaDB’s binary prepare protocol also rejects `UPDATE`/`DELETE … RETURNING`, so those stay on `query()`. Transaction control stays on `query()`. Pools you wrap without `execute` fall back to `query()`.
3181
+
3182
+ Owned pools from `createNeoOrmClient` accept the same `pool` object as PostgreSQL for shared fields (`max`, idle timeout, keep-alive). Default `max` is 10. Pipelining is enabled (commands are still awaited in order). PostgreSQL-only keys such as `statement_timeout` are ignored.
3183
+
3184
+ ```ts
3185
+ const db = createNeoOrmClient(manifest, {
3186
+ provider: "mariadb",
3187
+ connectionString: process.env.MARIADB_URL,
3188
+ pool: {
3189
+ max: 10,
3190
+ idleTimeoutMillis: 10_000,
3191
+ keepAlive: true,
3192
+ },
3193
+ });
3194
+ ```
3195
+
3196
+ Standalone `neoorm/sql` (`sqlId`) stays ANSI-quoted (`"users"`). Use `db.sql` with `db.sqlId("users")` so identifiers are backticks on MariaDB.
3197
+
3198
+ `mariadbDialect` is exported from `neoorm` for `dbPush`, migrate helpers, and custom executor wiring.
3199
+
3200
+ ## CLI
3201
+
3202
+ ```bash
3203
+ bunx neoorm init --provider mariadb
3204
+ bunx neoorm migrate deploy
3205
+ bunx neoorm db push
3206
+ bunx neoorm db pull
3207
+ bunx neoorm migrate status
3208
+ bunx neoorm migrate reset --force
3209
+ ```
3210
+
3211
+ - Deploy lock is `GET_LOCK('neoorm.migrate.<db>', timeout)` / `RELEASE_LOCK` on the deploy transaction.
3212
+ - `migrate reset` sets `FOREIGN_KEY_CHECKS=0`, drops all tables in the current database, then re-enables checks and re-applies migrations.
3213
+ - `db pull` introspects `information_schema`. MariaDB `JSON` columns are often `LONGTEXT` plus a `json_valid()` CHECK; NeoOrm maps those back to `jsonb()`.
3214
+
3215
+ ## Type mapping
3216
+
3217
+ | Schema builder | MariaDB storage |
3218
+ |----------------|-----------------|
3219
+ | `id()`, unique/PK `text` | `VARCHAR(191)` (InnoDB index prefix; `TEXT` cannot be a unique key) |
3220
+ | `text()` otherwise | `TEXT`, or `VARCHAR(n)` with `.maxLength()` |
3221
+ | `uuid()` | `CHAR(36)` |
3222
+ | `bool()` | `TINYINT(1)` |
3223
+ | `int()` | `INT` |
3224
+ | `bigint()` | `BIGINT` |
3225
+ | `real()` / `float()` | `FLOAT` |
3226
+ | `double()` | `DOUBLE` |
3227
+ | `serial()` | `INT NOT NULL AUTO_INCREMENT` |
3228
+ | `timestamp()` | `DATETIME(6)` + `CURRENT_TIMESTAMP(6)` |
3229
+ | `date()` | `DATE` |
3230
+ | `time()` | `TIME` |
3231
+ | `json()` / `jsonb()` | `JSON` (LONGTEXT + `json_valid()` CHECK under the hood) |
3232
+ | `decimal()` | `DECIMAL(p,s)` |
3233
+ | `money()` | `DECIMAL(19,4)` |
3234
+ | `xml()` | `LONGTEXT` |
3235
+ | `bytea()` | `BLOB` |
3236
+ | arrays / `citext()` | `JSON` / `VARCHAR` + `utf8mb4_uca1400_ai_ci` |
3237
+ | `interval()`, `inet()`, `cidr()`, ranges | rejected at schema compile |
3238
+ | `enum: "check"` | `VARCHAR` + `CHECK` |
3239
+ | `enum: "native"` | column `ENUM('a','b')` |
3240
+
3241
+ ## Differences from MySQL 8
3242
+
3243
+ | Feature | MySQL 8 | MariaDB 10.11 |
3244
+ |---------|---------|----------------|
3245
+ | Driver | `mysql2` | official `mariadb` package |
3246
+ | `upsert` | `INSERT … AS new ON DUPLICATE KEY UPDATE` | `ON DUPLICATE KEY UPDATE col = VALUES(col)` |
3247
+ | `search` | `REGEXP_LIKE` | `col REGEXP $n` (`(?i)` for insensitive) |
3248
+ | `citext` collation | `utf8mb4_0900_ai_ci` | `utf8mb4_uca1400_ai_ci` |
3249
+ | CHECK drop | `DROP CHECK` | `DROP CONSTRAINT` |
3250
+ | CHECK errno | 3819 | 4025 (`ER_CONSTRAINT_FAILED`) |
3251
+ | JSON storage | native JSON | JSON as LONGTEXT + `json_valid()` |
3252
+ | `RETURNING` | none | `INSERT`/`DELETE` `RETURNING`; `UPDATE` uses follow-up `SELECT` (UPDATE `RETURNING` is MariaDB 13+) |
3253
+
3254
+ ## Differences from PostgreSQL
3255
+
3256
+ | Feature | PostgreSQL | MariaDB |
3257
+ |---------|------------|---------|
3258
+ | Identifier quoting | `"users"` | `` `users` `` |
3259
+ | `RETURNING` | native | `INSERT`/`DELETE` `RETURNING`; `UPDATE` uses follow-up `SELECT` |
3260
+ | `upsert` | `ON CONFLICT … DO UPDATE` | `ON DUPLICATE KEY UPDATE` + `VALUES(col)` |
3261
+ | `skipDuplicates` | `ON CONFLICT DO NOTHING` | `INSERT IGNORE` |
3262
+ | `findOrCreate` | `xmax = 0` | SELECT → INSERT → retry on unique violation |
3263
+ | `in` / `notIn` | array bind | `IN (?, …)` up to 256 values; `JSON_TABLE` above that |
3264
+ | `search` | POSIX `~` | `REGEXP` |
3265
+ | `ilike` | `ILIKE` | `LOWER(col) LIKE LOWER(?)` |
3266
+ | JSON operators | `@>`, `?`, `#>` | `JSON_CONTAINS` / `JSON_EXTRACT` / `JSON_CONTAINS_PATH` |
3267
+ | Nested includes | `json_agg … FILTER` | JOIN `JSON_ARRAYAGG` when possible; otherwise batched `IN` queries (no `LATERAL`) |
3268
+ | `distinct` (`DISTINCT ON`) | supported | throws |
3269
+ | Partial indexes `index({ where })` | supported | rejected at schema compile |
3270
+ | PostGIS | supported | rejected |
3271
+ | `interval` / `inet` / `cidr` / range types | supported | rejected at schema compile |
3272
+ | `datasource.schema` | multi-schema | ignored (URL database) |
3273
+
3274
+ MariaDB has no `LATERAL`, so correlated `JSON_ARRAYAGG` derived tables (the MySQL 8 / Postgres inline has-many subquery) cannot see outer columns such as `` `users`.`id` ``.
3275
+
3276
+ Simple has-many includes (`with: { posts: true }`) on `findMany`, `findById`, and `findFirst` use `LEFT JOIN` + `JSON_ARRAYAGG` + `GROUP BY` in one statement. Nested `where`, `take`, `skip`, `orderBy`, or further `with` on that relation still make a single SQL invalid, so those includes are loaded with a follow-up `WHERE fk IN (…)` query.
3277
+
3278
+ `createMany` is a multi-value `INSERT` plus `affectedRows` and does not use `RETURNING`. `createManyAndReturn` for serial primary keys uses `LAST_INSERT_ID()` plus row count inside a transaction and assumes consecutive autoincrement values.
3279
+
3280
+ Offset pages (`findMany({ take, skip })`) are `LIMIT`/`OFFSET`; `paginate` stays cursor-based.
3281
+
3282
+ Everything else — relations, nested writes, cursor pagination, aggregates, `groupBy`, savepoint-based nested transactions — behaves the same as on PostgreSQL.
3283
+
3284
+ ## Plugins
3285
+
3286
+ ## Built-in `id()` and `timestamps()`
3287
+
3288
+ ```ts
3289
+ import { defineSchema, id, table, timestamps, uuid } from "neoorm/schema";
3290
+
3291
+ export const schema = defineSchema({
3292
+ users: table({
3293
+ id: uuid().primary(),
3294
+ ...timestamps(),
3295
+ }),
3296
+ posts: table({
3297
+ id: id(), // TEXT PK with {prefix}_{uuid}
3298
+ title: text().notNull(),
3299
+ }),
3300
+ });
3301
+ ```
3302
+
3303
+ ## PostGIS
3304
+
3305
+ ```ts
3306
+ import "neoorm/plugins/postgis";
3307
+ import { geometry, point } from "neoorm/plugins/postgis";
3308
+
3309
+ places: table({
3310
+ id: uuid().primary(),
3311
+ location: geometry({ subtype: "Point", srid: 4326 }).notNull(),
3312
+ boundary: point({ srid: 4326 }),
3313
+ }),
3314
+ ```
3315
+
3316
+ `srid` requires a whitelisted `subtype`. PostGIS typmod is `geometry(Point,4326)`, not `geometry(4326)`. Use `subtype: "Geometry"` to fix SRID without constraining the type. Allowed subtypes include `Point`, `LineString`, `Polygon`, `MultiPolygon`, `GeometryCollection`, and `Geometry`, optionally with `Z`, `M`, or `ZM` (e.g. `PointZ`).
3317
+
3318
+ Spatial `where` operators: `intersects`, `within`, `dWithin`. Importing `neoorm/plugins/postgis` merges those keys into `WhereInput` for spatial columns.
3319
+
3320
+ ## Citext
3321
+
3322
+ ```ts
3323
+ import { citext, table } from "neoorm/schema";
3324
+
3325
+ users: table({
3326
+ email: citext().notNull().unique(),
3327
+ }),
3328
+ ```
3329
+
3330
+ ## Custom plugins
3331
+
3332
+ See the plugin registry source for `ColumnTypePlugin` and `NeoOrmPlugin` interfaces.
3333
+
3334
+ Column types may implement `columnValidation(col)` returning a validator-neutral IR node (`{ kind: "string" }`, `{ kind: "enum", values }`, GeoJSON `object` / `union`, and so on). Zod, TypeBox, and Elysia codegen print that IR. Omit the hook to fall back to `unknown`.
3335
+
3336
+ To type `where` operators, augment `PluginColumnWhereOperators` on `neoorm/schema` with your column kind:
3337
+
3338
+ ```ts
3339
+ declare module "neoorm/schema" {
3340
+ interface PluginColumnWhereOperators {
3341
+ myKind: { overlaps?: string };
3342
+ }
3343
+ }
3344
+ ```
3345
+
3346
+ ## API docstring conventions
3347
+
3348
+ Contributor guide for TSDoc on NeoOrm public exports. Docstrings appear in IDE hovers and emitted `.d.ts` files.
3349
+
3350
+ ## Scope
3351
+
3352
+ Document symbols exported from:
3353
+
3354
+ - `neoorm`
3355
+ - `neoorm/schema`
3356
+ - `neoorm/sql`
3357
+ - `neoorm/plugins`
3358
+ - `neoorm/plugins/postgis`
3359
+
3360
+ Skip internal helpers, `infer*` types, and anything not re-exported from an entry barrel.
3361
+
3362
+ ## Format
3363
+
3364
+ - Use **TSDoc** (`/** ... */`) on exported functions, types, interfaces, and enum-like unions.
3365
+ - Document **fluent builder methods** (`.notNull()`, `.onDelete()`, `.defaultNow()`, etc.) on the exported `ColumnBuilder`, `TimestampColumnBuilder`, and `FkBuilder` interfaces so IDE hovers work on chained calls.
3366
+ - **First line**: imperative summary (`Define a schema from table accessors.`).
3367
+ - **`@param` / `@returns`**: non-trivial functions.
3368
+ - **`@default`**: option fields on config/types.
3369
+ - **`@example`**: only for non-obvious DSL (FK inference, `many()`, relation writes). Keep to 3–8 lines; mirror [schema.md](schema.md).
3370
+ - **`@deprecated`**: include `@see` pointing at the successor.
3371
+ - **`@see`**: link to user docs when more detail is needed.
3372
+
3373
+ ## Module banners
3374
+
3375
+ Each entry barrel starts with:
3376
+
3377
+ ```ts
3378
+ /**
3379
+ * @packageDocumentation
3380
+ * Brief description of this subpath import.
3381
+ */
3382
+ ```
3383
+
3384
+ ## Do not
3385
+
3386
+ - Duplicate full [docs/](.) prose into comments.
3387
+ - Document generated client output under `neoorm/client`.
3388
+ - Add runtime behavior changes alongside docstrings.
3389
+
3390
+ ## Seeding
3391
+
3392
+ `neoorm seed` runs a seed script against the database inside a single transaction. Any failure rolls the whole seed back, so failed runs are safe to retry.
3393
+
3394
+ ```
3395
+ neoorm seed [--env <name>] [--file <path>]
3396
+ ```
3397
+
3398
+ ## Seed files
3399
+
3400
+ By convention the seed lives next to the schema as `seed.ts` and exports a `seed(db)` function. `db` is a transaction-scoped client, so every write in the file is atomic:
3401
+
3402
+ ```ts
3403
+ // seed.ts
3404
+ import type { TransactionClient } from "neoorm";
3405
+ import { schema } from "./schema.js";
3406
+
3407
+ export async function seed(db: TransactionClient<typeof schema._tables>) {
3408
+ await db.users.upsert({
3409
+ where: { email: "admin@example.com" },
3410
+ create: { email: "admin@example.com", name: "Admin" },
3411
+ update: {},
3412
+ });
3413
+ await db.tags.createMany({
3414
+ data: [
3415
+ { slug: "news", name: "News" },
3416
+ { slug: "guides", name: "Guides" },
3417
+ ],
3418
+ skipDuplicates: true,
3419
+ });
3420
+ }
3421
+ ```
3422
+
3423
+ Annotating with `TransactionClient<typeof schema._tables>` gives fully typed repositories — the same types as the generated client. A default-exported function works too, and `SeedContext` from `neoorm` is available for manifest-agnostic helpers.
3424
+
3425
+ ## Environments
3426
+
3427
+ `--env <name>` runs `seeds/<name>.ts` instead of `seed.ts`:
3428
+
3429
+ ```bash
3430
+ neoorm seed --env dev
3431
+ neoorm seed --file ./seeds/manual.ts
3432
+ ```
3433
+
3434
+ `--file` wins over `--env`, and both win over config (see below).
3435
+
3436
+ ## Idempotency
3437
+
3438
+ Seeds are re-run by convention — there is no seed ledger. Write seeds so running them twice is safe: `upsert`, `findOrCreate`, and `createMany({ skipDuplicates: true })` are the building blocks. The transaction guarantees all-or-nothing per run, not across runs.
3439
+
3440
+ ## Configuration
3441
+
3442
+ Pin defaults in `neoorm.config.ts` (CLI flags override them):
3443
+
3444
+ ```ts
3445
+ export default defineConfig({
3446
+ // ...
3447
+ seed: { env: "dev", file: "./seeds/base.ts" },
3448
+ });
3449
+ ```
3450
+
3451
+ | Key | Type | Description |
3452
+ |-----|------|-------------|
3453
+ | `seed.file` | `string` | Default seed file, project-root relative |
3454
+ | `seed.env` | `string` | Default environment, runs `seeds/<env>.ts` next to the schema |
3455
+
3456
+ See [Configuration](configuration.md) for the full config reference.
3457
+