@nano-app/backend 0.15.0 → 0.16.0

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Preconfigured Express server with **file-convention routes** and **plugins**: it mounts the base middlewares, discovers your routes, formats every error as JSON, and groups extra endpoints in plugins.
6
6
 
7
7
  - Node.js 22.23.4, 24.22.1 or 26.12.1 and newer. ESM only.
8
- - Optional PostgreSQL layer through your generated Prisma client. Disabled by default: without it no database code is loaded.
8
+ - Optional PostgreSQL layer through Drizzle. Off by default: without it no database code is loaded.
9
9
 
10
10
  ```bash
11
11
  pnpm add @nano-app/backend
@@ -58,7 +58,7 @@ export default createRoute({
58
58
  });
59
59
  ```
60
60
 
61
- Each handler receives `(req, params)` and returns an `ApiResponse` without `success`: its `status` is the HTTP status (`200` by default) and the framework sets `success` to `status < 400`. `params` holds the plugin's `options`, `db` (the Prisma client when a database is enabled, see [Database](#database); a plugin without its own gets the app's) and `onBeforeResult` (see [Callbacks](#callbacks)):
61
+ Each handler receives `(req, params)` and returns an `ApiResponse` without `success`: its `status` is the HTTP status (`200` by default) and the framework sets `success` to `status < 400`. `params` holds the plugin's `options`, `db` (the Drizzle client when the app or the plugin has a database, see [Database](#database); a plugin without its own gets the app's) and `onBeforeResult` (see [Callbacks](#callbacks)):
62
62
 
63
63
  ```ts
64
64
  type ApiResponse<T> = {
@@ -114,61 +114,50 @@ By default the backend reads that header only when the request comes from loopba
114
114
 
115
115
  ## Database
116
116
 
117
- Off until enabled: at startup nano-app generates the Prisma client of your schema, checks that its tables can be read and passes it to your handlers as their second parameter. **It never modifies your schemas**: applying them (`prisma db push`, `prisma migrate dev`, `prisma migrate deploy`) is up to you, with Prisma.
117
+ Off until the app passes its schema: nano-app creates a [Drizzle](https://orm.drizzle.team) client of it on `DATABASE_URL` and passes it to your handlers as `db`. **It never modifies your schemas**: applying them (`drizzle-kit push`, `drizzle-kit generate` and `migrate`) is up to you.
118
118
 
119
119
  ```bash
120
- pnpm add prisma @prisma/client @prisma/adapter-pg pg # prisma too: the startup generates the client
120
+ pnpm add drizzle-orm pg
121
+ pnpm add -D drizzle-kit
121
122
  ```
122
123
 
123
- ```prisma
124
- // src/prisma/schema.prisma
125
- datasource db {
126
- provider = "postgresql"
127
- }
128
-
129
- generator client {
130
- provider = "prisma-client"
131
- output = "./client" // any value: nano-app generates in .nano-app/prisma-clients/app
132
- }
133
-
134
- model User {
135
- id Int @id @default(autoincrement())
136
- email String @unique
137
- }
124
+ ```ts
125
+ // src/db/schema.ts
126
+ import { pgTable, serial, text } from 'drizzle-orm/pg-core';
127
+
128
+ export const users = pgTable('users', {
129
+ id: serial().primaryKey(),
130
+ email: text().notNull().unique(),
131
+ });
138
132
  ```
139
133
 
140
134
  ```ts
141
135
  // src/app.ts — DATABASE_URL comes from the environment (or a .env file)
142
- createNanoApp({ mainPlugin: { database: { enabled: true } } });
136
+ import { createNanoApp } from '@nano-app/backend';
137
+ import * as schema from './db/schema';
138
+
139
+ createNanoApp({ mainPlugin: { database: { schema } } });
143
140
  ```
144
141
 
145
142
  ```ts
146
143
  // src/routes/users.route.ts
147
144
  import { createRoute } from '@nano-app/backend';
148
- import type { PrismaClient } from '../../.nano-app/prisma-clients/app/generated/client';
149
145
 
150
- export default createRoute<PrismaClient>({
151
- GET: async (_req, { db }) => ({ result: await db!.user.findMany() }),
146
+ export default createRoute({
147
+ GET: async (_req, { db }) => ({ result: await db.query.users.findMany() }),
152
148
  });
153
149
  ```
154
150
 
155
- | Option | Default | Description |
156
- |---|---|---|
157
- | `mainPlugin.database.enabled` | `false` | Generates and mounts your client at startup. |
158
- | `mainPlugin.database.schemaPath` | `"src/prisma/schema.prisma"` | Your single `.prisma` file; its datasource must be `postgresql`. |
159
-
160
- - Each key falls back to its default, so `{ enabled: true }` is enough with the default layout.
161
- - The client lives in `.nano-app/prisma-clients/app/` (add `.nano-app/` to `.gitignore`). It is generated again only when the schema changes. Do not run `prisma generate`: a second client would be out of sync.
162
- - The client exists after the first startup: start the app once before your editor or `tsc` can import its types. In a container, `.nano-app/` must be writable.
163
- - Startup fails if `DATABASE_URL` is missing, the schema is not a valid PostgreSQL schema, or a table or column of a model cannot be read. The message says which: apply your schema with Prisma and start again.
164
- - The `nano_` prefix is reserved for the official plugins (`@nano-app/plugins`): a datasource schema of yours starting with it fails the startup.
165
- - Pass your generated `PrismaClient` to `createRoute<PrismaClient>` to type `db`; otherwise it is `any`, and without an enabled database `undefined`.
166
- - For several PostgreSQL schemas, declare `schemas = ["public", "billing"]` in the datasource and `@@schema("billing")` on each model; one client covers them all.
167
- - Prisma is loaded only when your database or a mounted plugin's is enabled.
151
+ - `database.schema` is the module of your tables: every export that is a table, and `relations` (from `defineRelations`) when you relate them. A `pgSchema` the tables use is exported too, so drizzle-kit creates it.
152
+ - One pool serves the app and every plugin with a database. It connects on its first query: a missing table fails that query, not the startup.
153
+ - Startup fails if `DATABASE_URL` is missing.
154
+ - The `nano_` prefix is reserved for plugins: a table of yours in a `nano_*` Postgres schema fails the startup.
155
+ - Without a database, `db` is `undefined`.
156
+ - Drizzle and pg are loaded only when your database or a mounted plugin's is on.
168
157
 
169
158
  ## Better Auth
170
159
 
171
- Users, sign-in and sessions with [Better Auth](https://www.better-auth.com), on your database. On when your app has `src/auth.ts` (or the built `src/auth.js`), relative to `cwd`: its default export holds the options in `options` (everything but `database`: nano-app passes your Prisma client). Without the file it stays off.
160
+ Users, sign-in and sessions with [Better Auth](https://www.better-auth.com), on your database. On when your app has `src/auth.ts` (or the built `src/auth.js`), relative to `cwd`: its default export holds the options in `options` (everything but `database`: nano-app passes your Drizzle client and schema). Without the file it stays off.
172
161
 
173
162
  ```bash
174
163
  pnpm add better-auth
@@ -186,26 +175,26 @@ export default {
186
175
  ```ts
187
176
  // src/app.ts
188
177
  import { createNanoApp } from '@nano-app/backend';
178
+ import * as schema from './db/schema';
189
179
 
190
- createNanoApp({ mainPlugin: { database: { enabled: true } } });
180
+ createNanoApp({ mainPlugin: { database: { schema } } });
191
181
  ```
192
182
 
193
- Its tables are yours, like the rest of your schema. Add its models with its CLI, then apply them with Prisma; run both again when you change the options:
183
+ Its tables are yours, like the rest of your schema. Add them with its CLI, then apply them with drizzle-kit; run both again when you change the options:
194
184
 
195
185
  ```bash
196
- npx auth generate --adapter prisma --dialect postgresql --output src/prisma/schema.prisma
197
- npx prisma migrate dev
186
+ npx auth generate --adapter drizzle --dialect pg --output src/db/auth-schema.ts # then export its tables from src/db/schema.ts
187
+ pnpm drizzle-kit push
198
188
  ```
199
189
 
200
- - It needs the database: `src/auth.ts` without `mainPlugin.database.enabled` fails at startup, and so does a file without `options`.
201
- - Startup also fails if your schema lacks a model the options need (`user`, `session`, ...), naming them.
190
+ - It needs the database: `src/auth.ts` without `mainPlugin.database.schema` fails at startup, and so does a file without `options`.
191
+ - Startup also fails if your schema lacks a table the options need (`user`, `session`, ...), naming them.
202
192
  - Its endpoints are mounted with your routes, at its `basePath` (default `/api/auth`), and stay public.
203
193
  - In production, Better Auth limits sign-in, sign-up and password reset to a few attempts per client IP (its `rateLimit`): see [Client IP](#client-ip).
204
194
  - Every route of yours and of the `after` plugins needs a session: without one, 401. The `before` plugins stay public (a landing, an about page).
205
195
  - Handlers and `onBefore` receive the session in `params.session` (`{ user, session }`).
206
196
  - Sign-up works as Better Auth sets it: with `emailAndPassword` anyone can sign up and reach your routes. Close it with `disableSignUp: true`, or check roles in your handlers.
207
- - Relate your models to `User` in the same schema, as any Prisma relation. Extra user fields go in its `user.additionalFields`.
208
- - With several PostgreSQL schemas, add `@@schema(...)` to the generated models.
197
+ - Relate your tables to `user` in your `relations`. Extra user fields go in its `user.additionalFields`.
209
198
  - Set `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` in the environment, as Better Auth asks.
210
199
  - With it on, every route of the app and of the plugins mounted after it (`stage: 'after'`) needs a session: without one it answers 401 `Not authenticated`. The handlers receive it in `session`.
211
200
  - `createRoute({ public: true, ... })` answers without a session; its handlers still receive `session` when the request has one. An unknown path answers 404.
@@ -292,7 +281,7 @@ The endpoints of one plugin have no guaranteed order: one route's `onInit` must
292
281
 
293
282
  Everything is mounted on one Express application, in this order: base middlewares → `before` plugins → your routes → `after` plugins → 404 → error handler.
294
283
 
295
- A plugin with a database follows the same rules, with its client in `.nano-app/prisma-clients/<name>`: a plugin of yours points `database.schemaPath` at its schema and you apply it with Prisma. Two plugins with the same name and a database fail at startup. If that folder holds the client of another schema from an earlier start, the startup warns and replaces it. An official plugin of `@nano-app/plugins` needs nothing from you: at startup nano-app applies its migrations to its own `nano_*` schema. See [`@nano-app/plugins`](https://github.com/Nano-Nathan/nano-app/blob/main/plugins/README.md#database).
284
+ A plugin with a database passes its own `database: { schema }`, and keeps its tables in the Postgres schema `nano_<its name>`: a table elsewhere fails the startup. Two plugins with the same name and a database fail too. Without a database of its own, a plugin receives the app's `db`. See [`@nano-app/plugins`](https://github.com/Nano-Nathan/nano-app/blob/main/plugins/README.md#database).
296
285
 
297
286
  ## Errors
298
287
 
@@ -302,8 +291,8 @@ The framework mounts the 404 and the error handler: an unmatched request becomes
302
291
  throw { status: 409, message: 'Already exists' }; // → 409 { success: false, warning: false, message: "Already exists" }
303
292
  throw 'Field name is required'; // → 400 { success: false, message: "Field name is required" }
304
293
  throw new Error('boom'); // → 500 "Internal server error", logged to stderr
305
- // A Prisma error the request caused answers without its message, which names tables and columns:
306
- // P2025 → 404 "Not found", P2002 and P2003 → 409 "Conflict", validation → 400 "Invalid request"
294
+ // A Postgres error the request caused answers without its message, which names tables and columns:
295
+ // 23505 and 23503 → 409 "Conflict", 23502 and 22P02 → 400 "Invalid request"
307
296
  GET /unknown // → 404 { success: false, warning: false, message: "/unknown not found" }
308
297
  ```
309
298
 
@@ -314,7 +303,7 @@ GET /unknown // → 404 { success: false,
314
303
  | `createNanoApp(config?)` | Creates and starts the app. |
315
304
  | `createRoute(route)` | Types a route file. |
316
305
  | `createPlugin(creator)` | Types a plugin factory and its options. |
317
- | `createPluginCallbacks(callbacks)` | Types the callbacks of a plugin (`createPluginCallbacks<PrismaClient>` types `db`), e.g. to keep them in their own file. |
306
+ | `createPluginCallbacks(callbacks)` | Types the callbacks of a plugin (`createPluginCallbacks<{ db: MyDb }>` types `db`), e.g. to keep them in their own file. |
318
307
 
319
308
  Also exported: the `NanoApp` class that `createNanoApp` returns, the configuration types (`NanoAppConfig`, `PackagesConfig`, `PluginConfig`, `PluginStage`, `MainPluginConfig`, `DatabaseConfig`), the Express `Request` type and the HTTP contract (`ApiResponse`, `ApiPromise`, `QueryParams`, `RouteEntry`, `Handler`, `Handlers`, `NanoObject`) and the callback types (`Params`, `Callbacks`, `PluginCallbacks`).
320
309
 
package/build/index.d.ts CHANGED
@@ -17,13 +17,13 @@ type ApiResponse<T = any> = {
17
17
  type ApiPromise<T = any> = Promise<ApiResponse<T>>;
18
18
  /** Query parameters, sent as the JSON body of a `QUERY` request */
19
19
  type QueryParams = {
20
- skip?: number;
21
- top?: number;
22
- filter?: NanoObject;
23
- orderby?: NanoObject<'asc' | 'desc'>;
24
- select?: string;
25
- /** Relations to load, dot notation for nested ones: `"user,posts.comments"` */
26
- expand?: string;
20
+ /** Drizzle's relational filter: `{ title: { like: 'a%' } }` */
21
+ where?: NanoObject;
22
+ /** Columns to answer: `{ id: true, title: true }` */
23
+ columns?: NanoObject<boolean>;
24
+ orderBy?: NanoObject<'asc' | 'desc'>;
25
+ limit?: number;
26
+ offset?: number;
27
27
  };
28
28
  /** Extra params of handlers and callbacks: what the app injects (options, `db`) and the framework adds */
29
29
  type Params<Deps = NanoObject<any>> = Deps & {
@@ -87,12 +87,10 @@ declare class NanoError extends Error {
87
87
  /** Prefix of the framework's messages, shown before the message */
88
88
  get name(): string;
89
89
  }
90
- /** Database client configuration. */
90
+ /** Database of a plugin or of the app */
91
91
  type DatabaseConfig = {
92
- /** Whether the database is enabled. Defaults to false */
93
- enabled?: boolean;
94
- /** Prisma schema file (`.prisma`), relative to `cwd` */
95
- schemaPath: string;
92
+ /** Its Drizzle schema: the module of its tables, with its `relations` when it has them (`import * as schema from './db/schema'`) */
93
+ schema: NanoObject;
96
94
  };
97
95
  /** When a plugin is mounted, relative to the consumer routes */
98
96
  type PluginStage = 'before' | 'after';
@@ -106,7 +104,7 @@ type PluginConfig<Options = NanoObject> = {
106
104
  basePath?: string;
107
105
  /** Mount point relative to the consumer routes. Defaults to "before" */
108
106
  stage?: PluginStage;
109
- /** Prisma client the plugin needs, mounted before its endpoints */
107
+ /** Drizzle schema of the plugin's database, mounted before its endpoints */
110
108
  database?: DatabaseConfig;
111
109
  /** Plugin options */
112
110
  options?: Options;
@@ -119,13 +117,8 @@ type MainPluginConfig<Options = NanoObject> = {
119
117
  routesDir?: string;
120
118
  /** Base path prepended to every route, e.g. "/api" (no trailing slash). Defaults to none */
121
119
  basePath?: string;
122
- /** The project's database */
123
- database?: {
124
- /** Whether the database is enabled. Defaults to false */
125
- enabled?: boolean;
126
- /** Prisma schema file, relative to `cwd`. Defaults to "src/prisma/schema.prisma" */
127
- schemaPath?: string;
128
- };
120
+ /** Drizzle schema of the project's database; without it the app has none */
121
+ database?: DatabaseConfig;
129
122
  /** Plugin options */
130
123
  options?: Options;
131
124
  /** Plugin callbacks */
package/build/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { createRequire } from "node:module";
2
1
  import express from "express";
3
2
  import morgan from "morgan";
4
3
  import cors from "cors";
@@ -6,11 +5,9 @@ import helmet from "helmet";
6
5
  import compression from "compression";
7
6
  import "dotenv/config";
8
7
  import { glob } from "glob";
9
- import { dirname, isAbsolute, join, resolve } from "node:path";
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { isAbsolute, resolve } from "node:path";
9
+ import { existsSync } from "node:fs";
11
10
  import { createJiti } from "jiti";
12
- import { execFileSync } from "node:child_process";
13
- import { createHash } from "node:crypto";
14
11
  // ../core/utils/NanoError.ts
15
12
  /** Error of the framework: Node shows it as `[nano-app] [ERROR]: <message>`, and a handler answers its status */
16
13
  var NanoError = class extends Error {
@@ -27,31 +24,13 @@ var NanoError = class extends Error {
27
24
  };
28
25
  // ../core/files/FileReader.ts
29
26
  /** Reads and loads files from disk, resolved from `cwd` */
30
- var FileReader = class FileReader {
31
- /** Loader of the consumer's modules; the Prisma runtime is loaded natively */
32
- static oJiti = createJiti(import.meta.url, { nativeModules: ["@prisma/client"] });
27
+ var FileReader = class {
28
+ /** Loader of the consumer's modules */
29
+ static oJiti = createJiti(import.meta.url);
33
30
  /** Loads a module via jiti */
34
31
  static async loadModule(sFilePath, oOptions = {}) {
35
32
  return await this.oJiti.import(this.getAbsolutePath(sFilePath), oOptions);
36
33
  }
37
- /** Reads the contents of a file */
38
- static loadFile(sFilePath) {
39
- const sContent = readFileSync(this.getAbsolutePath(sFilePath), "utf-8");
40
- if (!sContent) throw new NanoError(`The file ${sFilePath} does not have valid content.`);
41
- return sContent;
42
- }
43
- /** Reads a Prisma schema without its `//` comments */
44
- static loadSchema(sFilePath) {
45
- return this.loadFile(sFilePath).replace(/\/\/.*$/gm, "");
46
- }
47
- /** Body of the `datasource` block of a Prisma schema, without its braces */
48
- static datasource(sContent) {
49
- return sContent.match(/datasource\s+\w+\s*\{([^}]*)\}/)?.[1] ?? "";
50
- }
51
- /** Schemas a Prisma schema declares in its datasource (`schemas = [...]`) */
52
- static schemas(sContent) {
53
- return [...(FileReader.datasource(sContent).match(/\bschemas\s*=\s*\[([^\]]*)\]/)?.[1] ?? "").matchAll(/["']([^"']+)["']/g)].map(([, sItem]) => sItem);
54
- }
55
34
  /** Resolves the absolute path of a file */
56
35
  static getAbsolutePath(sFilePath) {
57
36
  const sAbsolutePath = isAbsolute(sFilePath) ? sFilePath : resolve(process.cwd(), sFilePath);
@@ -62,10 +41,6 @@ var FileReader = class FileReader {
62
41
  // ../core/files/FileLoader.ts
63
42
  /** Finds and loads the consumer's files */
64
43
  var FileLoader = class {
65
- /** Loads a generated Prisma client module (`client.ts`, or `client.js`) */
66
- static async findPrismaClient(sClientPath) {
67
- return (await FileReader.loadModule(sClientPath))?.PrismaClient;
68
- }
69
44
  /** Finds `*.<extension>.ts` (or built `.js`) files in a folder (`src/<extension>s` by default) and returns `{ [subPath]: defaultExport }` */
70
45
  static async findMany(sExtension, sSourceDir) {
71
46
  const sSrcDir = (sSourceDir ?? `src/${sExtension}s`).replace(/^\.\//, "").replace(/\/$/, "");
@@ -91,8 +66,6 @@ const HTTP_METHODS = [
91
66
  "DELETE",
92
67
  "QUERY"
93
68
  ];
94
- /** Package of the official plugins: only its schemas may declare nano_* */
95
- const OFFICIAL_PLUGINS = "@nano-app/plugins";
96
69
  /** Loopback and private networks: the proxies the backend trusts by default */
97
70
  const PRIVATE_NETWORKS = [
98
71
  "127.0.0.0/8",
@@ -168,16 +141,6 @@ var Validator = class Validator {
168
141
  return Validator.isPath(match) && Validator.isPath(redirect) && !Paths.matches(match, redirect) && (roles === void 0 || typeof roles === "string") && (permissions === void 0 || Validator.isStringMap(permissions));
169
142
  });
170
143
  }
171
- /** Whether a Prisma schema, without comments, is PostgreSQL and declares the schemas it may */
172
- static isPrisma(sContent, sFilePath, isPlugin) {
173
- const sProvider = FileReader.datasource(sContent).match(/\bprovider\s*=\s*["']([^"']+)["']/)?.[1];
174
- if (sProvider !== "postgresql") throw new NanoError(`${sFilePath}: the datasource provider must be "postgresql"${sProvider ? `, not "${sProvider}"` : ""}.`);
175
- const aSchemas = FileReader.schemas(sContent);
176
- const isPluginInvalid = !aSchemas.length || aSchemas.some((sItem) => !sItem.startsWith("nano_"));
177
- const isConsumerInvalid = aSchemas.some((sItem) => sItem.startsWith("nano_"));
178
- if (isPlugin ? isPluginInvalid : isConsumerInvalid) throw new NanoError(`${sFilePath}: ${isPlugin ? "a plugin declares only nano_* schemas" : "the nano_* schemas are reserved for plugins"}.`);
179
- return true;
180
- }
181
144
  };
182
145
  // ../core/utils/Logger.ts
183
146
  /** Messages of the framework on the console */
@@ -206,7 +169,7 @@ var Logger = class Logger {
206
169
  // src/auth/BetterAuth.ts
207
170
  /** Files of the app's Better Auth options, relative to `cwd`: source, then built */
208
171
  const AUTH_FILES = ["src/auth.ts", "src/auth.js"];
209
- /** Better Auth on the app's Prisma client: the instance, its handler and the session lookup */
172
+ /** Better Auth on the app's Drizzle client: the instance, its handler and the session lookup */
210
173
  var BetterAuth = class {
211
174
  static oAuth;
212
175
  /** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
@@ -221,14 +184,14 @@ var BetterAuth = class {
221
184
  static get() {
222
185
  return this.oAuth;
223
186
  }
224
- /** Creates the instance on the app's client, its handler and the session lookup */
225
- static async create(oOptions, oDb) {
187
+ /** Creates the instance on the app's client and schema, its handler and the session lookup */
188
+ static async create(oOptions, oDb, oSchema) {
226
189
  const { betterAuth } = await import("better-auth");
227
- const { prismaAdapter } = await import("better-auth/adapters/prisma");
190
+ const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
228
191
  const { getSchema } = await import("better-auth/db");
229
192
  const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
230
- const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oDb[sModel]);
231
- if (aMissing.length) throw new NanoError(`Better Auth needs the models ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with Prisma.`);
193
+ const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oSchema[sModel]);
194
+ if (aMissing.length) throw new NanoError(`Better Auth needs the tables ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with drizzle-kit.`);
232
195
  const oAuth = betterAuth({
233
196
  ...oOptions,
234
197
  advanced: {
@@ -238,7 +201,10 @@ var BetterAuth = class {
238
201
  ...oOptions.advanced?.ipAddress
239
202
  }
240
203
  },
241
- database: prismaAdapter(oDb, { provider: "postgresql" })
204
+ database: drizzleAdapter(oDb, {
205
+ provider: "pg",
206
+ schema: oSchema
207
+ })
242
208
  });
243
209
  this.oAuth = oAuth;
244
210
  const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
@@ -348,111 +314,43 @@ var Router = class {
348
314
  return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
349
315
  }
350
316
  };
351
- // src/database/Prisma.ts
352
- /** Runs the consumer's Prisma CLI: generates clients and applies official plugins' migrations */
353
- var Prisma = class {
354
- /** Folder of a client and its files, in the project */
355
- static dir(sName) {
356
- return join(process.cwd(), ".nano-app", "prisma-clients", sName);
357
- }
358
- /** Generates the client of a schema in `.nano-app/prisma-clients/<name>`, unless it is up to date */
359
- static generate(sName, sSchemaPath) {
360
- const sDir = this.dir(sName);
361
- const sSource = FileReader.getAbsolutePath(sSchemaPath);
362
- const sHash = createHash("sha256").update(readFileSync(sSource)).digest("hex");
363
- const sSourceFile = join(sDir, "source.json");
364
- const oCurrent = existsSync(sSourceFile) ? JSON.parse(readFileSync(sSourceFile, "utf-8")) : void 0;
365
- if (oCurrent && oCurrent.schemaPath !== sSource) Logger.warn(`.nano-app/prisma-clients/${sName} has the client of ${oCurrent.schemaPath}: it is replaced by the one of ${sSource}.`);
366
- if (oCurrent?.schemaPath === sSource && oCurrent.hash === sHash && existsSync(join(sDir, "generated", "client.ts"))) return;
367
- mkdirSync(sDir, { recursive: true });
368
- const sSchema = readFileSync(sSource, "utf-8");
369
- writeFileSync(join(sDir, "schema.prisma"), sSchema.replace(/output\s*=\s*"[^"]*"/, "output = \"./generated\""));
370
- this.run([
371
- "generate",
372
- "--schema",
373
- join(sDir, "schema.prisma")
374
- ]);
375
- writeFileSync(sSourceFile, JSON.stringify({
376
- schemaPath: sSource,
377
- hash: sHash
378
- }));
379
- Logger.info(`Prisma client "${sName}" generated`);
380
- }
381
- /** Applies the pending migrations an official plugin ships next to its schema */
382
- static migrate(sName, sSchemaPath, sDbSchema) {
383
- const sMigrations = join(dirname(FileReader.getAbsolutePath(sSchemaPath)), "migrations");
384
- if (!existsSync(sMigrations)) throw new NanoError(`The plugin "${sName}" ships no migrations: ${sMigrations}`);
385
- const oUrl = new URL(process.env.DATABASE_URL ?? "");
386
- oUrl.searchParams.set("schema", sDbSchema);
387
- const sConfig = join(this.dir(sName), "prisma.config.mjs");
388
- writeFileSync(sConfig, [
389
- "export default {",
390
- ` schema: ${JSON.stringify(join(this.dir(sName), "schema.prisma"))},`,
391
- ` migrations: { path: ${JSON.stringify(sMigrations)} },`,
392
- " datasource: { url: process.env.NANO_APP_MIGRATION_URL },",
393
- "};",
394
- ""
395
- ].join("\n"));
396
- this.run([
397
- "migrate",
398
- "deploy",
399
- "--config",
400
- sConfig
401
- ], { NANO_APP_MIGRATION_URL: oUrl.toString() });
402
- }
403
- /** Runs a Prisma command of the consumer's `prisma` package */
404
- static run(aArgs, oEnv = {}) {
405
- const sCli = createRequire(`${process.cwd()}/`).resolve("prisma/build/index.js");
406
- execFileSync(process.execPath, [sCli, ...aArgs], {
407
- env: {
408
- ...process.env,
409
- ...oEnv
410
- },
411
- stdio: "pipe"
412
- });
413
- }
414
- };
415
317
  // src/database/Database.ts
416
- /** Prisma clients of the app and its plugins, one per name */
318
+ /** Drizzle clients of the app and its plugins, one per name, on one pool of `DATABASE_URL` */
417
319
  var Database = class {
418
- static oAdapter;
320
+ static oPool;
419
321
  /** Mounted clients, by name */
420
322
  static oClients = {};
421
- /** Initializes the Database client */
422
- static async init() {
423
- const sUrl = process.env.DATABASE_URL;
424
- if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
425
- const { PrismaPg } = await import("@prisma/adapter-pg");
426
- this.oAdapter = new PrismaPg({ connectionString: sUrl });
427
- }
428
323
  /** Gets a client by name */
429
324
  static get(sName) {
430
325
  return this.oClients[sName];
431
326
  }
432
- /** Generates and mounts the Prisma client of a schema; an official plugin's is also migrated */
433
- static async mount(sName, { schemaPath }) {
327
+ /** Mounts the Drizzle client of a schema: the app's tables outside `nano_*`, a plugin's only in `nano_<name>` */
328
+ static async mount(sName, { schema }) {
434
329
  if (this.get(sName)) throw new NanoError(`The database "${sName}" is already mounted.`);
435
- const isPlugin = this.isOfficial(schemaPath);
436
- const sContent = FileReader.loadSchema(schemaPath);
437
- Validator.isPrisma(sContent, schemaPath, isPlugin);
438
- Prisma.generate(sName, schemaPath);
439
- if (!this.oAdapter) await this.init();
440
- if (isPlugin) Prisma.migrate(sName, schemaPath, FileReader.schemas(sContent)[0]);
441
- const sClientPath = join(Prisma.dir(sName), "generated", "client.ts");
442
- const oClient = new (await (FileLoader.findPrismaClient(sClientPath)))({ adapter: this.oAdapter });
443
- for (const [, sModel] of sContent.matchAll(/^\s*model\s+(\w+)/gm)) await oClient[`${sModel[0].toLowerCase()}${sModel.slice(1)}`].findFirst();
330
+ const { defineRelations, is } = await import("drizzle-orm");
331
+ const { getTableConfig, PgTable } = await import("drizzle-orm/pg-core");
332
+ const { drizzle } = await import("drizzle-orm/node-postgres");
333
+ const sOwn = `nano_${sName}`;
334
+ const isApp = sName === "app";
335
+ for (const oTable of Object.values(schema).filter((oValue) => is(oValue, PgTable))) {
336
+ const { name, schema: sSchema = "public" } = getTableConfig(oTable);
337
+ if (isApp ? sSchema.startsWith("nano_") : sSchema !== sOwn) throw new NanoError(`The database "${sName}": table "${sSchema}.${name}" breaks the rule, ${isApp ? "the nano_* schemas are reserved for plugins" : `a plugin keeps its tables in "${sOwn}"`}.`);
338
+ }
339
+ this.oPool ??= await this.pool();
340
+ const oClient = drizzle({
341
+ client: this.oPool,
342
+ relations: schema.relations ?? defineRelations(schema)
343
+ });
444
344
  this.oClients[sName] = oClient;
445
345
  Logger.info(`Database "${sName}" mounted`);
446
346
  return oClient;
447
347
  }
448
- /** Whether a schema ships with the official plugins: its nearest package.json is theirs */
449
- static isOfficial(sSchemaPath) {
450
- let sDir = dirname(FileReader.getAbsolutePath(sSchemaPath));
451
- while (!existsSync(join(sDir, "package.json"))) {
452
- if (dirname(sDir) === sDir) return false;
453
- sDir = dirname(sDir);
454
- }
455
- return JSON.parse(readFileSync(join(sDir, "package.json"), "utf-8")).name === OFFICIAL_PLUGINS;
348
+ /** The pool of `DATABASE_URL`, shared by every client */
349
+ static async pool() {
350
+ const sUrl = process.env.DATABASE_URL;
351
+ if (!sUrl) throw new NanoError("The DATABASE_URL environment variable is not defined.");
352
+ const { Pool } = await import("pg");
353
+ return new Pool({ connectionString: sUrl });
456
354
  }
457
355
  };
458
356
  // src/routes/Plugin.ts
@@ -468,10 +366,10 @@ var Plugin = class {
468
366
  /** Mounts its endpoints on express app */
469
367
  async mount(oApp) {
470
368
  const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
471
- const oDb = database?.enabled ? await Database.mount(name, database) : Database.get("app");
369
+ const oDb = database ? await Database.mount(name, database) : Database.get("app");
472
370
  if (this.oAuthOptions) {
473
- if (!oDb) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.enabled to true.");
474
- const { sPath, oHandler, fnSession } = await BetterAuth.create(this.oAuthOptions, oDb);
371
+ if (!database) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.schema.");
372
+ const { sPath, oHandler, fnSession } = await BetterAuth.create(this.oAuthOptions, oDb, database.schema);
475
373
  oApp.all(sPath, oHandler);
476
374
  oApp.use(fnSession);
477
375
  }
@@ -519,11 +417,7 @@ const INITIAL_CONFIG = {
519
417
  /** Main plugin to mount */
520
418
  mainPlugin: {
521
419
  routesDir: "src/routes",
522
- basePath: "",
523
- database: {
524
- enabled: false,
525
- schemaPath: "src/prisma/schema.prisma"
526
- }
420
+ basePath: ""
527
421
  },
528
422
  /** Plugins around the consumer routes */
529
423
  plugins: []
@@ -555,26 +449,30 @@ function mergeConfig(oConfig = {}) {
555
449
  };
556
450
  }
557
451
  // src/app/errors.ts
558
- /** Prisma errors the request caused; their own messages name tables and columns */
559
- const PRISMA_ERRORS = {
560
- P2025: {
561
- status: 404,
562
- message: "Not found"
563
- },
564
- P2002: {
452
+ /** Database errors the request caused, by their Postgres code or Drizzle's name; their own messages name tables and columns */
453
+ const DATABASE_ERRORS = {
454
+ "23505": {
565
455
  status: 409,
566
456
  message: "Conflict"
567
457
  },
568
- P2003: {
458
+ "23503": {
569
459
  status: 409,
570
460
  message: "Conflict"
571
461
  },
572
- PrismaClientValidationError: {
462
+ "23502": {
463
+ status: 400,
464
+ message: "Invalid request"
465
+ },
466
+ "22P02": {
467
+ status: 400,
468
+ message: "Invalid request"
469
+ },
470
+ DrizzleError: {
573
471
  status: 400,
574
472
  message: "Invalid request"
575
473
  }
576
474
  };
577
- /** Whether anything thrown is an object, read as a HandlerError or a Prisma error */
475
+ /** Whether anything thrown is an object, read as a HandlerError or a Drizzle error with its Postgres cause */
578
476
  function isHandlerError(oError) {
579
477
  return typeof oError === "object" && oError !== null;
580
478
  }
@@ -588,7 +486,8 @@ function normalize(oError) {
588
486
  }
589
487
  };
590
488
  const oThrown = isHandlerError(oError) ? oError : {};
591
- const { status, message, warning } = PRISMA_ERRORS[oThrown.code ?? oThrown.name ?? ""] ?? oThrown;
489
+ const sKey = oThrown.cause?.code ?? oThrown.name ?? "";
490
+ const { status, message, warning } = DATABASE_ERRORS[sKey] ?? oThrown;
592
491
  const iStatus = status ?? 500;
593
492
  return {
594
493
  status: iStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano-app/backend",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Express server with file-convention routes, plugins and an optional PostgreSQL layer",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -27,7 +27,7 @@
27
27
  "plugins",
28
28
  "file-based-routing",
29
29
  "postgresql",
30
- "prisma"
30
+ "drizzle"
31
31
  ],
32
32
  "author": "Cristian Serrano",
33
33
  "license": "MIT",
@@ -53,19 +53,15 @@
53
53
  "morgan": "^1.12.1"
54
54
  },
55
55
  "peerDependencies": {
56
- "@prisma/adapter-pg": "^7.10.1",
57
- "@prisma/client": "^7.10.1",
58
56
  "better-auth": "^1.7.18",
57
+ "drizzle-orm": "^1.0.0-rc.5",
59
58
  "pg": "^8.23.1"
60
59
  },
61
60
  "peerDependenciesMeta": {
62
- "@prisma/adapter-pg": {
63
- "optional": true
64
- },
65
- "@prisma/client": {
61
+ "better-auth": {
66
62
  "optional": true
67
63
  },
68
- "better-auth": {
64
+ "drizzle-orm": {
69
65
  "optional": true
70
66
  },
71
67
  "pg": {
@@ -73,8 +69,6 @@
73
69
  }
74
70
  },
75
71
  "devDependencies": {
76
- "@prisma/adapter-pg": "^7.10.1",
77
- "@prisma/client": "^7.10.1",
78
72
  "@types/compression": "^1.8.1",
79
73
  "@types/cors": "^2.8.19",
80
74
  "@types/express": "^5.0.6",
@@ -82,8 +76,9 @@
82
76
  "@types/node": "^26.6.6",
83
77
  "@types/pg": "^8.20.0",
84
78
  "better-auth": "^1.7.18",
79
+ "drizzle-kit": "^1.0.0-rc.5",
80
+ "drizzle-orm": "^1.0.0-rc.5",
85
81
  "pg": "^8.23.1",
86
- "prisma": "^7.10.1",
87
82
  "tsdown": "^0.23.5",
88
83
  "tsx": "^4.23.15",
89
84
  "typescript": "^7.0.2"