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/AGENTS.md +46 -0
- package/dist/bin/neoorm.d.ts.map +1 -1
- package/dist/bin/neoorm.js +218 -6
- package/dist/bin/neoorm.js.map +1 -1
- package/dist/codegen/generate.d.ts +32 -1
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/codegen/generate.js +129 -28
- package/dist/codegen/generate.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +28 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/error-codes.d.ts +1 -0
- package/dist/runtime/error-codes.d.ts.map +1 -1
- package/dist/runtime/error-codes.js +1 -0
- package/dist/runtime/error-codes.js.map +1 -1
- package/dist/seed/runner.d.ts +46 -0
- package/dist/seed/runner.d.ts.map +1 -0
- package/dist/seed/runner.js +99 -0
- package/dist/seed/runner.js.map +1 -0
- package/docs/cli.md +32 -1
- package/docs/configuration.md +2 -0
- package/docs/migrations.md +14 -4
- package/docs/seeding.md +67 -0
- package/llms-full.txt +3457 -0
- package/llms.txt +40 -0
- package/package.json +6 -2
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
|
+
|